From 8d6cc7d2d96bbbca096feb71ad595b862067564d Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 17 Sep 2026 12:25:19 -0700 Subject: [PATCH 1/8] docs: propose catalog access-review RPC Signed-off-by: Cody Hartsook --- design/EP-2860-access-review-rpc.md | 197 ++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 design/EP-2860-access-review-rpc.md diff --git a/design/EP-2860-access-review-rpc.md b/design/EP-2860-access-review-rpc.md new file mode 100644 index 000000000..0c2bdfdd4 --- /dev/null +++ b/design/EP-2860-access-review-rpc.md @@ -0,0 +1,197 @@ +# EP-2860: Access review for catalog actions + +* Issue: [#2860](https://github.com/kagent-dev/kagent/issues/2860) + +## Summary + +Add an authenticated, read-only `AuthorizationService.CheckAccess` RPC that lets a client ask whether the current caller may perform a catalog action. The answer is advisory: the corresponding catalog RPC remains the authoritative enforcement point and must authorize again when it runs. + +This extends [EP-1270](EP-1270-scoped-authorization.md) without putting capability fields back into `AgentTemplate`, `Harness`, or `ModelConfig` responses. + +## Motivation + +EP-1270 deliberately removed `can_create`, `can_update`, and `can_delete` fields from catalog responses. Embedded hints duplicate policy decisions, become stale with the resource that carried them, and couple resource schemas to presentation behavior. + +The UI still needs a way to explain unavailable actions before a caller submits a write. A dedicated review request keeps that concern separate from catalog data and makes its advisory lifetime explicit. + +### Goals + +- Review `GET`, `CREATE`, `UPDATE`, and `DELETE` for the protected catalog resources in EP-1270. +- Use the authenticated request principal and the same canonical resource types, verbs, and attributes as the corresponding operation. +- Support a namespace-level create review before a proposed name is known. +- Keep every real catalog operation authoritative and unchanged. +- Make the review available to browser clients through the generated TypeScript API. +- Fail closed without revealing whether a named resource exists. + +### Non-goals + +- Return policy rules, roles, claims, scopes, or denial explanations. +- Review `LIST`; partial collection access is not representable by one Boolean. +- Add capability fields to catalog resources or list responses. +- Cache an authorization result on the server or turn it into a grant. +- Expand authorization to resources outside `AgentTemplate`, `Harness`, and `ModelConfig`. +- Batch reviews in the first version. Add batching only if measured UI request volume requires it. + +## API + +Add `proto/kagent/api/v1alpha1/authorization.proto`: + +```proto +service AuthorizationService { + rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); +} + +enum AccessReviewResourceType { + ACCESS_REVIEW_RESOURCE_TYPE_UNSPECIFIED = 0; + ACCESS_REVIEW_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + ACCESS_REVIEW_RESOURCE_TYPE_HARNESS = 2; + ACCESS_REVIEW_RESOURCE_TYPE_MODEL_CONFIG = 3; +} + +enum AccessReviewVerb { + ACCESS_REVIEW_VERB_UNSPECIFIED = 0; + ACCESS_REVIEW_VERB_GET = 1; + ACCESS_REVIEW_VERB_CREATE = 2; + ACCESS_REVIEW_VERB_UPDATE = 3; + ACCESS_REVIEW_VERB_DELETE = 4; +} + +message CheckAccessRequest { + AccessReviewResourceType resource_type = 1; + AccessReviewVerb verb = 2; + string namespace = 3; + optional string name = 4; +} + +message CheckAccessResponse { + bool allowed = 1; +} +``` + +The source proto owns request validation: + +- `resource_type` and `verb` must be defined, non-zero enum values. +- `namespace` is a required DNS-1123 subdomain. +- A present `name` is a non-empty DNS-1123 subdomain. Absence is distinct from an empty string. +- `name` may be absent only for `CREATE`. Reads, updates, and deletes address a concrete resource. +- The supported operation matrix is: + + | Resource | Verbs | + | --- | --- | + | `AgentTemplate` | `GET`, `CREATE`, `UPDATE`, `DELETE` | + | `Harness` | `CREATE`, `DELETE` | + | `ModelConfig` | `GET`, `CREATE`, `UPDATE`, `DELETE` | + +Use standard `buf.validate` rules first and message CEL only for the name/verb and resource/verb combinations. + +`optional string name` is intentional. Treating an empty string as “any name” would make a malformed named request silently broader. + +## Review semantics + +### Named review + +For a present `name`, construct the same `auth.Resource` identity the catalog operation uses and evaluate the operation's authorization checks without reading Kubernetes. + +- `GET`, `CREATE`, and `DELETE` evaluate their matching `auth.Verb`. +- `UPDATE` evaluates every authorization prerequisite of the real update flow. After #2859, `AgentTemplate` and `ModelConfig` updates require both the read and update checks, so the review is allowed only when both checks allow the same named resource. +- An authorizer rejection returns `allowed: false`; it is not a failed RPC. The existing `Authorizer.Check` contract represents every denial as an error and has no separate backend-failure category, so the review treats any `Check` error the same way the catalog services do: denied. + +This is an advisory check against the proposed reference, not trusted evidence for a mutation. The later catalog RPC still validates or loads the real resource and authorizes it independently. + +### Namespace-level create review + +When `name` is absent, request the `CREATE` scope for the resource type and ask whether at least one valid name in the requested namespace can satisfy it: + +- `ALL` allows. +- `NONE` denies. +- `ANY_OF` allows when at least one clause accepts the namespace and has at least one satisfiable name after all `name IN (...)` predicates in that clause are intersected. + +Add this as a semantic operation on the existing compiled `kubeauth.Matcher`; do not duplicate scope parsing in the access-review service. Invalid scope output remains an internal failure, and an authorizer backend failure remains unavailable. + +### Error behavior + +- Missing authenticated session: `Unauthenticated`. +- Invalid request or unsupported resource/verb combination: `InvalidArgument` through Protovalidate. +- Policy rejection: successful response with `allowed: false`. +- Failure to obtain a scope: `Unavailable`. +- Malformed scope returned by an authorizer: `Internal`. + +The API intentionally returns no denial reason. Exposing backend-specific policy explanations would couple the public contract to an authorizer and can leak policy details. + +## Backend implementation + +1. Define canonical catalog resource-type constants beside `auth.Resource` and replace the current repeated string literals in model and kubecrud wiring. +2. Add a transport-independent `go/core/internal/service/accessreview` service over `auth.CollectionAuthorizer`. +3. Add the thin gRPC adapter that maps protobuf enums to the existing auth verbs and canonical resource types. +4. Register the service in `grpcserver.Config` and `app.Run`. +5. Add `AuthorizationService.CheckAccess` to `DefaultMethodPolicies` as `AccessRead`, so authentication runs before the handler while the requested catalog verb remains data evaluated by the service. +6. Regenerate Go and TypeScript protobuf outputs from the source proto. +7. Update EP-1270 and the scoped-authorization development guide to distinguish rejected embedded hints from the explicit advisory review API. + +No Kubernetes client, database, new dependency, or server-side cache is needed. + +## UI integration + +Add the first bundled UI caller in the same PR as the RPC. Keep the API/core and UI work in separate commits so the generated contract and policy semantics can still be reviewed before the presentation changes. + +1. Add one stable `authorization.checkAccess` operation and an SWR-backed hook keyed by resource type, verb, namespace, and optional name. +2. Use exact-name reviews for edit, save, and delete controls. Use the namespace-level create review only where the namespace is known before the name. +3. Disable rather than hide unavailable actions and provide an accessible explanation. Direct navigation to a form must also review the submit action. +4. Do not treat a review error as a denial. Leave the action available and let the authoritative operation report its result; optionally show the review failure as advisory UI state. +5. Continue handling `PermissionDenied` from every mutation because a prior allowed result can become stale immediately. +6. Avoid eager checks for every off-screen table row. Review the actions rendered on the current page or when an action surface opens; add a batch RPC only if this is still measurably expensive. + +The mock transport must model allowed, denied, and failed reviews rather than defaulting every check to allowed. + +## Delivery plan + +Ship one end-to-end PR so the new RPC has a real caller in the same change: + +1. Proto contract, validation, generated Go/TypeScript artifacts, canonical resource mapping, access-review service, namespace-level create-scope matching, gRPC/app wiring, and backend tests. +2. Stable UI operation, hook, mock implementation, access-aware controls, and UI tests. +3. Documentation updates and full focused validation. + +Before implementation, rebase this branch onto `upstream/main` after #2859 merges so `UPDATE` reviews mirror the final read/write authorization sequence without stacking #2860 on the active PR. + +## Test plan + +### Semantic unit tests + +- Named reviews pass the authenticated principal, canonical type, namespace, name, and expected verb sequence. +- `UPDATE` requires every check used by the corresponding update operation. +- Namespace-level create handles `ALL`, `NONE`, namespace-only clauses, name-only clauses, namespace/name conjunctions, OR clauses, and intersecting repeated name predicates. +- Unsatisfiable or malformed scopes fail closed. +- A missing session is unauthenticated and a policy rejection is `allowed: false`. + +### gRPC and generation checks + +- Protovalidate rejects unspecified enums, bad DNS names, a missing name for non-create verbs, and unsupported resource/verb pairs before the handler runs. +- The method policy requires authentication. +- An in-process gRPC server with a scoped test authorizer proves named denial, namespace-level allow/deny, and that a successful review does not bypass a later denied mutation. +- `buf lint`, `buf generate`, and the repository generated-output check pass. + +### End-to-end + +- The default OSS authorizer returns `allowed: true` for every supported review. +- UI tests cover the non-authoritative behavior described above. + +Run at minimum: + +```bash +make proto-lint +make proto-check +make -C go test +make -C go lint +(cd ui && yarn typecheck && yarn test && yarn lint) +``` + +Run the focused Go and UI tests first, then the relevant Playwright and Kind E2E cases before the PR is ready to merge. + +## Alternatives + +- **Capability fields on catalog responses:** rejected by EP-1270 because they duplicate policy decisions and age with unrelated resource data. +- **Calling the mutation and handling `PermissionDenied`:** remains mandatory as enforcement, but is a worse first interaction when the UI can cheaply ask in advance. +- **Returning the full authorization scope:** rejected because it exposes policy representation and makes every client implement the matcher. +- **String resource types and verbs:** rejected because the supported set is closed and protobuf enums let Protovalidate reject unknown values before service code. +- **Omitted name for every verb:** deferred. Current UI reads, updates, and deletes concrete resources; only creation has a real action before a name exists. Broader existential semantics can be added with a demonstrated caller. +- **Batch review in v1:** deferred until request volume is measured. From 812dbd5b777eb806299f3833cd91f902c0269895 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 17 Sep 2026 13:09:19 -0700 Subject: [PATCH 2/8] docs: propose batched access-review matrix Signed-off-by: Cody Hartsook --- design/EP-2860-access-review-rpc.md | 391 ++++++++++++++++++---------- 1 file changed, 257 insertions(+), 134 deletions(-) diff --git a/design/EP-2860-access-review-rpc.md b/design/EP-2860-access-review-rpc.md index 0c2bdfdd4..f34c49103 100644 --- a/design/EP-2860-access-review-rpc.md +++ b/design/EP-2860-access-review-rpc.md @@ -1,197 +1,320 @@ -# EP-2860: Access review for catalog actions +# EP-2860: Batched access review for catalog actions -* Issue: [#2860](https://github.com/kagent-dev/kagent/issues/2860) +> **Discussion draft:** keep this document in the draft PR while the API is being +> reviewed, then remove it before merge. -## Summary - -Add an authenticated, read-only `AuthorizationService.CheckAccess` RPC that lets a client ask whether the current caller may perform a catalog action. The answer is advisory: the corresponding catalog RPC remains the authoritative enforcement point and must authorize again when it runs. - -This extends [EP-1270](EP-1270-scoped-authorization.md) without putting capability fields back into `AgentTemplate`, `Harness`, or `ModelConfig` responses. - -## Motivation +* OSS issue: [kagent-dev/kagent#2860](https://github.com/kagent-dev/kagent/issues/2860) +* Enterprise context: [solo-io/enterprise-kagent#95](https://github.com/solo-io/enterprise-kagent/issues/95) -EP-1270 deliberately removed `can_create`, `can_update`, and `can_delete` fields from catalog responses. Embedded hints duplicate policy decisions, become stale with the resource that carried them, and couple resource schemas to presentation behavior. +## Summary -The UI still needs a way to explain unavailable actions before a caller submits a write. A dedicated review request keeps that concern separate from catalog data and makes its advisory lifetime explicit. +Add an authenticated `AuthorizationService.CheckAccess` RPC that returns an +advisory permission matrix for catalog actions. One request reviews several +namespaced targets of one resource type against several verbs. -### Goals +This replaces the UX purpose of the earlier `canCreate`, `canUpdate`, and +`canDelete` response fields without embedding authorization state in catalog +resources. The enterprise UI can decide whether to hide or disable an action, +while every catalog operation continues to authorize the real request. -- Review `GET`, `CREATE`, `UPDATE`, and `DELETE` for the protected catalog resources in EP-1270. -- Use the authenticated request principal and the same canonical resource types, verbs, and attributes as the corresponding operation. -- Support a namespace-level create review before a proposed name is known. -- Keep every real catalog operation authoritative and unchanged. -- Make the review available to browser clients through the generated TypeScript API. -- Fail closed without revealing whether a named resource exists. +## Goals -### Non-goals +- Let a UI decide whether to present create, get, update, and delete actions. +- Preserve the earlier `canCreate` behavior before the user starts filling in a + form. +- Review a whole page of resource actions in one browser request. +- Reuse the authenticated principal, resource names, verbs, and authorization + scopes already used by catalog services. +- Keep the default OSS authorizer behavior unchanged. +- Keep access-review results advisory and independent from mutation enforcement. -- Return policy rules, roles, claims, scopes, or denial explanations. -- Review `LIST`; partial collection access is not representable by one Boolean. -- Add capability fields to catalog resources or list responses. -- Cache an authorization result on the server or turn it into a grant. -- Expand authorization to resources outside `AgentTemplate`, `Harness`, and `ModelConfig`. -- Batch reviews in the first version. Add batching only if measured UI request volume requires it. +## Non-goals -## API +- Add capability fields to catalog list or item responses. +- Return roles, policies, claims, denial reasons, catalog keys, or raw scopes. +- Review `LIST`; partial collection visibility is not representable by one + Boolean. +- Read Kubernetes resources as part of a review. +- Cache decisions on the server or turn a successful review into a grant. +- Add access-aware behavior to the OSS UI. The consumer is the enterprise UI. -Add `proto/kagent/api/v1alpha1/authorization.proto`: +## Proposed API ```proto service AuthorizationService { rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); } -enum AccessReviewResourceType { - ACCESS_REVIEW_RESOURCE_TYPE_UNSPECIFIED = 0; - ACCESS_REVIEW_RESOURCE_TYPE_AGENT_TEMPLATE = 1; - ACCESS_REVIEW_RESOURCE_TYPE_HARNESS = 2; - ACCESS_REVIEW_RESOURCE_TYPE_MODEL_CONFIG = 3; +enum AuthorizationResourceType { + AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; + AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; + AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; +} + +enum AuthorizationVerb { + AUTHORIZATION_VERB_UNSPECIFIED = 0; + AUTHORIZATION_VERB_GET = 1; + AUTHORIZATION_VERB_CREATE = 2; + AUTHORIZATION_VERB_UPDATE = 3; + AUTHORIZATION_VERB_DELETE = 4; } -enum AccessReviewVerb { - ACCESS_REVIEW_VERB_UNSPECIFIED = 0; - ACCESS_REVIEW_VERB_GET = 1; - ACCESS_REVIEW_VERB_CREATE = 2; - ACCESS_REVIEW_VERB_UPDATE = 3; - ACCESS_REVIEW_VERB_DELETE = 4; +message AccessTarget { + string namespace = 1; + optional string name = 2; } message CheckAccessRequest { - AccessReviewResourceType resource_type = 1; - AccessReviewVerb verb = 2; - string namespace = 3; - optional string name = 4; + AuthorizationResourceType resource_type = 1; + repeated AuthorizationVerb verbs = 2; + repeated AccessTarget targets = 3; } message CheckAccessResponse { - bool allowed = 1; + repeated ResourceAccess results = 1; +} + +message ResourceAccess { + AccessTarget target = 1; + repeated AuthorizationVerb allowed_verbs = 2; } ``` -The source proto owns request validation: +One request is homogeneous by resource type. That matches the common UI surfaces +(a template list, a model list, or a harness list) and lets the server obtain one +authorization scope per requested verb. A screen containing multiple catalog +resource types can issue at most three requests in parallel. + +`results` has the same order and cardinality as `targets`. Echoing the target also +makes the response self-describing and avoids string-encoding a namespaced name as +a protobuf map key. + +### Validation -- `resource_type` and `verb` must be defined, non-zero enum values. -- `namespace` is a required DNS-1123 subdomain. -- A present `name` is a non-empty DNS-1123 subdomain. Absence is distinct from an empty string. -- `name` may be absent only for `CREATE`. Reads, updates, and deletes address a concrete resource. -- The supported operation matrix is: +Declare request-intrinsic validation in the source proto with `buf.validate`: - | Resource | Verbs | +- `resource_type` must be a defined, non-zero enum value. +- `verbs` must contain between one and four unique, defined, non-zero values. +- `targets` must contain between one and 100 entries. +- Every namespace is a required Kubernetes DNS label. +- A present name is a non-empty Kubernetes DNS subdomain. +- Unsupported resource/verb combinations are rejected. The initial matrix is: + + | Resource type | Verbs | | --- | --- | | `AgentTemplate` | `GET`, `CREATE`, `UPDATE`, `DELETE` | | `Harness` | `CREATE`, `DELETE` | | `ModelConfig` | `GET`, `CREATE`, `UPDATE`, `DELETE` | -Use standard `buf.validate` rules first and message CEL only for the name/verb and resource/verb combinations. - -`optional string name` is intentional. Treating an empty string as “any name” would make a malformed named request silently broader. - -## Review semantics +The limit bounds one request to at most 400 Boolean decisions. It is large enough +for the current 25-row UI pages and prevents an access-review call from becoming +an unbounded policy-evaluation endpoint. -### Named review +## Semantics -For a present `name`, construct the same `auth.Resource` identity the catalog operation uses and evaluate the operation's authorization checks without reading Kubernetes. +### Named targets -- `GET`, `CREATE`, and `DELETE` evaluate their matching `auth.Verb`. -- `UPDATE` evaluates every authorization prerequisite of the real update flow. After #2859, `AgentTemplate` and `ModelConfig` updates require both the read and update checks, so the review is allowed only when both checks allow the same named resource. -- An authorizer rejection returns `allowed: false`; it is not a failed RPC. The existing `Authorizer.Check` contract represents every denial as an error and has no separate backend-failure category, so the review treats any `Check` error the same way the catalog services do: denied. +For a target with `name`, a verb is returned in `allowed_verbs` when the caller's +action scope matches the exact `(resource type, namespace, name)` identity. -This is an advisory check against the proposed reference, not trusted evidence for a mutation. The later catalog RPC still validates or loads the real resource and authorizes it independently. +The review does not load the named resource. This avoids an existence side channel +and keeps the result advisory: the subsequent get, update, or delete loads or +validates its real input and authorizes it again. -### Namespace-level create review +### Namespace targets and `canCreate` -When `name` is absent, request the `CREATE` scope for the resource type and ask whether at least one valid name in the requested namespace can satisfy it: +For a target without `name`, a verb is allowed when at least one valid resource +name in that namespace can satisfy its action scope: - `ALL` allows. - `NONE` denies. -- `ANY_OF` allows when at least one clause accepts the namespace and has at least one satisfiable name after all `name IN (...)` predicates in that clause are intersected. - -Add this as a semantic operation on the existing compiled `kubeauth.Matcher`; do not duplicate scope parsing in the access-review service. Invalid scope output remains an internal failure, and an authorizer backend failure remains unavailable. - -### Error behavior - -- Missing authenticated session: `Unauthenticated`. -- Invalid request or unsupported resource/verb combination: `InvalidArgument` through Protovalidate. -- Policy rejection: successful response with `allowed: false`. -- Failure to obtain a scope: `Unavailable`. -- Malformed scope returned by an authorizer: `Internal`. - -The API intentionally returns no denial reason. Exposing backend-specific policy explanations would couple the public contract to an authorizer and can leak policy details. - -## Backend implementation - -1. Define canonical catalog resource-type constants beside `auth.Resource` and replace the current repeated string literals in model and kubecrud wiring. -2. Add a transport-independent `go/core/internal/service/accessreview` service over `auth.CollectionAuthorizer`. -3. Add the thin gRPC adapter that maps protobuf enums to the existing auth verbs and canonical resource types. -4. Register the service in `grpcserver.Config` and `app.Run`. -5. Add `AuthorizationService.CheckAccess` to `DefaultMethodPolicies` as `AccessRead`, so authentication runs before the handler while the requested catalog verb remains data evaluated by the service. -6. Regenerate Go and TypeScript protobuf outputs from the source proto. -7. Update EP-1270 and the scoped-authorization development guide to distinguish rejected embedded hints from the explicit advisory review API. - -No Kubernetes client, database, new dependency, or server-side cache is needed. +- `ANY_OF` allows when at least one clause accepts the namespace and contains a + satisfiable name after all name predicates in that clause are applied. + +This is the direct replacement for the earlier collection-level `canCreate`: +"some proposed resource in this namespace could be allowed." It does not +authorize the object eventually submitted. + +The same existential meaning can apply consistently to every verb, although the +first concrete caller for a nameless target is `CREATE`. Restricting nameless +targets to `CREATE` is an API-review option if broader queries are considered +unnecessary policy disclosure. + +Namespace remains required. A global create button can batch the namespaces the +UI already lists and show when any result allows `CREATE`. This also lets the form +disable unauthorized namespace choices. An implicit "any namespace" query is not +needed for the current UI. + +### Denials and failures + +- A policy denial is a successful response in which the verb is absent from + `allowed_verbs`. +- Missing authentication returns `Unauthenticated`. +- Invalid input returns `InvalidArgument` through Protovalidate. +- Failure to obtain an authorization scope returns `Unavailable`. +- A malformed scope returned by an authorizer returns `Internal`. + +The first version fails the whole RPC if any requested action scope cannot be +evaluated. Per-cell errors add a second error model for little UX value: the UI +must already treat the entire review as advisory and keep handling +`PermissionDenied` from the real operation. + +## Evaluation and performance + +For each requested verb, the server asks `CollectionAuthorizer.Scope` once for +the authenticated principal and resource type, compiles the result with the +existing Kubernetes authorization matcher, and applies it to every target: + +```text +for verb in request.verbs: + matcher = CompileScope(authorizer.Scope(principal, verb, resourceType)) + for target in request.targets: + allowed = target.name is present + ? matcher.Matches(namespace, name) + : matcher.MatchesAnyName(namespace) +``` -## UI integration +The authorizer must derive `Check` and `Scope` from the same policy evaluation so +an exact target produces the same answer through either form. This is also the +invariant required by the earlier capability-field design in enterprise issue +#95, which calculated item and collection capabilities from the corresponding +action scope. -Add the first bundled UI caller in the same PR as the RPC. Keep the API/core and UI work in separate commits so the generated contract and policy semantics can still be reviewed before the presentation changes. +For a page of 100 resources showing update and delete actions: -1. Add one stable `authorization.checkAccess` operation and an SWR-backed hook keyed by resource type, verb, namespace, and optional name. -2. Use exact-name reviews for edit, save, and delete controls. Use the namespace-level create review only where the namespace is known before the name. -3. Disable rather than hide unavailable actions and provide an accessible explanation. Direct navigation to a form must also review the submit action. -4. Do not treat a review error as a denial. Leave the action available and let the authoritative operation report its result; optionally show the review failure as advisory UI state. -5. Continue handling `PermissionDenied` from every mutation because a prior allowed result can become stale immediately. -6. Avoid eager checks for every off-screen table row. Review the actions rendered on the current page or when an action surface opens; add a batch RPC only if this is still measurably expensive. +| Shape | Browser requests | Authorizer scope evaluations | Local matches | +| --- | ---: | ---: | ---: | +| One RPC per resource and verb | 200 | up to 200 | 0 | +| Batched matrix | 1 | 2 | 200 | -The mock transport must model allowed, denied, and failed reviews rather than defaulting every check to allowed. +There is no server cache. A review may become stale immediately, so caching it as +a grant would be incorrect. A browser data cache may deduplicate identical +in-flight reviews, but catalog operations remain authoritative. -## Delivery plan +## UI flows -Ship one end-to-end PR so the new RPC has a real caller in the same change: +### Collection-level create action -1. Proto contract, validation, generated Go/TypeScript artifacts, canonical resource mapping, access-review service, namespace-level create-scope matching, gRPC/app wiring, and backend tests. -2. Stable UI operation, hook, mock implementation, access-aware controls, and UI tests. -3. Documentation updates and full focused validation. +Once the UI knows the candidate namespaces, it sends one nameless target per +namespace with `CREATE`: -Before implementation, rebase this branch onto `upstream/main` after #2859 merges so `UPDATE` reviews mirror the final read/write authorization sequence without stacking #2860 on the active PR. +```json +{ + "resourceType": "AGENT_TEMPLATE", + "verbs": ["CREATE"], + "targets": [ + {"namespace": "kagent"}, + {"namespace": "team-a"} + ] +} +``` -## Test plan +The enterprise UI can show the global create button if any target allows +`CREATE`, then allow only those namespaces in the form. The review can run in +parallel with the catalog and namespace reads; capability fields also were not +available until their containing collection response arrived. + +### Per-item actions + +After a list loads, the UI sends its visible rows as named targets and requests +the verbs rendered on that page. A 25-row template page therefore makes one +review request rather than 50 update/delete requests. + +### Detail actions + +A detail page sends one named target with `UPDATE` and `DELETE`. The matrix API +handles the single-target case, so a second singular RPC is unnecessary. + +### Loading, errors, and staleness + +The enterprise UI owns whether a denied action is hidden or disabled. While the +review is loading it can hold the action area or render a stable placeholder to +avoid flashing unauthorized controls. + +A review transport failure is not a policy denial. The UI should preserve its +existing fallback behavior and let the authoritative operation return +`PermissionDenied`; otherwise a transient advisory failure becomes an accidental +availability failure. + +## Backend boundaries + +- The protobuf adapter maps the closed enums to the canonical `auth.Verb` and + catalog resource-type values. +- A transport-independent access-review service derives the principal from the + authenticated context and evaluates action scopes. +- `kubeauth.Matcher` owns exact and existential target matching. +- `AuthorizationService.CheckAccess` has `AccessRead` method policy so the caller + is authenticated before the requested catalog verbs are evaluated. +- The service does not use a Kubernetes client or database. +- Generated Go and TypeScript clients are committed from the source proto. + +The OSS UI does not call the RPC. The generated TypeScript contract is consumed +by a follow-up enterprise UI change. + +## Security properties + +- Results apply only to the authenticated caller. +- Named checks do not reveal whether a resource exists. +- No policy representation or denial explanation crosses the API boundary. +- Request limits bound policy work. +- A successful review never bypasses authorization on a later operation. +- The default `NoopAuthorizer` returns `ALL`, preserving the OSS experience. + +## Testing + +- Protovalidate rejects invalid enums, duplicates, empty lists, oversized target + sets, invalid namespaces and names, and unsupported resource/verb pairs. +- Scope matching covers `ALL`, `NONE`, namespace/name conjunctions, OR clauses, + repeated name predicates, invalid candidate names, and nameless targets. +- Service tests prove one scope lookup per verb rather than per target. +- Service tests prove results preserve target order and contain only allowed + requested verbs. +- gRPC tests prove authentication, enum mapping, registration, and default OSS + allow behavior. +- Mutation tests continue to prove that a prior allowed review does not bypass a + later denial. +- Enterprise UI tests cover the global create action, namespace choices, + per-item actions, loading, review failure fallback, and mutation-time denial. -### Semantic unit tests +## Alternatives -- Named reviews pass the authenticated principal, canonical type, namespace, name, and expected verb sequence. -- `UPDATE` requires every check used by the corresponding update operation. -- Namespace-level create handles `ALL`, `NONE`, namespace-only clauses, name-only clauses, namespace/name conjunctions, OR clauses, and intersecting repeated name predicates. -- Unsatisfiable or malformed scopes fail closed. -- A missing session is unauthenticated and a policy rejection is `allowed: false`. +### Capability fields on catalog responses -### gRPC and generation checks +They avoid the extra review request, but couple catalog schemas and every catalog +handler to UI actions, become stale with unrelated resource data, and cannot be +refreshed independently. This was rejected by OSS issue #2710 and is the reason +for the dedicated review API. -- Protovalidate rejects unspecified enums, bad DNS names, a missing name for non-create verbs, and unsupported resource/verb pairs before the handler runs. -- The method policy requires authentication. -- An in-process gRPC server with a scoped test authorizer proves named denial, namespace-level allow/deny, and that a successful review does not bypass a later denied mutation. -- `buf lint`, `buf generate`, and the repository generated-output check pass. +### A repeated list of fully independent checks -### End-to-end +This removes browser round trips but repeats resource type and verb data for each +cell and encourages one authorizer evaluation per cell. Grouping one resource +type, several verbs, and several targets expresses the matrix directly and makes +scope reuse natural. -- The default OSS authorizer returns `allowed: true` for every supported review. -- UI tests cover the non-authoritative behavior described above. +### Return authorization scopes to the browser -Run at minimum: +This would minimize server work but expose policy representation and require the +UI to duplicate the scope matcher. It also makes policy-format compatibility a +public API concern. -```bash -make proto-lint -make proto-check -make -C go test -make -C go lint -(cd ui && yarn typecheck && yarn test && yarn lint) -``` +### Separate singular and batch RPCs -Run the focused Go and UI tests first, then the relevant Playwright and Kind E2E cases before the PR is ready to merge. +The matrix handles one target and one verb without special cases. A second RPC +would duplicate validation, mapping, tests, and client code. -## Alternatives +## Questions for review -- **Capability fields on catalog responses:** rejected by EP-1270 because they duplicate policy decisions and age with unrelated resource data. -- **Calling the mutation and handling `PermissionDenied`:** remains mandatory as enforcement, but is a worse first interaction when the UI can cheaply ask in advance. -- **Returning the full authorization scope:** rejected because it exposes policy representation and makes every client implement the matcher. -- **String resource types and verbs:** rejected because the supported set is closed and protobuf enums let Protovalidate reject unknown values before service code. -- **Omitted name for every verb:** deferred. Current UI reads, updates, and deletes concrete resources; only creation has a real action before a name exists. Broader existential semantics can be added with a demonstrated caller. -- **Batch review in v1:** deferred until request volume is measured. +1. Should a nameless target retain uniform existential semantics for every verb, + or be valid only for `CREATE`? +2. Is 100 the right initial target limit for the enterprise UI's largest rendered + page? +3. Should `ResourceAccess` echo each target, or rely only on request/response + ordering for a smaller response? +4. When an update operation requires more than `UPDATE` authorization (for + example, a separate `GET` prerequisite), should the `UPDATE` matrix cell be the + conjunction of all operation prerequisites? +5. Does the enterprise authorizer guarantee that action scopes and exact checks + are equivalent for namespace/name attributes? If not, the wire API can remain + batched while the initial server implementation loops over exact `Check` calls. From e5089cc0895570ebadfc14262e671a99e043c724 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Thu, 17 Sep 2026 13:19:32 -0700 Subject: [PATCH 3/8] docs: trim access-review design Signed-off-by: Cody Hartsook --- design/EP-2860-access-review-rpc.md | 352 ++++++++++------------------ 1 file changed, 128 insertions(+), 224 deletions(-) diff --git a/design/EP-2860-access-review-rpc.md b/design/EP-2860-access-review-rpc.md index f34c49103..a4ede0715 100644 --- a/design/EP-2860-access-review-rpc.md +++ b/design/EP-2860-access-review-rpc.md @@ -1,44 +1,33 @@ # EP-2860: Batched access review for catalog actions -> **Discussion draft:** keep this document in the draft PR while the API is being -> reviewed, then remove it before merge. +> **Discussion draft:** remove this document before the PR merges. -* OSS issue: [kagent-dev/kagent#2860](https://github.com/kagent-dev/kagent/issues/2860) -* Enterprise context: [solo-io/enterprise-kagent#95](https://github.com/solo-io/enterprise-kagent/issues/95) +- OSS issue: [kagent-dev/kagent#2860](https://github.com/kagent-dev/kagent/issues/2860) +- Enterprise context: [solo-io/enterprise-kagent#95](https://github.com/solo-io/enterprise-kagent/issues/95) ## Summary Add an authenticated `AuthorizationService.CheckAccess` RPC that returns an -advisory permission matrix for catalog actions. One request reviews several -namespaced targets of one resource type against several verbs. +advisory permission matrix. One request checks several targets of one catalog +resource type against several verbs. -This replaces the UX purpose of the earlier `canCreate`, `canUpdate`, and -`canDelete` response fields without embedding authorization state in catalog -resources. The enterprise UI can decide whether to hide or disable an action, -while every catalog operation continues to authorize the real request. +This gives the enterprise UI the same early UX signals as the former +`canCreate`, `canUpdate`, and `canDelete` fields without coupling authorization +state to catalog resources. Real operations continue to authorize every request. -## Goals +## Scope -- Let a UI decide whether to present create, get, update, and delete actions. -- Preserve the earlier `canCreate` behavior before the user starts filling in a - form. -- Review a whole page of resource actions in one browser request. -- Reuse the authenticated principal, resource names, verbs, and authorization - scopes already used by catalog services. -- Keep the default OSS authorizer behavior unchanged. -- Keep access-review results advisory and independent from mutation enforcement. +The RPC should: -## Non-goals +- support create-button, namespace-picker, and per-item action UX; +- review a page of resources in one browser request; +- reuse the existing resource types, verbs, and authorization scopes; and +- preserve the default OSS authorizer behavior. -- Add capability fields to catalog list or item responses. -- Return roles, policies, claims, denial reasons, catalog keys, or raw scopes. -- Review `LIST`; partial collection visibility is not representable by one - Boolean. -- Read Kubernetes resources as part of a review. -- Cache decisions on the server or turn a successful review into a grant. -- Add access-aware behavior to the OSS UI. The consumer is the enterprise UI. +It will not return roles, policies, denial reasons, or raw scopes; review `LIST`; +read Kubernetes resources; add server-side caching; or change the OSS UI. -## Proposed API +## API ```proto service AuthorizationService { @@ -81,90 +70,75 @@ message ResourceAccess { } ``` -One request is homogeneous by resource type. That matches the common UI surfaces -(a template list, a model list, or a harness list) and lets the server obtain one -authorization scope per requested verb. A screen containing multiple catalog -resource types can issue at most three requests in parallel. - -`results` has the same order and cardinality as `targets`. Echoing the target also -makes the response self-describing and avoids string-encoding a namespaced name as -a protobuf map key. +One resource type per request matches current UI surfaces and lets the server +evaluate each requested verb's scope once. Results preserve target order and echo +the target so callers need no encoded map key. ### Validation -Declare request-intrinsic validation in the source proto with `buf.validate`: - -- `resource_type` must be a defined, non-zero enum value. -- `verbs` must contain between one and four unique, defined, non-zero values. -- `targets` must contain between one and 100 entries. -- Every namespace is a required Kubernetes DNS label. -- A present name is a non-empty Kubernetes DNS subdomain. -- Unsupported resource/verb combinations are rejected. The initial matrix is: +Declare validation in the proto with `buf.validate`: - | Resource type | Verbs | - | --- | --- | - | `AgentTemplate` | `GET`, `CREATE`, `UPDATE`, `DELETE` | - | `Harness` | `CREATE`, `DELETE` | - | `ModelConfig` | `GET`, `CREATE`, `UPDATE`, `DELETE` | +- require defined, non-zero enums; +- require 1–4 unique verbs and 1–100 targets; +- require a Kubernetes DNS-label namespace; +- when present, require a non-empty DNS-subdomain name; and +- reject unsupported resource/verb combinations. -The limit bounds one request to at most 400 Boolean decisions. It is large enough -for the current 25-row UI pages and prevents an access-review call from becoming -an unbounded policy-evaluation endpoint. - -## Semantics +Initial supported verbs: -### Named targets +| Resource | Verbs | +| --- | --- | +| `AgentTemplate` | `GET`, `CREATE`, `UPDATE`, `DELETE` | +| `Harness` | `CREATE`, `DELETE` | +| `ModelConfig` | `GET`, `CREATE`, `UPDATE`, `DELETE` | -For a target with `name`, a verb is returned in `allowed_verbs` when the caller's -action scope matches the exact `(resource type, namespace, name)` identity. +The limits bound a request to 400 decisions while covering current 25-row pages. -The review does not load the named resource. This avoids an existence side channel -and keeps the result advisory: the subsequent get, update, or delete loads or -validates its real input and authorizes it again. +## Semantics -### Namespace targets and `canCreate` +### Named target -For a target without `name`, a verb is allowed when at least one valid resource -name in that namespace can satisfy its action scope: +`{namespace, name}` checks the exact resource identity. The review does not load +the resource, avoiding an existence side channel. The later operation loads its +real input and authorizes again. -- `ALL` allows. -- `NONE` denies. -- `ANY_OF` allows when at least one clause accepts the namespace and contains a - satisfiable name after all name predicates in that clause are applied. +### Namespace target -This is the direct replacement for the earlier collection-level `canCreate`: -"some proposed resource in this namespace could be allowed." It does not -authorize the object eventually submitted. +`{namespace}` asks whether at least one valid resource name in that namespace +could satisfy the action scope. For `CREATE`, this replaces collection-level +`canCreate`; it does not authorize the object eventually submitted. -The same existential meaning can apply consistently to every verb, although the -first concrete caller for a nameless target is `CREATE`. Restricting nameless -targets to `CREATE` is an API-review option if broader queries are considered -unnecessary policy disclosure. +Namespace is always required. To decide a global Create button, the UI sends all +candidate namespaces as targets in one request and shows the button if any allow +`CREATE`. It can then hide or disable denied namespaces in the form. -Namespace remains required. A global create button can batch the namespaces the -UI already lists and show when any result allows `CREATE`. This also lets the form -disable unauthorized namespace choices. An implicit "any namespace" query is not -needed for the current UI. +```json +{ + "resourceType": "AGENT_TEMPLATE", + "verbs": ["CREATE"], + "targets": [ + {"namespace": "kagent"}, + {"namespace": "team-a"} + ] +} +``` ### Denials and failures -- A policy denial is a successful response in which the verb is absent from - `allowed_verbs`. +- A denied verb is absent from `allowed_verbs`. - Missing authentication returns `Unauthenticated`. -- Invalid input returns `InvalidArgument` through Protovalidate. -- Failure to obtain an authorization scope returns `Unavailable`. -- A malformed scope returned by an authorizer returns `Internal`. +- Invalid input returns `InvalidArgument`. +- Scope lookup failure returns `Unavailable`. +- A malformed authorizer scope returns `Internal`. -The first version fails the whole RPC if any requested action scope cannot be -evaluated. Per-cell errors add a second error model for little UX value: the UI -must already treat the entire review as advisory and keep handling -`PermissionDenied` from the real operation. +The first version fails the whole RPC if any scope cannot be evaluated. Per-cell +errors add little value because the review is advisory and real operations remain +authoritative. ## Evaluation and performance -For each requested verb, the server asks `CollectionAuthorizer.Scope` once for -the authenticated principal and resource type, compiles the result with the -existing Kubernetes authorization matcher, and applies it to every target: +For each requested verb, call `CollectionAuthorizer.Scope` once, compile the +existing matcher, and apply it to every target: ```text for verb in request.verbs: @@ -175,146 +149,76 @@ for verb in request.verbs: : matcher.MatchesAnyName(namespace) ``` -The authorizer must derive `Check` and `Scope` from the same policy evaluation so -an exact target produces the same answer through either form. This is also the -invariant required by the earlier capability-field design in enterprise issue -#95, which calculated item and collection capabilities from the corresponding -action scope. +`Check` and `Scope` must derive from the same policy evaluation so exact-target +answers agree. -For a page of 100 resources showing update and delete actions: +For 100 rows showing update and delete actions: -| Shape | Browser requests | Authorizer scope evaluations | Local matches | +| Shape | Browser requests | Scope evaluations | Local matches | | --- | ---: | ---: | ---: | | One RPC per resource and verb | 200 | up to 200 | 0 | | Batched matrix | 1 | 2 | 200 | -There is no server cache. A review may become stale immediately, so caching it as -a grant would be incorrect. A browser data cache may deduplicate identical -in-flight reviews, but catalog operations remain authoritative. - -## UI flows - -### Collection-level create action - -Once the UI knows the candidate namespaces, it sends one nameless target per -namespace with `CREATE`: - -```json -{ - "resourceType": "AGENT_TEMPLATE", - "verbs": ["CREATE"], - "targets": [ - {"namespace": "kagent"}, - {"namespace": "team-a"} - ] -} -``` - -The enterprise UI can show the global create button if any target allows -`CREATE`, then allow only those namespaces in the form. The review can run in -parallel with the catalog and namespace reads; capability fields also were not -available until their containing collection response arrived. - -### Per-item actions - -After a list loads, the UI sends its visible rows as named targets and requests -the verbs rendered on that page. A 25-row template page therefore makes one -review request rather than 50 update/delete requests. - -### Detail actions - -A detail page sends one named target with `UPDATE` and `DELETE`. The matrix API -handles the single-target case, so a second singular RPC is unnecessary. - -### Loading, errors, and staleness - -The enterprise UI owns whether a denied action is hidden or disabled. While the -review is loading it can hold the action area or render a stable placeholder to -avoid flashing unauthorized controls. - -A review transport failure is not a policy denial. The UI should preserve its -existing fallback behavior and let the authoritative operation return -`PermissionDenied`; otherwise a transient advisory failure becomes an accidental -availability failure. - -## Backend boundaries - -- The protobuf adapter maps the closed enums to the canonical `auth.Verb` and - catalog resource-type values. -- A transport-independent access-review service derives the principal from the - authenticated context and evaluates action scopes. -- `kubeauth.Matcher` owns exact and existential target matching. -- `AuthorizationService.CheckAccess` has `AccessRead` method policy so the caller - is authenticated before the requested catalog verbs are evaluated. -- The service does not use a Kubernetes client or database. -- Generated Go and TypeScript clients are committed from the source proto. - -The OSS UI does not call the RPC. The generated TypeScript contract is consumed -by a follow-up enterprise UI change. - -## Security properties - -- Results apply only to the authenticated caller. -- Named checks do not reveal whether a resource exists. -- No policy representation or denial explanation crosses the API boundary. -- Request limits bound policy work. -- A successful review never bypasses authorization on a later operation. -- The default `NoopAuthorizer` returns `ALL`, preserving the OSS experience. - -## Testing - -- Protovalidate rejects invalid enums, duplicates, empty lists, oversized target - sets, invalid namespaces and names, and unsupported resource/verb pairs. -- Scope matching covers `ALL`, `NONE`, namespace/name conjunctions, OR clauses, - repeated name predicates, invalid candidate names, and nameless targets. -- Service tests prove one scope lookup per verb rather than per target. -- Service tests prove results preserve target order and contain only allowed - requested verbs. -- gRPC tests prove authentication, enum mapping, registration, and default OSS - allow behavior. -- Mutation tests continue to prove that a prior allowed review does not bypass a - later denial. -- Enterprise UI tests cover the global create action, namespace choices, - per-item actions, loading, review failure fallback, and mutation-time denial. - -## Alternatives - -### Capability fields on catalog responses - -They avoid the extra review request, but couple catalog schemas and every catalog -handler to UI actions, become stale with unrelated resource data, and cannot be -refreshed independently. This was rejected by OSS issue #2710 and is the reason -for the dedicated review API. - -### A repeated list of fully independent checks - -This removes browser round trips but repeats resource type and verb data for each -cell and encourages one authorizer evaluation per cell. Grouping one resource -type, several verbs, and several targets expresses the matrix directly and makes -scope reuse natural. - -### Return authorization scopes to the browser - -This would minimize server work but expose policy representation and require the -UI to duplicate the scope matcher. It also makes policy-format compatibility a -public API concern. - -### Separate singular and batch RPCs - -The matrix handles one target and one verb without special cases. A second RPC -would duplicate validation, mapping, tests, and client code. +There is no server cache. The enterprise UI may use SWR to deduplicate or briefly +cache advisory responses, but a cached result never bypasses operation-time +authorization. + +## UI behavior + +- **Collection:** check `CREATE` for candidate namespaces while loading the page; + show Create if any namespace allows it. +- **Create form:** show or enable only allowed namespaces. +- **List:** check visible named rows for the actions rendered on that page. +- **Detail:** send one named target with the required actions. +- **Loading:** avoid flashing controls before the review completes. +- **Review failure:** do not treat transport failure as policy denial; preserve a + path to the authoritative operation. + +The OSS UI will not call the RPC. Generated TypeScript is consumed by the +enterprise UI. + +## Backend and security + +- The protobuf adapter maps closed enums to canonical `auth.Verb` and catalog + resource types. +- A transport-independent service obtains the authenticated principal and + evaluates scopes. +- `kubeauth.Matcher` owns exact and existential matching. +- The RPC requires `AccessRead` method policy. +- It uses no Kubernetes client or database. +- The default `NoopAuthorizer` returns `ALL`. +- Responses reveal only Boolean action results, not policy structure or resource + existence. + +## Tests + +- Proto validation: enums, duplicate verbs, limits, names, namespaces, and + supported combinations. +- Matcher: `ALL`, `NONE`, exact targets, namespace/name predicates, OR clauses, + and nameless targets. +- Service: one scope lookup per verb, stable result order, and allowed verbs. +- gRPC: authentication, enum mapping, registration, and default allow behavior. +- Enterprise UI: Create visibility, namespace choices, per-item actions, loading, + advisory failure, and mutation-time denial. + +## Alternatives rejected + +- **Capability fields:** couple authorization to catalog schemas and cannot be + refreshed independently. +- **Independent check list:** repeats resource and verb data and encourages one + policy evaluation per cell. +- **Scopes in the browser:** expose policy representation and duplicate matcher + semantics. +- **Separate singular RPC:** duplicates an API already covered by a one-cell + matrix. ## Questions for review -1. Should a nameless target retain uniform existential semantics for every verb, - or be valid only for `CREATE`? -2. Is 100 the right initial target limit for the enterprise UI's largest rendered - page? -3. Should `ResourceAccess` echo each target, or rely only on request/response - ordering for a smaller response? -4. When an update operation requires more than `UPDATE` authorization (for - example, a separate `GET` prerequisite), should the `UPDATE` matrix cell be the - conjunction of all operation prerequisites? -5. Does the enterprise authorizer guarantee that action scopes and exact checks - are equivalent for namespace/name attributes? If not, the wire API can remain - batched while the initial server implementation loops over exact `Check` calls. +1. Should nameless targets work for every verb or only `CREATE`? +2. Is 100 the right initial target limit? +3. Should results echo targets or rely only on ordering? +4. If an operation has multiple authorization prerequisites, should its matrix + cell require all of them? +5. Does the enterprise authorizer guarantee equivalent `Scope` and exact `Check` + decisions? If not, the wire API can remain batched while the initial server + loops over `Check` calls. From 8c38c57b7b87d8250deeb21e4d8557142cf416fa Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 18 Sep 2026 12:35:59 -0700 Subject: [PATCH 4/8] feat: add batched catalog access review Signed-off-by: Cody Hartsook --- .../kagent/api/v1alpha1/authorization.pb.go | 439 ++++++++++++++++++ .../api/v1alpha1/authorization_grpc.pb.go | 127 +++++ go/core/internal/grpcserver/authorization.go | 62 +++ .../internal/grpcserver/authorization_test.go | 104 +++++ go/core/internal/grpcserver/policy.go | 1 + go/core/internal/grpcserver/policy_test.go | 6 + .../internal/grpcserver/protovalidate_test.go | 84 ++++ go/core/internal/grpcserver/server.go | 5 + .../internal/service/accessreview/service.go | 67 +++ .../service/accessreview/service_test.go | 134 ++++++ go/core/internal/service/kubeauth/scope.go | 31 ++ .../internal/service/kubeauth/scope_test.go | 57 +++ go/core/internal/service/model/service.go | 5 +- go/core/pkg/app/app.go | 6 +- go/core/pkg/auth/auth.go | 6 + go/core/test/e2e/access_review_test.go | 48 ++ proto/kagent/api/v1alpha1/authorization.proto | 79 ++++ .../kagent/api/v1alpha1/authorization_pb.ts | 191 ++++++++ 18 files changed, 1446 insertions(+), 6 deletions(-) create mode 100644 go/api/gen/kagent/api/v1alpha1/authorization.pb.go create mode 100644 go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go create mode 100644 go/core/internal/grpcserver/authorization.go create mode 100644 go/core/internal/grpcserver/authorization_test.go create mode 100644 go/core/internal/service/accessreview/service.go create mode 100644 go/core/internal/service/accessreview/service_test.go create mode 100644 go/core/test/e2e/access_review_test.go create mode 100644 proto/kagent/api/v1alpha1/authorization.proto create mode 100644 ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts diff --git a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go new file mode 100644 index 000000000..67c195c55 --- /dev/null +++ b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go @@ -0,0 +1,439 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: kagent/api/v1alpha1/authorization.proto + +package apiv1alpha1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AuthorizationResourceType int32 + +const ( + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED AuthorizationResourceType = 0 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE AuthorizationResourceType = 1 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS AuthorizationResourceType = 2 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG AuthorizationResourceType = 3 +) + +// Enum value maps for AuthorizationResourceType. +var ( + AuthorizationResourceType_name = map[int32]string{ + 0: "AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED", + 1: "AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE", + 2: "AUTHORIZATION_RESOURCE_TYPE_HARNESS", + 3: "AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG", + } + AuthorizationResourceType_value = map[string]int32{ + "AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED": 0, + "AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE": 1, + "AUTHORIZATION_RESOURCE_TYPE_HARNESS": 2, + "AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG": 3, + } +) + +func (x AuthorizationResourceType) Enum() *AuthorizationResourceType { + p := new(AuthorizationResourceType) + *p = x + return p +} + +func (x AuthorizationResourceType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AuthorizationResourceType) Descriptor() protoreflect.EnumDescriptor { + return file_kagent_api_v1alpha1_authorization_proto_enumTypes[0].Descriptor() +} + +func (AuthorizationResourceType) Type() protoreflect.EnumType { + return &file_kagent_api_v1alpha1_authorization_proto_enumTypes[0] +} + +func (x AuthorizationResourceType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AuthorizationResourceType.Descriptor instead. +func (AuthorizationResourceType) EnumDescriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{0} +} + +type AuthorizationVerb int32 + +const ( + AuthorizationVerb_AUTHORIZATION_VERB_UNSPECIFIED AuthorizationVerb = 0 + AuthorizationVerb_AUTHORIZATION_VERB_GET AuthorizationVerb = 1 + AuthorizationVerb_AUTHORIZATION_VERB_CREATE AuthorizationVerb = 2 + AuthorizationVerb_AUTHORIZATION_VERB_UPDATE AuthorizationVerb = 3 + AuthorizationVerb_AUTHORIZATION_VERB_DELETE AuthorizationVerb = 4 +) + +// Enum value maps for AuthorizationVerb. +var ( + AuthorizationVerb_name = map[int32]string{ + 0: "AUTHORIZATION_VERB_UNSPECIFIED", + 1: "AUTHORIZATION_VERB_GET", + 2: "AUTHORIZATION_VERB_CREATE", + 3: "AUTHORIZATION_VERB_UPDATE", + 4: "AUTHORIZATION_VERB_DELETE", + } + AuthorizationVerb_value = map[string]int32{ + "AUTHORIZATION_VERB_UNSPECIFIED": 0, + "AUTHORIZATION_VERB_GET": 1, + "AUTHORIZATION_VERB_CREATE": 2, + "AUTHORIZATION_VERB_UPDATE": 3, + "AUTHORIZATION_VERB_DELETE": 4, + } +) + +func (x AuthorizationVerb) Enum() *AuthorizationVerb { + p := new(AuthorizationVerb) + *p = x + return p +} + +func (x AuthorizationVerb) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AuthorizationVerb) Descriptor() protoreflect.EnumDescriptor { + return file_kagent_api_v1alpha1_authorization_proto_enumTypes[1].Descriptor() +} + +func (AuthorizationVerb) Type() protoreflect.EnumType { + return &file_kagent_api_v1alpha1_authorization_proto_enumTypes[1] +} + +func (x AuthorizationVerb) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AuthorizationVerb.Descriptor instead. +func (AuthorizationVerb) EnumDescriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{1} +} + +type CheckAccessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResourceType AuthorizationResourceType `protobuf:"varint,1,opt,name=resource_type,json=resourceType,proto3,enum=kagent.api.v1alpha1.AuthorizationResourceType" json:"resource_type,omitempty"` + Verbs []AuthorizationVerb `protobuf:"varint,2,rep,packed,name=verbs,proto3,enum=kagent.api.v1alpha1.AuthorizationVerb" json:"verbs,omitempty"` + Targets []*AccessTarget `protobuf:"bytes,3,rep,name=targets,proto3" json:"targets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckAccessRequest) Reset() { + *x = CheckAccessRequest{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckAccessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckAccessRequest) ProtoMessage() {} + +func (x *CheckAccessRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckAccessRequest.ProtoReflect.Descriptor instead. +func (*CheckAccessRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{0} +} + +func (x *CheckAccessRequest) GetResourceType() AuthorizationResourceType { + if x != nil { + return x.ResourceType + } + return AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED +} + +func (x *CheckAccessRequest) GetVerbs() []AuthorizationVerb { + if x != nil { + return x.Verbs + } + return nil +} + +func (x *CheckAccessRequest) GetTargets() []*AccessTarget { + if x != nil { + return x.Targets + } + return nil +} + +type AccessTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + // When omitted, the review checks whether any valid resource name in the + // namespace is permitted. + Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccessTarget) Reset() { + *x = AccessTarget{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccessTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccessTarget) ProtoMessage() {} + +func (x *AccessTarget) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccessTarget.ProtoReflect.Descriptor instead. +func (*AccessTarget) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{1} +} + +func (x *AccessTarget) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *AccessTarget) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +type CheckAccessResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*ResourceAccess `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckAccessResponse) Reset() { + *x = CheckAccessResponse{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckAccessResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckAccessResponse) ProtoMessage() {} + +func (x *CheckAccessResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckAccessResponse.ProtoReflect.Descriptor instead. +func (*CheckAccessResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{2} +} + +func (x *CheckAccessResponse) GetResults() []*ResourceAccess { + if x != nil { + return x.Results + } + return nil +} + +type ResourceAccess struct { + state protoimpl.MessageState `protogen:"open.v1"` + Target *AccessTarget `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` + AllowedVerbs []AuthorizationVerb `protobuf:"varint,2,rep,packed,name=allowed_verbs,json=allowedVerbs,proto3,enum=kagent.api.v1alpha1.AuthorizationVerb" json:"allowed_verbs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceAccess) Reset() { + *x = ResourceAccess{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceAccess) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceAccess) ProtoMessage() {} + +func (x *ResourceAccess) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceAccess.ProtoReflect.Descriptor instead. +func (*ResourceAccess) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{3} +} + +func (x *ResourceAccess) GetTarget() *AccessTarget { + if x != nil { + return x.Target + } + return nil +} + +func (x *ResourceAccess) GetAllowedVerbs() []AuthorizationVerb { + if x != nil { + return x.AllowedVerbs + } + return nil +} + +var File_kagent_api_v1alpha1_authorization_proto protoreflect.FileDescriptor + +const file_kagent_api_v1alpha1_authorization_proto_rawDesc = "" + + "\n" + + "'kagent/api/v1alpha1/authorization.proto\x12\x13kagent.api.v1alpha1\x1a\x1bbuf/validate/validate.proto\"\xb8\x03\n" + + "\x12CheckAccessRequest\x12_\n" + + "\rresource_type\x18\x01 \x01(\x0e2..kagent.api.v1alpha1.AuthorizationResourceTypeB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\fresourceType\x12S\n" + + "\x05verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbB\x15\xbaH\x12\x92\x01\x0f\b\x01\x10\x04\x18\x01\"\a\x82\x01\x04\x10\x01 \x00R\x05verbs\x12G\n" + + "\atargets\x18\x03 \x03(\v2!.kagent.api.v1alpha1.AccessTargetB\n" + + "\xbaH\a\x92\x01\x04\b\x01\x10dR\atargets:\xa2\x01\xbaH\x9e\x01\x1a\x9b\x01\n" + + "\x18supported_resource_verbs\x126Harness supports only CREATE and DELETE access reviews\x1aGthis.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)\"\xca\x01\n" + + "\fAccessTarget\x12H\n" + + "\tnamespace\x18\x01 \x01(\tB*\xbaH'r%\x10\x01\x18?2\x1f^[a-z0-9]([-a-z0-9]*[a-z0-9])?$R\tnamespace\x12g\n" + + "\x04name\x18\x02 \x01(\tBN\xbaHKrI\x10\x01\x18\xfd\x012B^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$H\x00R\x04name\x88\x01\x01B\a\n" + + "\x05_name\"T\n" + + "\x13CheckAccessResponse\x12=\n" + + "\aresults\x18\x01 \x03(\v2#.kagent.api.v1alpha1.ResourceAccessR\aresults\"\x98\x01\n" + + "\x0eResourceAccess\x129\n" + + "\x06target\x18\x01 \x01(\v2!.kagent.api.v1alpha1.AccessTargetR\x06target\x12K\n" + + "\rallowed_verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbR\fallowedVerbs*\xcf\x01\n" + + "\x19AuthorizationResourceType\x12+\n" + + "'AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED\x10\x00\x12.\n" + + "*AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE\x10\x01\x12'\n" + + "#AUTHORIZATION_RESOURCE_TYPE_HARNESS\x10\x02\x12,\n" + + "(AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG\x10\x03*\xb0\x01\n" + + "\x11AuthorizationVerb\x12\"\n" + + "\x1eAUTHORIZATION_VERB_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16AUTHORIZATION_VERB_GET\x10\x01\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_CREATE\x10\x02\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_UPDATE\x10\x03\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_DELETE\x10\x042x\n" + + "\x14AuthorizationService\x12`\n" + + "\vCheckAccess\x12'.kagent.api.v1alpha1.CheckAccessRequest\x1a(.kagent.api.v1alpha1.CheckAccessResponseBIZGgithub.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1;apiv1alpha1b\x06proto3" + +var ( + file_kagent_api_v1alpha1_authorization_proto_rawDescOnce sync.Once + file_kagent_api_v1alpha1_authorization_proto_rawDescData []byte +) + +func file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP() []byte { + file_kagent_api_v1alpha1_authorization_proto_rawDescOnce.Do(func() { + file_kagent_api_v1alpha1_authorization_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kagent_api_v1alpha1_authorization_proto_rawDesc), len(file_kagent_api_v1alpha1_authorization_proto_rawDesc))) + }) + return file_kagent_api_v1alpha1_authorization_proto_rawDescData +} + +var file_kagent_api_v1alpha1_authorization_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_kagent_api_v1alpha1_authorization_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_kagent_api_v1alpha1_authorization_proto_goTypes = []any{ + (AuthorizationResourceType)(0), // 0: kagent.api.v1alpha1.AuthorizationResourceType + (AuthorizationVerb)(0), // 1: kagent.api.v1alpha1.AuthorizationVerb + (*CheckAccessRequest)(nil), // 2: kagent.api.v1alpha1.CheckAccessRequest + (*AccessTarget)(nil), // 3: kagent.api.v1alpha1.AccessTarget + (*CheckAccessResponse)(nil), // 4: kagent.api.v1alpha1.CheckAccessResponse + (*ResourceAccess)(nil), // 5: kagent.api.v1alpha1.ResourceAccess +} +var file_kagent_api_v1alpha1_authorization_proto_depIdxs = []int32{ + 0, // 0: kagent.api.v1alpha1.CheckAccessRequest.resource_type:type_name -> kagent.api.v1alpha1.AuthorizationResourceType + 1, // 1: kagent.api.v1alpha1.CheckAccessRequest.verbs:type_name -> kagent.api.v1alpha1.AuthorizationVerb + 3, // 2: kagent.api.v1alpha1.CheckAccessRequest.targets:type_name -> kagent.api.v1alpha1.AccessTarget + 5, // 3: kagent.api.v1alpha1.CheckAccessResponse.results:type_name -> kagent.api.v1alpha1.ResourceAccess + 3, // 4: kagent.api.v1alpha1.ResourceAccess.target:type_name -> kagent.api.v1alpha1.AccessTarget + 1, // 5: kagent.api.v1alpha1.ResourceAccess.allowed_verbs:type_name -> kagent.api.v1alpha1.AuthorizationVerb + 2, // 6: kagent.api.v1alpha1.AuthorizationService.CheckAccess:input_type -> kagent.api.v1alpha1.CheckAccessRequest + 4, // 7: kagent.api.v1alpha1.AuthorizationService.CheckAccess:output_type -> kagent.api.v1alpha1.CheckAccessResponse + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_kagent_api_v1alpha1_authorization_proto_init() } +func file_kagent_api_v1alpha1_authorization_proto_init() { + if File_kagent_api_v1alpha1_authorization_proto != nil { + return + } + file_kagent_api_v1alpha1_authorization_proto_msgTypes[1].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1alpha1_authorization_proto_rawDesc), len(file_kagent_api_v1alpha1_authorization_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_kagent_api_v1alpha1_authorization_proto_goTypes, + DependencyIndexes: file_kagent_api_v1alpha1_authorization_proto_depIdxs, + EnumInfos: file_kagent_api_v1alpha1_authorization_proto_enumTypes, + MessageInfos: file_kagent_api_v1alpha1_authorization_proto_msgTypes, + }.Build() + File_kagent_api_v1alpha1_authorization_proto = out.File + file_kagent_api_v1alpha1_authorization_proto_goTypes = nil + file_kagent_api_v1alpha1_authorization_proto_depIdxs = nil +} diff --git a/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go new file mode 100644 index 000000000..ddc175c28 --- /dev/null +++ b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go @@ -0,0 +1,127 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: kagent/api/v1alpha1/authorization.proto + +package apiv1alpha1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AuthorizationService_CheckAccess_FullMethodName = "/kagent.api.v1alpha1.AuthorizationService/CheckAccess" +) + +// AuthorizationServiceClient is the client API for AuthorizationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AuthorizationService answers advisory access checks for catalog UI actions. +// The resource operation remains authoritative. +type AuthorizationServiceClient interface { + CheckAccess(ctx context.Context, in *CheckAccessRequest, opts ...grpc.CallOption) (*CheckAccessResponse, error) +} + +type authorizationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAuthorizationServiceClient(cc grpc.ClientConnInterface) AuthorizationServiceClient { + return &authorizationServiceClient{cc} +} + +func (c *authorizationServiceClient) CheckAccess(ctx context.Context, in *CheckAccessRequest, opts ...grpc.CallOption) (*CheckAccessResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckAccessResponse) + err := c.cc.Invoke(ctx, AuthorizationService_CheckAccess_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthorizationServiceServer is the server API for AuthorizationService service. +// All implementations must embed UnimplementedAuthorizationServiceServer +// for forward compatibility. +// +// AuthorizationService answers advisory access checks for catalog UI actions. +// The resource operation remains authoritative. +type AuthorizationServiceServer interface { + CheckAccess(context.Context, *CheckAccessRequest) (*CheckAccessResponse, error) + mustEmbedUnimplementedAuthorizationServiceServer() +} + +// UnimplementedAuthorizationServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAuthorizationServiceServer struct{} + +func (UnimplementedAuthorizationServiceServer) CheckAccess(context.Context, *CheckAccessRequest) (*CheckAccessResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CheckAccess not implemented") +} +func (UnimplementedAuthorizationServiceServer) mustEmbedUnimplementedAuthorizationServiceServer() {} +func (UnimplementedAuthorizationServiceServer) testEmbeddedByValue() {} + +// UnsafeAuthorizationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AuthorizationServiceServer will +// result in compilation errors. +type UnsafeAuthorizationServiceServer interface { + mustEmbedUnimplementedAuthorizationServiceServer() +} + +func RegisterAuthorizationServiceServer(s grpc.ServiceRegistrar, srv AuthorizationServiceServer) { + // If the following call panics, it indicates UnimplementedAuthorizationServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AuthorizationService_ServiceDesc, srv) +} + +func _AuthorizationService_CheckAccess_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckAccessRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizationServiceServer).CheckAccess(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizationService_CheckAccess_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizationServiceServer).CheckAccess(ctx, req.(*CheckAccessRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AuthorizationService_ServiceDesc is the grpc.ServiceDesc for AuthorizationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AuthorizationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "kagent.api.v1alpha1.AuthorizationService", + HandlerType: (*AuthorizationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CheckAccess", + Handler: _AuthorizationService_CheckAccess_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "kagent/api/v1alpha1/authorization.proto", +} diff --git a/go/core/internal/grpcserver/authorization.go b/go/core/internal/grpcserver/authorization.go new file mode 100644 index 000000000..20afd980d --- /dev/null +++ b/go/core/internal/grpcserver/authorization.go @@ -0,0 +1,62 @@ +package grpcserver + +import ( + "context" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type authorizationServer struct { + apiv1alpha1.UnimplementedAuthorizationServiceServer + service *accessreview.Service +} + +func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alpha1.CheckAccessRequest) (*apiv1alpha1.CheckAccessResponse, error) { + resourceTypes := map[apiv1alpha1.AuthorizationResourceType]string{ + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE: auth.ResourceAgentTemplate, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS: auth.ResourceHarness, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG: auth.ResourceModelConfig, + } + verbs := map[apiv1alpha1.AuthorizationVerb]auth.Verb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET: auth.VerbGet, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE: auth.VerbCreate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE: auth.VerbUpdate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE: auth.VerbDelete, + } + authorizationVerbs := map[auth.Verb]apiv1alpha1.AuthorizationVerb{ + auth.VerbGet: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET, + auth.VerbCreate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + auth.VerbUpdate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + auth.VerbDelete: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE, + } + requestVerbs := make([]auth.Verb, len(request.GetVerbs())) + for i, verb := range request.GetVerbs() { + requestVerbs[i] = verbs[verb] + } + requestTargets := make([]accessreview.Target, len(request.GetTargets())) + for i, target := range request.GetTargets() { + requestTargets[i] = accessreview.Target{Namespace: target.GetNamespace(), Name: target.GetName()} + } + + results, err := s.service.Check(ctx, resourceTypes[request.GetResourceType()], requestVerbs, requestTargets) + if err != nil { + return nil, err + } + + response := &apiv1alpha1.CheckAccessResponse{Results: make([]*apiv1alpha1.ResourceAccess, len(results))} + for i, result := range results { + target := &apiv1alpha1.AccessTarget{Namespace: result.Target.Namespace} + if result.Target.Name != "" { + name := result.Target.Name + target.Name = &name + } + allowedVerbs := make([]apiv1alpha1.AuthorizationVerb, len(result.AllowedVerbs)) + for j, verb := range result.AllowedVerbs { + allowedVerbs[j] = authorizationVerbs[verb] + } + response.Results[i] = &apiv1alpha1.ResourceAccess{Target: target, AllowedVerbs: allowedVerbs} + } + return response, nil +} diff --git a/go/core/internal/grpcserver/authorization_test.go b/go/core/internal/grpcserver/authorization_test.go new file mode 100644 index 000000000..99cf97e93 --- /dev/null +++ b/go/core/internal/grpcserver/authorization_test.go @@ -0,0 +1,104 @@ +package grpcserver + +import ( + "context" + "net" + "testing" + + apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" +) + +type accessReviewScopeCall struct { + verb pkgauth.Verb + resourceType string +} + +type accessReviewAuthorizer struct { + scopeCalls []accessReviewScopeCall +} + +func (*accessReviewAuthorizer) Check(context.Context, pkgauth.Principal, pkgauth.Verb, pkgauth.Resource) error { + return nil +} + +func (a *accessReviewAuthorizer) Scope(_ context.Context, _ pkgauth.Principal, verb pkgauth.Verb, resourceType string) (apiauthorization.AuthorizationScope, error) { + a.scopeCalls = append(a.scopeCalls, accessReviewScopeCall{verb: verb, resourceType: resourceType}) + return apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}, nil +} + +func TestAuthorizationServiceGeneratedClient(t *testing.T) { + authorizer := &accessReviewAuthorizer{} + listener := bufconn.Listen(DefaultMaxMessageSize) + server, err := New(Config{ + Listener: listener, + Registerer: prometheus.NewRegistry(), + Authenticator: &authimpl.UnsecureAuthenticator{}, + SystemService: testSystemService(), + AuthorizationService: accessreview.NewService(authorizer), + }) + require.NoError(t, err) + serverContext, cancelServer := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- server.Start(serverContext) }() + t.Cleanup(func() { + cancelServer() + assert.NoError(t, <-done) + }) + + connection, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + client := apiv1alpha1.NewAuthorizationServiceClient(connection) + name := "assistant" + + response, err := client.CheckAccess(t.Context(), &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + Targets: []*apiv1alpha1.AccessTarget{ + {Namespace: "team-a", Name: &name}, + {Namespace: "team-b"}, + }, + }) + require.NoError(t, err) + want := &apiv1alpha1.CheckAccessResponse{ + Results: []*apiv1alpha1.ResourceAccess{ + { + Target: &apiv1alpha1.AccessTarget{Namespace: "team-a", Name: &name}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + }, + { + Target: &apiv1alpha1.AccessTarget{Namespace: "team-b"}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + }, + }, + } + assert.True(t, proto.Equal(want, response), "response = %v, want %v", response, want) + assert.Equal(t, []accessReviewScopeCall{ + {verb: pkgauth.VerbUpdate, resourceType: pkgauth.ResourceAgentTemplate}, + {verb: pkgauth.VerbCreate, resourceType: pkgauth.ResourceAgentTemplate}, + }, authorizer.scopeCalls) +} diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index b296d8f44..4127be9f4 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -57,6 +57,7 @@ func DefaultMethodPolicies() MethodPolicies { apiv1alpha1.HarnessService_ListHarnesses_FullMethodName: auth.AccessRead, apiv1alpha1.HarnessService_CreateHarness_FullMethodName: auth.AccessCreate, apiv1alpha1.HarnessService_DeleteHarness_FullMethodName: auth.AccessDelete, + apiv1alpha1.AuthorizationService_CheckAccess_FullMethodName: auth.AccessRead, } policies[apiv1alpha1.AgentInstanceService_CreateAgentInstance_FullMethodName] = auth.AccessCreate policies[apiv1alpha1.AgentInstanceService_GetAgentInstance_FullMethodName] = auth.AccessRead diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index 88588d41f..507bb49bc 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -45,6 +45,12 @@ func TestAgentInstanceServicePoliciesMatchTheirEffect(t *testing.T) { } } +func TestAuthorizationServicePolicyIsRead(t *testing.T) { + if got := DefaultMethodPolicies()[apiv1alpha1.AuthorizationService_CheckAccess_FullMethodName]; got != pkgauth.AccessRead { + t.Fatalf("CheckAccess policy = %q, want %q", got, pkgauth.AccessRead) + } +} + // TestReadOnlyShareCannotRenameAConversation is the property the policy entry // exists for, measured through the interceptor rather than read off the table: a // read-only share link may open a conversation and must not be able to retitle diff --git a/go/core/internal/grpcserver/protovalidate_test.go b/go/core/internal/grpcserver/protovalidate_test.go index 93c5c19b9..82f8f30a4 100644 --- a/go/core/internal/grpcserver/protovalidate_test.go +++ b/go/core/internal/grpcserver/protovalidate_test.go @@ -69,6 +69,90 @@ func TestAgentInstanceRequestValidation(t *testing.T) { } } +func TestCheckAccessRequestValidation(t *testing.T) { + validator, err := protovalidate.New() + if err != nil { + t.Fatal(err) + } + name := "assistant" + emptyName := "" + targets := make([]*apiv1alpha1.AccessTarget, 101) + for i := range targets { + targets[i] = &apiv1alpha1.AccessTarget{Namespace: "team-a"} + } + for _, test := range []struct { + name string + request *apiv1alpha1.CheckAccessRequest + valid bool + }{ + { + name: "named and namespace targets", + request: &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE}, + Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &name}, {Namespace: "team-b"}}, + }, + valid: true, + }, + { + name: "harness create and delete", + request: &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, + Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE}, + Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}, + }, + valid: true, + }, + { + name: "missing resource type", + request: &apiv1alpha1.CheckAccessRequest{Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "missing verbs", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "duplicate verbs", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "unknown verb", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{99}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "missing targets", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}}, + }, + { + name: "nil target", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{nil}}, + }, + { + name: "too many targets", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: targets}, + }, + { + name: "invalid namespace", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "NOT A NAMESPACE"}}}, + }, + { + name: "present empty name", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &emptyName}}}, + }, + { + name: "unsupported harness verb", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &name}}}, + }, + } { + t.Run(test.name, func(t *testing.T) { + err := validator.Validate(test.request) + if (err == nil) != test.valid { + t.Fatalf("Validate() error = %v, valid = %t", err, test.valid) + } + }) + } +} + func TestInvalidInstanceAndCheckpointIDsNeverReachHandlers(t *testing.T) { validator, err := protovalidate.New() if err != nil { diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index f591b1973..5cc730c56 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -15,6 +15,7 @@ import ( protovalidatemiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" @@ -59,6 +60,7 @@ type Config struct { AgentInstanceService *agentinstance.Service CheckpointService *checkpoint.Service ScheduledRunService *scheduledrun.Service + AuthorizationService *accessreview.Service A2AHandler a2asrv.RequestHandler // RegisterServices registers services core does not own. Called during New, // because gRPC requires every service to be registered before Serve. @@ -155,6 +157,9 @@ func New(config Config) (*Server, error) { if config.CheckpointService != nil { apiv1alpha1.RegisterCheckpointServiceServer(grpcServer, &checkpointServer{service: config.CheckpointService}) } + if config.AuthorizationService != nil { + apiv1alpha1.RegisterAuthorizationServiceServer(grpcServer, &authorizationServer{service: config.AuthorizationService}) + } if config.A2AHandler != nil { a2agrpc.NewHandler(config.A2AHandler).RegisterWith(grpcServer) } diff --git a/go/core/internal/service/accessreview/service.go b/go/core/internal/service/accessreview/service.go new file mode 100644 index 000000000..dd6661cf0 --- /dev/null +++ b/go/core/internal/service/accessreview/service.go @@ -0,0 +1,67 @@ +package accessreview + +import ( + "context" + + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Service struct { + authorizer auth.CollectionAuthorizer +} + +type Target struct { + Namespace string + Name string +} + +type Result struct { + Target Target + AllowedVerbs []auth.Verb +} + +func NewService(authorizer auth.CollectionAuthorizer) *Service { + return &Service{authorizer: authorizer} +} + +func (s *Service) Check(ctx context.Context, resourceType string, verbs []auth.Verb, targets []Target) ([]Result, error) { + session, ok := auth.AuthSessionFrom(ctx) + if !ok { + return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) + } + + results := make([]Result, len(targets)) + for i, target := range targets { + results[i].Target = target + } + + for _, verb := range verbs { + scope, err := s.authorizer.Scope(ctx, session.Principal(), verb, resourceType) + if err != nil { + return nil, serviceerrors.NewUnavailable("Failed to read the "+resourceType+" authorization scope", err) + } + matcher, err := kubeauth.CompileScope(scope) + if err != nil { + return nil, serviceerrors.NewInternal("Failed to apply the "+resourceType+" authorization scope", err) + } + for i, target := range targets { + var allowed bool + if target.Name == "" { + allowed = matcher.MatchesAnyName(target.Namespace) + } else { + allowed = matcher.Matches(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{ + Namespace: target.Namespace, + Name: target.Name, + }}) + } + if allowed { + results[i].AllowedVerbs = append(results[i].AllowedVerbs, verb) + } + } + } + + return results, nil +} diff --git a/go/core/internal/service/accessreview/service_test.go b/go/core/internal/service/accessreview/service_test.go new file mode 100644 index 000000000..b919073d8 --- /dev/null +++ b/go/core/internal/service/accessreview/service_test.go @@ -0,0 +1,134 @@ +package accessreview_test + +import ( + "context" + "errors" + "testing" + + apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" + "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testSession struct{ principal auth.Principal } + +func (s testSession) Principal() auth.Principal { return s.principal } + +type scopeCall struct { + principal auth.Principal + verb auth.Verb + resourceType string +} + +type testAuthorizer struct { + scopes map[auth.Verb]apiauthorization.AuthorizationScope + scopeErrs map[auth.Verb]error + scopeCalls []scopeCall + checkCalls int +} + +func (a *testAuthorizer) Check(context.Context, auth.Principal, auth.Verb, auth.Resource) error { + a.checkCalls++ + return nil +} + +func (a *testAuthorizer) Scope(_ context.Context, principal auth.Principal, verb auth.Verb, resourceType string) (apiauthorization.AuthorizationScope, error) { + a.scopeCalls = append(a.scopeCalls, scopeCall{principal: principal, verb: verb, resourceType: resourceType}) + return a.scopes[verb], a.scopeErrs[verb] +} + +func TestCheckAccessMatrix(t *testing.T) { + principal := auth.Principal{User: auth.User{ID: "reader"}} + ctx := auth.AuthSessionTo(t.Context(), testSession{principal: principal}) + authorizer := &testAuthorizer{scopes: map[auth.Verb]apiauthorization.AuthorizationScope{ + auth.VerbUpdate: { + Kind: apiauthorization.ScopeAnyOf, + AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"assistant"}}, + }}}, + }, + auth.VerbCreate: { + Kind: apiauthorization.ScopeAnyOf, + AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}, + }}}, + }, + }} + targets := []accessreview.Target{ + {Namespace: "team-a", Name: "assistant"}, + {Namespace: "team-b", Name: "assistant"}, + {Namespace: "team-a"}, + {Namespace: "team-a", Name: "other"}, + } + + results, err := accessreview.NewService(authorizer).Check( + ctx, + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbUpdate, auth.VerbCreate}, + targets, + ) + require.NoError(t, err) + assert.Equal(t, []accessreview.Result{ + {Target: targets[0], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, + {Target: targets[1]}, + {Target: targets[2], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, + {Target: targets[3], AllowedVerbs: []auth.Verb{auth.VerbCreate}}, + }, results) + assert.Equal(t, []scopeCall{ + {principal: principal, verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate}, + {principal: principal, verb: auth.VerbCreate, resourceType: auth.ResourceAgentTemplate}, + }, authorizer.scopeCalls) + assert.Zero(t, authorizer.checkCalls) +} + +func TestCheckAccessScopeFailures(t *testing.T) { + tests := []struct { + name string + scope apiauthorization.AuthorizationScope + scopeError error + wantCode serviceerrors.Code + }{ + { + name: "authorizer unavailable", + scopeError: errors.New("unavailable"), + wantCode: serviceerrors.CodeUnavailable, + }, + { + name: "malformed scope", + scope: apiauthorization.AuthorizationScope{}, + wantCode: serviceerrors.CodeInternal, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + authorizer := &testAuthorizer{ + scopes: map[auth.Verb]apiauthorization.AuthorizationScope{auth.VerbCreate: test.scope}, + scopeErrs: map[auth.Verb]error{auth.VerbCreate: test.scopeError}, + } + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + _, err := accessreview.NewService(authorizer).Check( + ctx, + auth.ResourceModelConfig, + []auth.Verb{auth.VerbCreate}, + []accessreview.Target{{Namespace: "team-a"}}, + ) + assert.True(t, serviceerrors.IsCode(err, test.wantCode), "error = %v", err) + }) + } +} + +func TestCheckAccessRequiresSession(t *testing.T) { + _, err := accessreview.NewService(&testAuthorizer{}).Check( + t.Context(), + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbGet}, + []accessreview.Target{{Namespace: "team-a", Name: "assistant"}}, + ) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeUnauthenticated), "error = %v", err) +} diff --git a/go/core/internal/service/kubeauth/scope.go b/go/core/internal/service/kubeauth/scope.go index 6d5fe6de2..bee7d5e8e 100644 --- a/go/core/internal/service/kubeauth/scope.go +++ b/go/core/internal/service/kubeauth/scope.go @@ -6,6 +6,7 @@ import ( apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" ) // Matcher is a validated authorization scope that can be applied to Kubernetes objects. @@ -87,3 +88,33 @@ func (m Matcher) Matches(object metav1.Object) bool { } return false } + +// MatchesAnyName reports whether the scope contains any valid Kubernetes +// resource name in namespace. +func (m Matcher) MatchesAnyName(namespace string) bool { + if len(utilvalidation.IsDNS1123Label(namespace)) != 0 { + return false + } + if m.scope.Kind == apiauthorization.ScopeAll { + return true + } + for _, clause := range m.scope.AnyOf { + names := []string{"x"} + for _, predicate := range clause.All { + if predicate.Attribute == apiauthorization.AttributeName { + names = predicate.Values + break + } + } + for _, name := range names { + if len(utilvalidation.IsDNS1123Subdomain(name)) != 0 { + continue + } + object := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}} + if m.Matches(object) { + return true + } + } + } + return false +} diff --git a/go/core/internal/service/kubeauth/scope_test.go b/go/core/internal/service/kubeauth/scope_test.go index 292945017..615ebebf1 100644 --- a/go/core/internal/service/kubeauth/scope_test.go +++ b/go/core/internal/service/kubeauth/scope_test.go @@ -89,6 +89,63 @@ func TestMatcherOwnsCompiledScope(t *testing.T) { } } +func TestMatcherMatchesAnyName(t *testing.T) { + tests := []struct { + name string + namespace string + scope apiauthorization.AuthorizationScope + want bool + }{ + {name: "all", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}, want: true}, + {name: "none", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeNone}}, + {name: "invalid namespace", namespace: "NOT A NAMESPACE", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}}, + { + name: "namespace", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}}}}}, + want: true, + }, + { + name: "different namespace", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-b"}}}}}}, + }, + { + name: "intersecting names", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-a", "agent-b"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-b"}}, + }}}}, + want: true, + }, + { + name: "disjoint names", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-a"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-b"}}, + }}}}, + }, + { + name: "invalid resource name", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"NOT A NAME"}}}}}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + namespace := test.namespace + if namespace == "" { + namespace = "team-a" + } + matcher, err := kubeauth.CompileScope(test.scope) + if err != nil { + t.Fatalf("CompileScope() error = %v", err) + } + if got := matcher.MatchesAnyName(namespace); got != test.want { + t.Fatalf("MatchesAnyName() = %v, want %v", got, test.want) + } + }) + } +} + func TestCompileScopeRejectsInvalidScopes(t *testing.T) { tests := []struct { name string diff --git a/go/core/internal/service/model/service.go b/go/core/internal/service/model/service.go index 47e93a9e8..f77365201 100644 --- a/go/core/internal/service/model/service.go +++ b/go/core/internal/service/model/service.go @@ -20,9 +20,6 @@ import ( var modelConfigGVK = v1alpha3.GroupVersion.WithKind("ModelConfig") -// modelConfigResource names ModelConfig in authorization decisions. -const modelConfigResource = "ModelConfig" - type Service struct { kubeClient client.Client modelConfigs *kubecrud.Service[*v1alpha3.ModelConfig, *v1alpha3.ModelConfigList] @@ -57,7 +54,7 @@ type DeleteRequest struct { func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, defaultNamespace string, options ...ServiceOption) *Service { service := &Service{ kubeClient: kubeClient, - modelConfigs: kubecrud.NewService(kubeClient, authorizer, &v1alpha3.ModelConfig{}, &v1alpha3.ModelConfigList{}, modelConfigResource), + modelConfigs: kubecrud.NewService(kubeClient, authorizer, &v1alpha3.ModelConfig{}, &v1alpha3.ModelConfigList{}, auth.ResourceModelConfig), defaultNamespace: defaultNamespace, } for _, option := range options { diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 0457c1c9e..77f2c4a35 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -31,6 +31,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/grpcserver" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" v2mcp "github.com/kagent-dev/kagent/go/core/internal/mcp" + "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" @@ -321,10 +322,11 @@ func Run(ctx context.Context, opts Options) error { MemoryService: memory, AgentInstanceService: instances, ScheduledRunService: schedules, + AuthorizationService: accessreview.NewService(authorizer), // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. - AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), + AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, auth.ResourceAgentTemplate), + HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, auth.ResourceHarness), CheckpointService: checkpoints, A2AHandler: gateway, HTTPHandler: mux, diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index da3e8fd19..891c3ee44 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -18,6 +18,12 @@ const ( VerbDelete Verb = "delete" ) +const ( + ResourceAgentTemplate = "AgentTemplate" + ResourceHarness = "Harness" + ResourceModelConfig = "ModelConfig" +) + type Resource struct { Type string Namespace string diff --git a/go/core/test/e2e/access_review_test.go b/go/core/test/e2e/access_review_test.go new file mode 100644 index 000000000..fa5b1bf1c --- /dev/null +++ b/go/core/test/e2e/access_review_test.go @@ -0,0 +1,48 @@ +package e2e_test + +import ( + "context" + "testing" + "time" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" +) + +func TestE2EAccessReview(t *testing.T) { + t.Parallel() + connection, err := grpc.NewClient(interactionTarget(t), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(t.Context(), "x-user-id", "e2e"), time.Minute) + t.Cleanup(cancel) + name := "smoke" + + response, err := apiv1alpha1.NewAuthorizationServiceClient(connection).CheckAccess(ctx, &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + }, + Targets: []*apiv1alpha1.AccessTarget{ + {Namespace: "kagent"}, + {Namespace: "kagent", Name: &name}, + }, + }) + require.NoError(t, err) + want := &apiv1alpha1.CheckAccessResponse{Results: []*apiv1alpha1.ResourceAccess{ + { + Target: &apiv1alpha1.AccessTarget{Namespace: "kagent"}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, + }, + { + Target: &apiv1alpha1.AccessTarget{Namespace: "kagent", Name: &name}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, + }, + }} + require.True(t, proto.Equal(want, response), "response = %v, want %v", response, want) +} diff --git a/proto/kagent/api/v1alpha1/authorization.proto b/proto/kagent/api/v1alpha1/authorization.proto new file mode 100644 index 000000000..6023a5be8 --- /dev/null +++ b/proto/kagent/api/v1alpha1/authorization.proto @@ -0,0 +1,79 @@ +syntax = "proto3"; + +package kagent.api.v1alpha1; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1;apiv1alpha1"; + +// AuthorizationService answers advisory access checks for catalog UI actions. +// The resource operation remains authoritative. +service AuthorizationService { + rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); +} + +enum AuthorizationResourceType { + AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; + AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; + AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; +} + +enum AuthorizationVerb { + AUTHORIZATION_VERB_UNSPECIFIED = 0; + AUTHORIZATION_VERB_GET = 1; + AUTHORIZATION_VERB_CREATE = 2; + AUTHORIZATION_VERB_UPDATE = 3; + AUTHORIZATION_VERB_DELETE = 4; +} + +message CheckAccessRequest { + option (buf.validate.message).cel = { + id: "supported_resource_verbs" + message: "Harness supports only CREATE and DELETE access reviews" + expression: "this.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)" + }; + AuthorizationResourceType resource_type = 1 [(buf.validate.field).enum = { + defined_only: true + not_in: 0 + }]; + repeated AuthorizationVerb verbs = 2 [(buf.validate.field).repeated = { + min_items: 1 + max_items: 4 + unique: true + items: { + enum: { + defined_only: true + not_in: 0 + } + } + }]; + repeated AccessTarget targets = 3 [(buf.validate.field).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +message AccessTarget { + string namespace = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 63 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }]; + // When omitted, the review checks whether any valid resource name in the + // namespace is permitted. + optional string name = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 253 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" + }]; +} + +message CheckAccessResponse { + repeated ResourceAccess results = 1; +} + +message ResourceAccess { + AccessTarget target = 1; + repeated AuthorizationVerb allowed_verbs = 2; +} diff --git a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts new file mode 100644 index 000000000..ac9976835 --- /dev/null +++ b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts @@ -0,0 +1,191 @@ +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" +// @generated from file kagent/api/v1alpha1/authorization.proto (package kagent.api.v1alpha1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file kagent/api/v1alpha1/authorization.proto. + */ +export const file_kagent_api_v1alpha1_authorization: GenFile = /*@__PURE__*/ + fileDesc("CidrYWdlbnQvYXBpL3YxYWxwaGExL2F1dGhvcml6YXRpb24ucHJvdG8SE2thZ2VudC5hcGkudjFhbHBoYTEimgMKEkNoZWNrQWNjZXNzUmVxdWVzdBJRCg1yZXNvdXJjZV90eXBlGAEgASgOMi4ua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlQgq6SAeCAQQQASAAEkwKBXZlcmJzGAIgAygOMiYua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uVmVyYkIVukgSkgEPCAEQBBgBIgeCAQQQASAAEj4KB3RhcmdldHMYAyADKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLkFjY2Vzc1RhcmdldEIKukgHkgEECAEQZDqiAbpIngEamwEKGHN1cHBvcnRlZF9yZXNvdXJjZV92ZXJicxI2SGFybmVzcyBzdXBwb3J0cyBvbmx5IENSRUFURSBhbmQgREVMRVRFIGFjY2VzcyByZXZpZXdzGkd0aGlzLnJlc291cmNlX3R5cGUgIT0gMiB8fCB0aGlzLnZlcmJzLmFsbCh2ZXJiLCB2ZXJiID09IDIgfHwgdmVyYiA9PSA0KSK5AQoMQWNjZXNzVGFyZ2V0Ej0KCW5hbWVzcGFjZRgBIAEoCUIqukgnciUQARg/Mh9eW2EtejAtOV0oWy1hLXowLTldKlthLXowLTldKT8kEmEKBG5hbWUYAiABKAlCTrpIS3JJEAEY/QEyQl5bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyhbLl1bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPykqJEgAiAEBQgcKBV9uYW1lIksKE0NoZWNrQWNjZXNzUmVzcG9uc2USNAoHcmVzdWx0cxgBIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VBY2Nlc3MiggEKDlJlc291cmNlQWNjZXNzEjEKBnRhcmdldBgBIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuQWNjZXNzVGFyZ2V0Ej0KDWFsbG93ZWRfdmVyYnMYAiADKA4yJi5rYWdlbnQuYXBpLnYxYWxwaGExLkF1dGhvcml6YXRpb25WZXJiKs8BChlBdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlEisKJ0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9VTlNQRUNJRklFRBAAEi4KKkFVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9BR0VOVF9URU1QTEFURRABEicKI0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9IQVJORVNTEAISLAooQVVUSE9SSVpBVElPTl9SRVNPVVJDRV9UWVBFX01PREVMX0NPTkZJRxADKrABChFBdXRob3JpemF0aW9uVmVyYhIiCh5BVVRIT1JJWkFUSU9OX1ZFUkJfVU5TUEVDSUZJRUQQABIaChZBVVRIT1JJWkFUSU9OX1ZFUkJfR0VUEAESHQoZQVVUSE9SSVpBVElPTl9WRVJCX0NSRUFURRACEh0KGUFVVEhPUklaQVRJT05fVkVSQl9VUERBVEUQAxIdChlBVVRIT1JJWkFUSU9OX1ZFUkJfREVMRVRFEAQyeAoUQXV0aG9yaXphdGlvblNlcnZpY2USYAoLQ2hlY2tBY2Nlc3MSJy5rYWdlbnQuYXBpLnYxYWxwaGExLkNoZWNrQWNjZXNzUmVxdWVzdBooLmthZ2VudC5hcGkudjFhbHBoYTEuQ2hlY2tBY2Nlc3NSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_buf_validate_validate]); + +/** + * @generated from message kagent.api.v1alpha1.CheckAccessRequest + */ +export type CheckAccessRequest = Message<"kagent.api.v1alpha1.CheckAccessRequest"> & { + /** + * @generated from field: kagent.api.v1alpha1.AuthorizationResourceType resource_type = 1; + */ + resourceType: AuthorizationResourceType; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AuthorizationVerb verbs = 2; + */ + verbs: AuthorizationVerb[]; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AccessTarget targets = 3; + */ + targets: AccessTarget[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.CheckAccessRequest. + * Use `create(CheckAccessRequestSchema)` to create a new message. + */ +export const CheckAccessRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 0); + +/** + * @generated from message kagent.api.v1alpha1.AccessTarget + */ +export type AccessTarget = Message<"kagent.api.v1alpha1.AccessTarget"> & { + /** + * @generated from field: string namespace = 1; + */ + namespace: string; + + /** + * When omitted, the review checks whether any valid resource name in the + * namespace is permitted. + * + * @generated from field: optional string name = 2; + */ + name?: string | undefined; +}; + +/** + * Describes the message kagent.api.v1alpha1.AccessTarget. + * Use `create(AccessTargetSchema)` to create a new message. + */ +export const AccessTargetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 1); + +/** + * @generated from message kagent.api.v1alpha1.CheckAccessResponse + */ +export type CheckAccessResponse = Message<"kagent.api.v1alpha1.CheckAccessResponse"> & { + /** + * @generated from field: repeated kagent.api.v1alpha1.ResourceAccess results = 1; + */ + results: ResourceAccess[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.CheckAccessResponse. + * Use `create(CheckAccessResponseSchema)` to create a new message. + */ +export const CheckAccessResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 2); + +/** + * @generated from message kagent.api.v1alpha1.ResourceAccess + */ +export type ResourceAccess = Message<"kagent.api.v1alpha1.ResourceAccess"> & { + /** + * @generated from field: kagent.api.v1alpha1.AccessTarget target = 1; + */ + target?: AccessTarget | undefined; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AuthorizationVerb allowed_verbs = 2; + */ + allowedVerbs: AuthorizationVerb[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.ResourceAccess. + * Use `create(ResourceAccessSchema)` to create a new message. + */ +export const ResourceAccessSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 3); + +/** + * @generated from enum kagent.api.v1alpha1.AuthorizationResourceType + */ +export enum AuthorizationResourceType { + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + */ + AGENT_TEMPLATE = 1, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; + */ + HARNESS = 2, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; + */ + MODEL_CONFIG = 3, +} + +/** + * Describes the enum kagent.api.v1alpha1.AuthorizationResourceType. + */ +export const AuthorizationResourceTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_kagent_api_v1alpha1_authorization, 0); + +/** + * @generated from enum kagent.api.v1alpha1.AuthorizationVerb + */ +export enum AuthorizationVerb { + /** + * @generated from enum value: AUTHORIZATION_VERB_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHORIZATION_VERB_GET = 1; + */ + GET = 1, + + /** + * @generated from enum value: AUTHORIZATION_VERB_CREATE = 2; + */ + CREATE = 2, + + /** + * @generated from enum value: AUTHORIZATION_VERB_UPDATE = 3; + */ + UPDATE = 3, + + /** + * @generated from enum value: AUTHORIZATION_VERB_DELETE = 4; + */ + DELETE = 4, +} + +/** + * Describes the enum kagent.api.v1alpha1.AuthorizationVerb. + */ +export const AuthorizationVerbSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_kagent_api_v1alpha1_authorization, 1); + +/** + * AuthorizationService answers advisory access checks for catalog UI actions. + * The resource operation remains authoritative. + * + * @generated from service kagent.api.v1alpha1.AuthorizationService + */ +export const AuthorizationService: GenService<{ + /** + * @generated from rpc kagent.api.v1alpha1.AuthorizationService.CheckAccess + */ + checkAccess: { + methodKind: "unary"; + input: typeof CheckAccessRequestSchema; + output: typeof CheckAccessResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_kagent_api_v1alpha1_authorization, 0); + From a4baed502fe14991aea2973abe23378370df7673 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 18 Sep 2026 16:18:00 -0700 Subject: [PATCH 5/8] refactor: move access review into kubeauth Signed-off-by: Cody Hartsook --- design/EP-2860-access-review-rpc.md | 224 ------------------ go/core/internal/grpcserver/authorization.go | 10 +- .../internal/grpcserver/authorization_test.go | 4 +- go/core/internal/grpcserver/server.go | 6 +- .../service.go => kubeauth/review.go} | 23 +- .../review_test.go} | 18 +- go/core/pkg/app/app.go | 4 +- 7 files changed, 32 insertions(+), 257 deletions(-) delete mode 100644 design/EP-2860-access-review-rpc.md rename go/core/internal/service/{accessreview/service.go => kubeauth/review.go} (69%) rename go/core/internal/service/{accessreview/service_test.go => kubeauth/review_test.go} (89%) diff --git a/design/EP-2860-access-review-rpc.md b/design/EP-2860-access-review-rpc.md deleted file mode 100644 index a4ede0715..000000000 --- a/design/EP-2860-access-review-rpc.md +++ /dev/null @@ -1,224 +0,0 @@ -# EP-2860: Batched access review for catalog actions - -> **Discussion draft:** remove this document before the PR merges. - -- OSS issue: [kagent-dev/kagent#2860](https://github.com/kagent-dev/kagent/issues/2860) -- Enterprise context: [solo-io/enterprise-kagent#95](https://github.com/solo-io/enterprise-kagent/issues/95) - -## Summary - -Add an authenticated `AuthorizationService.CheckAccess` RPC that returns an -advisory permission matrix. One request checks several targets of one catalog -resource type against several verbs. - -This gives the enterprise UI the same early UX signals as the former -`canCreate`, `canUpdate`, and `canDelete` fields without coupling authorization -state to catalog resources. Real operations continue to authorize every request. - -## Scope - -The RPC should: - -- support create-button, namespace-picker, and per-item action UX; -- review a page of resources in one browser request; -- reuse the existing resource types, verbs, and authorization scopes; and -- preserve the default OSS authorizer behavior. - -It will not return roles, policies, denial reasons, or raw scopes; review `LIST`; -read Kubernetes resources; add server-side caching; or change the OSS UI. - -## API - -```proto -service AuthorizationService { - rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); -} - -enum AuthorizationResourceType { - AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; - AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; - AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; - AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; -} - -enum AuthorizationVerb { - AUTHORIZATION_VERB_UNSPECIFIED = 0; - AUTHORIZATION_VERB_GET = 1; - AUTHORIZATION_VERB_CREATE = 2; - AUTHORIZATION_VERB_UPDATE = 3; - AUTHORIZATION_VERB_DELETE = 4; -} - -message AccessTarget { - string namespace = 1; - optional string name = 2; -} - -message CheckAccessRequest { - AuthorizationResourceType resource_type = 1; - repeated AuthorizationVerb verbs = 2; - repeated AccessTarget targets = 3; -} - -message CheckAccessResponse { - repeated ResourceAccess results = 1; -} - -message ResourceAccess { - AccessTarget target = 1; - repeated AuthorizationVerb allowed_verbs = 2; -} -``` - -One resource type per request matches current UI surfaces and lets the server -evaluate each requested verb's scope once. Results preserve target order and echo -the target so callers need no encoded map key. - -### Validation - -Declare validation in the proto with `buf.validate`: - -- require defined, non-zero enums; -- require 1–4 unique verbs and 1–100 targets; -- require a Kubernetes DNS-label namespace; -- when present, require a non-empty DNS-subdomain name; and -- reject unsupported resource/verb combinations. - -Initial supported verbs: - -| Resource | Verbs | -| --- | --- | -| `AgentTemplate` | `GET`, `CREATE`, `UPDATE`, `DELETE` | -| `Harness` | `CREATE`, `DELETE` | -| `ModelConfig` | `GET`, `CREATE`, `UPDATE`, `DELETE` | - -The limits bound a request to 400 decisions while covering current 25-row pages. - -## Semantics - -### Named target - -`{namespace, name}` checks the exact resource identity. The review does not load -the resource, avoiding an existence side channel. The later operation loads its -real input and authorizes again. - -### Namespace target - -`{namespace}` asks whether at least one valid resource name in that namespace -could satisfy the action scope. For `CREATE`, this replaces collection-level -`canCreate`; it does not authorize the object eventually submitted. - -Namespace is always required. To decide a global Create button, the UI sends all -candidate namespaces as targets in one request and shows the button if any allow -`CREATE`. It can then hide or disable denied namespaces in the form. - -```json -{ - "resourceType": "AGENT_TEMPLATE", - "verbs": ["CREATE"], - "targets": [ - {"namespace": "kagent"}, - {"namespace": "team-a"} - ] -} -``` - -### Denials and failures - -- A denied verb is absent from `allowed_verbs`. -- Missing authentication returns `Unauthenticated`. -- Invalid input returns `InvalidArgument`. -- Scope lookup failure returns `Unavailable`. -- A malformed authorizer scope returns `Internal`. - -The first version fails the whole RPC if any scope cannot be evaluated. Per-cell -errors add little value because the review is advisory and real operations remain -authoritative. - -## Evaluation and performance - -For each requested verb, call `CollectionAuthorizer.Scope` once, compile the -existing matcher, and apply it to every target: - -```text -for verb in request.verbs: - matcher = CompileScope(authorizer.Scope(principal, verb, resourceType)) - for target in request.targets: - allowed = target.name is present - ? matcher.Matches(namespace, name) - : matcher.MatchesAnyName(namespace) -``` - -`Check` and `Scope` must derive from the same policy evaluation so exact-target -answers agree. - -For 100 rows showing update and delete actions: - -| Shape | Browser requests | Scope evaluations | Local matches | -| --- | ---: | ---: | ---: | -| One RPC per resource and verb | 200 | up to 200 | 0 | -| Batched matrix | 1 | 2 | 200 | - -There is no server cache. The enterprise UI may use SWR to deduplicate or briefly -cache advisory responses, but a cached result never bypasses operation-time -authorization. - -## UI behavior - -- **Collection:** check `CREATE` for candidate namespaces while loading the page; - show Create if any namespace allows it. -- **Create form:** show or enable only allowed namespaces. -- **List:** check visible named rows for the actions rendered on that page. -- **Detail:** send one named target with the required actions. -- **Loading:** avoid flashing controls before the review completes. -- **Review failure:** do not treat transport failure as policy denial; preserve a - path to the authoritative operation. - -The OSS UI will not call the RPC. Generated TypeScript is consumed by the -enterprise UI. - -## Backend and security - -- The protobuf adapter maps closed enums to canonical `auth.Verb` and catalog - resource types. -- A transport-independent service obtains the authenticated principal and - evaluates scopes. -- `kubeauth.Matcher` owns exact and existential matching. -- The RPC requires `AccessRead` method policy. -- It uses no Kubernetes client or database. -- The default `NoopAuthorizer` returns `ALL`. -- Responses reveal only Boolean action results, not policy structure or resource - existence. - -## Tests - -- Proto validation: enums, duplicate verbs, limits, names, namespaces, and - supported combinations. -- Matcher: `ALL`, `NONE`, exact targets, namespace/name predicates, OR clauses, - and nameless targets. -- Service: one scope lookup per verb, stable result order, and allowed verbs. -- gRPC: authentication, enum mapping, registration, and default allow behavior. -- Enterprise UI: Create visibility, namespace choices, per-item actions, loading, - advisory failure, and mutation-time denial. - -## Alternatives rejected - -- **Capability fields:** couple authorization to catalog schemas and cannot be - refreshed independently. -- **Independent check list:** repeats resource and verb data and encourages one - policy evaluation per cell. -- **Scopes in the browser:** expose policy representation and duplicate matcher - semantics. -- **Separate singular RPC:** duplicates an API already covered by a one-cell - matrix. - -## Questions for review - -1. Should nameless targets work for every verb or only `CREATE`? -2. Is 100 the right initial target limit? -3. Should results echo targets or rely only on ordering? -4. If an operation has multiple authorization prerequisites, should its matrix - cell require all of them? -5. Does the enterprise authorizer guarantee equivalent `Scope` and exact `Check` - decisions? If not, the wire API can remain batched while the initial server - loops over `Check` calls. diff --git a/go/core/internal/grpcserver/authorization.go b/go/core/internal/grpcserver/authorization.go index 20afd980d..ee8522746 100644 --- a/go/core/internal/grpcserver/authorization.go +++ b/go/core/internal/grpcserver/authorization.go @@ -4,13 +4,13 @@ import ( "context" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" - "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/pkg/auth" ) type authorizationServer struct { apiv1alpha1.UnimplementedAuthorizationServiceServer - service *accessreview.Service + reviewer *kubeauth.Reviewer } func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alpha1.CheckAccessRequest) (*apiv1alpha1.CheckAccessResponse, error) { @@ -35,12 +35,12 @@ func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alp for i, verb := range request.GetVerbs() { requestVerbs[i] = verbs[verb] } - requestTargets := make([]accessreview.Target, len(request.GetTargets())) + requestTargets := make([]kubeauth.ReviewTarget, len(request.GetTargets())) for i, target := range request.GetTargets() { - requestTargets[i] = accessreview.Target{Namespace: target.GetNamespace(), Name: target.GetName()} + requestTargets[i] = kubeauth.ReviewTarget{Namespace: target.GetNamespace(), Name: target.GetName()} } - results, err := s.service.Check(ctx, resourceTypes[request.GetResourceType()], requestVerbs, requestTargets) + results, err := s.reviewer.Review(ctx, resourceTypes[request.GetResourceType()], requestVerbs, requestTargets) if err != nil { return nil, err } diff --git a/go/core/internal/grpcserver/authorization_test.go b/go/core/internal/grpcserver/authorization_test.go index 99cf97e93..af6e2eebd 100644 --- a/go/core/internal/grpcserver/authorization_test.go +++ b/go/core/internal/grpcserver/authorization_test.go @@ -8,7 +8,7 @@ import ( apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" - "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -45,7 +45,7 @@ func TestAuthorizationServiceGeneratedClient(t *testing.T) { Registerer: prometheus.NewRegistry(), Authenticator: &authimpl.UnsecureAuthenticator{}, SystemService: testSystemService(), - AuthorizationService: accessreview.NewService(authorizer), + AuthorizationService: kubeauth.NewReviewer(authorizer), }) require.NoError(t, err) serverContext, cancelServer := context.WithCancel(t.Context()) diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index 5cc730c56..f7b696e74 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -15,9 +15,9 @@ import ( protovalidatemiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" - "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" @@ -60,7 +60,7 @@ type Config struct { AgentInstanceService *agentinstance.Service CheckpointService *checkpoint.Service ScheduledRunService *scheduledrun.Service - AuthorizationService *accessreview.Service + AuthorizationService *kubeauth.Reviewer A2AHandler a2asrv.RequestHandler // RegisterServices registers services core does not own. Called during New, // because gRPC requires every service to be registered before Serve. @@ -158,7 +158,7 @@ func New(config Config) (*Server, error) { apiv1alpha1.RegisterCheckpointServiceServer(grpcServer, &checkpointServer{service: config.CheckpointService}) } if config.AuthorizationService != nil { - apiv1alpha1.RegisterAuthorizationServiceServer(grpcServer, &authorizationServer{service: config.AuthorizationService}) + apiv1alpha1.RegisterAuthorizationServiceServer(grpcServer, &authorizationServer{reviewer: config.AuthorizationService}) } if config.A2AHandler != nil { a2agrpc.NewHandler(config.A2AHandler).RegisterWith(grpcServer) diff --git a/go/core/internal/service/accessreview/service.go b/go/core/internal/service/kubeauth/review.go similarity index 69% rename from go/core/internal/service/accessreview/service.go rename to go/core/internal/service/kubeauth/review.go index dd6661cf0..2ebf8aaaf 100644 --- a/go/core/internal/service/accessreview/service.go +++ b/go/core/internal/service/kubeauth/review.go @@ -1,49 +1,48 @@ -package accessreview +package kubeauth import ( "context" - "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type Service struct { +type Reviewer struct { authorizer auth.CollectionAuthorizer } -type Target struct { +type ReviewTarget struct { Namespace string Name string } -type Result struct { - Target Target +type ReviewResult struct { + Target ReviewTarget AllowedVerbs []auth.Verb } -func NewService(authorizer auth.CollectionAuthorizer) *Service { - return &Service{authorizer: authorizer} +func NewReviewer(authorizer auth.CollectionAuthorizer) *Reviewer { + return &Reviewer{authorizer: authorizer} } -func (s *Service) Check(ctx context.Context, resourceType string, verbs []auth.Verb, targets []Target) ([]Result, error) { +func (r *Reviewer) Review(ctx context.Context, resourceType string, verbs []auth.Verb, targets []ReviewTarget) ([]ReviewResult, error) { session, ok := auth.AuthSessionFrom(ctx) if !ok { return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) } - results := make([]Result, len(targets)) + results := make([]ReviewResult, len(targets)) for i, target := range targets { results[i].Target = target } for _, verb := range verbs { - scope, err := s.authorizer.Scope(ctx, session.Principal(), verb, resourceType) + scope, err := r.authorizer.Scope(ctx, session.Principal(), verb, resourceType) if err != nil { return nil, serviceerrors.NewUnavailable("Failed to read the "+resourceType+" authorization scope", err) } - matcher, err := kubeauth.CompileScope(scope) + matcher, err := CompileScope(scope) if err != nil { return nil, serviceerrors.NewInternal("Failed to apply the "+resourceType+" authorization scope", err) } diff --git a/go/core/internal/service/accessreview/service_test.go b/go/core/internal/service/kubeauth/review_test.go similarity index 89% rename from go/core/internal/service/accessreview/service_test.go rename to go/core/internal/service/kubeauth/review_test.go index b919073d8..f3deb776b 100644 --- a/go/core/internal/service/accessreview/service_test.go +++ b/go/core/internal/service/kubeauth/review_test.go @@ -1,4 +1,4 @@ -package accessreview_test +package kubeauth_test import ( "context" @@ -6,7 +6,7 @@ import ( "testing" apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" - "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/stretchr/testify/assert" @@ -58,21 +58,21 @@ func TestCheckAccessMatrix(t *testing.T) { }}}, }, }} - targets := []accessreview.Target{ + targets := []kubeauth.ReviewTarget{ {Namespace: "team-a", Name: "assistant"}, {Namespace: "team-b", Name: "assistant"}, {Namespace: "team-a"}, {Namespace: "team-a", Name: "other"}, } - results, err := accessreview.NewService(authorizer).Check( + results, err := kubeauth.NewReviewer(authorizer).Review( ctx, auth.ResourceAgentTemplate, []auth.Verb{auth.VerbUpdate, auth.VerbCreate}, targets, ) require.NoError(t, err) - assert.Equal(t, []accessreview.Result{ + assert.Equal(t, []kubeauth.ReviewResult{ {Target: targets[0], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, {Target: targets[1]}, {Target: targets[2], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, @@ -112,11 +112,11 @@ func TestCheckAccessScopeFailures(t *testing.T) { } ctx := auth.AuthSessionTo(t.Context(), testSession{}) - _, err := accessreview.NewService(authorizer).Check( + _, err := kubeauth.NewReviewer(authorizer).Review( ctx, auth.ResourceModelConfig, []auth.Verb{auth.VerbCreate}, - []accessreview.Target{{Namespace: "team-a"}}, + []kubeauth.ReviewTarget{{Namespace: "team-a"}}, ) assert.True(t, serviceerrors.IsCode(err, test.wantCode), "error = %v", err) }) @@ -124,11 +124,11 @@ func TestCheckAccessScopeFailures(t *testing.T) { } func TestCheckAccessRequiresSession(t *testing.T) { - _, err := accessreview.NewService(&testAuthorizer{}).Check( + _, err := kubeauth.NewReviewer(&testAuthorizer{}).Review( t.Context(), auth.ResourceAgentTemplate, []auth.Verb{auth.VerbGet}, - []accessreview.Target{{Namespace: "team-a", Name: "assistant"}}, + []kubeauth.ReviewTarget{{Namespace: "team-a", Name: "assistant"}}, ) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeUnauthenticated), "error = %v", err) } diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 77f2c4a35..f6e2ba341 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -31,9 +31,9 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/grpcserver" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" v2mcp "github.com/kagent-dev/kagent/go/core/internal/mcp" - "github.com/kagent-dev/kagent/go/core/internal/service/accessreview" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" @@ -322,7 +322,7 @@ func Run(ctx context.Context, opts Options) error { MemoryService: memory, AgentInstanceService: instances, ScheduledRunService: schedules, - AuthorizationService: accessreview.NewService(authorizer), + AuthorizationService: kubeauth.NewReviewer(authorizer), // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, auth.ResourceAgentTemplate), From 54a2072ae2b1316c2d918fba3211bf0946c1a70b Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Fri, 18 Sep 2026 17:02:59 -0700 Subject: [PATCH 6/8] refactor: clarify access reviewer naming Signed-off-by: Cody Hartsook --- go/core/internal/grpcserver/authorization.go | 2 +- go/core/internal/grpcserver/authorization_test.go | 2 +- go/core/internal/grpcserver/server.go | 2 +- go/core/internal/service/kubeauth/review.go | 8 ++++---- go/core/internal/service/kubeauth/review_test.go | 6 +++--- go/core/pkg/app/app.go | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go/core/internal/grpcserver/authorization.go b/go/core/internal/grpcserver/authorization.go index ee8522746..cb960c2cb 100644 --- a/go/core/internal/grpcserver/authorization.go +++ b/go/core/internal/grpcserver/authorization.go @@ -10,7 +10,7 @@ import ( type authorizationServer struct { apiv1alpha1.UnimplementedAuthorizationServiceServer - reviewer *kubeauth.Reviewer + reviewer *kubeauth.AccessReviewer } func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alpha1.CheckAccessRequest) (*apiv1alpha1.CheckAccessResponse, error) { diff --git a/go/core/internal/grpcserver/authorization_test.go b/go/core/internal/grpcserver/authorization_test.go index af6e2eebd..ecff6e1c4 100644 --- a/go/core/internal/grpcserver/authorization_test.go +++ b/go/core/internal/grpcserver/authorization_test.go @@ -45,7 +45,7 @@ func TestAuthorizationServiceGeneratedClient(t *testing.T) { Registerer: prometheus.NewRegistry(), Authenticator: &authimpl.UnsecureAuthenticator{}, SystemService: testSystemService(), - AuthorizationService: kubeauth.NewReviewer(authorizer), + AuthorizationService: kubeauth.NewAccessReviewer(authorizer), }) require.NoError(t, err) serverContext, cancelServer := context.WithCancel(t.Context()) diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index f7b696e74..30ac472c7 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -60,7 +60,7 @@ type Config struct { AgentInstanceService *agentinstance.Service CheckpointService *checkpoint.Service ScheduledRunService *scheduledrun.Service - AuthorizationService *kubeauth.Reviewer + AuthorizationService *kubeauth.AccessReviewer A2AHandler a2asrv.RequestHandler // RegisterServices registers services core does not own. Called during New, // because gRPC requires every service to be registered before Serve. diff --git a/go/core/internal/service/kubeauth/review.go b/go/core/internal/service/kubeauth/review.go index 2ebf8aaaf..1913b6a38 100644 --- a/go/core/internal/service/kubeauth/review.go +++ b/go/core/internal/service/kubeauth/review.go @@ -8,7 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type Reviewer struct { +type AccessReviewer struct { authorizer auth.CollectionAuthorizer } @@ -22,11 +22,11 @@ type ReviewResult struct { AllowedVerbs []auth.Verb } -func NewReviewer(authorizer auth.CollectionAuthorizer) *Reviewer { - return &Reviewer{authorizer: authorizer} +func NewAccessReviewer(authorizer auth.CollectionAuthorizer) *AccessReviewer { + return &AccessReviewer{authorizer: authorizer} } -func (r *Reviewer) Review(ctx context.Context, resourceType string, verbs []auth.Verb, targets []ReviewTarget) ([]ReviewResult, error) { +func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs []auth.Verb, targets []ReviewTarget) ([]ReviewResult, error) { session, ok := auth.AuthSessionFrom(ctx) if !ok { return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) diff --git a/go/core/internal/service/kubeauth/review_test.go b/go/core/internal/service/kubeauth/review_test.go index f3deb776b..cd532cdf6 100644 --- a/go/core/internal/service/kubeauth/review_test.go +++ b/go/core/internal/service/kubeauth/review_test.go @@ -65,7 +65,7 @@ func TestCheckAccessMatrix(t *testing.T) { {Namespace: "team-a", Name: "other"}, } - results, err := kubeauth.NewReviewer(authorizer).Review( + results, err := kubeauth.NewAccessReviewer(authorizer).Review( ctx, auth.ResourceAgentTemplate, []auth.Verb{auth.VerbUpdate, auth.VerbCreate}, @@ -112,7 +112,7 @@ func TestCheckAccessScopeFailures(t *testing.T) { } ctx := auth.AuthSessionTo(t.Context(), testSession{}) - _, err := kubeauth.NewReviewer(authorizer).Review( + _, err := kubeauth.NewAccessReviewer(authorizer).Review( ctx, auth.ResourceModelConfig, []auth.Verb{auth.VerbCreate}, @@ -124,7 +124,7 @@ func TestCheckAccessScopeFailures(t *testing.T) { } func TestCheckAccessRequiresSession(t *testing.T) { - _, err := kubeauth.NewReviewer(&testAuthorizer{}).Review( + _, err := kubeauth.NewAccessReviewer(&testAuthorizer{}).Review( t.Context(), auth.ResourceAgentTemplate, []auth.Verb{auth.VerbGet}, diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index f6e2ba341..de87a252f 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -322,7 +322,7 @@ func Run(ctx context.Context, opts Options) error { MemoryService: memory, AgentInstanceService: instances, ScheduledRunService: schedules, - AuthorizationService: kubeauth.NewReviewer(authorizer), + AuthorizationService: kubeauth.NewAccessReviewer(authorizer), // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, auth.ResourceAgentTemplate), From 1fcf5d8eb687d7d05dca286b26610cf2ff3966a4 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Tue, 22 Sep 2026 11:28:06 -0700 Subject: [PATCH 7/8] fix: use exact checks for named access reviews Signed-off-by: Cody Hartsook --- design/EP-1270-scoped-authorization.md | 20 ++++++-- .../kagent/api/v1alpha1/authorization.pb.go | 4 +- .../api/v1alpha1/authorization_grpc.pb.go | 8 +-- go/core/internal/service/kubeauth/review.go | 33 ++++++------ .../internal/service/kubeauth/review_test.go | 50 +++++++++++++++++-- go/core/pkg/auth/auth.go | 3 ++ proto/kagent/api/v1alpha1/authorization.proto | 8 +-- .../kagent/api/v1alpha1/authorization_pb.ts | 8 +-- 8 files changed, 96 insertions(+), 38 deletions(-) diff --git a/design/EP-1270-scoped-authorization.md b/design/EP-1270-scoped-authorization.md index f9fef279a..4f7ff2726 100644 --- a/design/EP-1270-scoped-authorization.md +++ b/design/EP-1270-scoped-authorization.md @@ -29,14 +29,15 @@ Authorization decisions use trusted resource identity. Unauthorized resources ar - Define roles, policies, claims, subjects, grants, or catalog keys. - Protect `SandboxAgent`, `AgentHarness`, `AgentInstance`, `ModelProviderConfig`, tool server, or prompt template resources. - Expose policy-engine, SQL, Kubernetes, or other backend expressions. -- Predict authorization for UI controls. +- Embed authorization capability hints in catalog resources. ## Authorization model -Kagent needs two forms of authorization decision: +Kagent needs three forms of authorization decision: - Whether a principal may perform an operation on a specific resource. - Which resources a principal may receive from a collection request. +- Whether an advisory client action is currently permitted for an exact resource or for any valid resource name in a namespace. A collection decision may allow the complete collection, deny the complete collection, or describe allowed alternatives. Each alternative may constrain both namespace and name. Alternatives are combined with OR, while constraints within an alternative are combined with AND. Each constraint may allow one or more exact values. @@ -60,9 +61,20 @@ A protected collection returns only resources permitted by its collection decisi A decision that permits no resources returns an empty collection. An authorization failure or a decision that cannot be safely applied fails the request; it never broadens access. +## Advisory access review + +An access review with a resource name uses the same exact authorization check as +the corresponding operation. A target without a name is an existential question: +whether the complete authorization scope contains at least one valid resource name +in that namespace. It is not an exact check with an empty or wildcard name. + +An authorizer that cannot produce a complete scope fails the namespace-only +review rather than returning partial results. Reviews are advisory; every resource +operation authorizes its actual input again. + ## Client behavior -Catalog responses do not include create, update, or delete capability hints for presentation logic. Such hints duplicate policy decisions, can become stale, and couple the public API to a particular client experience. +Catalog responses do not include create, update, or delete capability hints. Such hints duplicate policy decisions, can become stale, and couple the resource API to a particular client experience. Clients may request separate advisory access reviews when they need early permission signals. A client may therefore display an action that the caller cannot complete. The attempted operation remains authoritative and returns permission denied. Clients should handle that response without treating it as an unexpected server failure. @@ -77,4 +89,4 @@ Resources outside the initial scope retain their existing authorization behavior - Checking items after pagination was rejected because it can produce incomplete pages and incorrect totals. - Separate allowed-name and allowed-namespace lists were rejected because they cannot preserve required relationships between attributes. - Backend query fragments were rejected because they couple authorization policy to storage and create an unsafe trust boundary. -- UI capability hints were rejected because the operation itself is the only authoritative authorization decision. +- Capability hints were rejected because the operation itself is the only authoritative authorization decision. diff --git a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go index 67c195c55..3dbbba638 100644 --- a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go @@ -192,8 +192,8 @@ func (x *CheckAccessRequest) GetTargets() []*AccessTarget { type AccessTarget struct { state protoimpl.MessageState `protogen:"open.v1"` Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` - // When omitted, the review checks whether any valid resource name in the - // namespace is permitted. + // When present, the review checks this exact resource identity. When omitted, + // it checks whether any valid resource name in the namespace is permitted. Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go index ddc175c28..d432aa6ef 100644 --- a/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go @@ -26,8 +26,8 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// AuthorizationService answers advisory access checks for catalog UI actions. -// The resource operation remains authoritative. +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. type AuthorizationServiceClient interface { CheckAccess(ctx context.Context, in *CheckAccessRequest, opts ...grpc.CallOption) (*CheckAccessResponse, error) } @@ -54,8 +54,8 @@ func (c *authorizationServiceClient) CheckAccess(ctx context.Context, in *CheckA // All implementations must embed UnimplementedAuthorizationServiceServer // for forward compatibility. // -// AuthorizationService answers advisory access checks for catalog UI actions. -// The resource operation remains authoritative. +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. type AuthorizationServiceServer interface { CheckAccess(context.Context, *CheckAccessRequest) (*CheckAccessResponse, error) mustEmbedUnimplementedAuthorizationServiceServer() diff --git a/go/core/internal/service/kubeauth/review.go b/go/core/internal/service/kubeauth/review.go index 1913b6a38..94abf1651 100644 --- a/go/core/internal/service/kubeauth/review.go +++ b/go/core/internal/service/kubeauth/review.go @@ -5,7 +5,6 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type AccessReviewer struct { @@ -31,6 +30,7 @@ func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs if !ok { return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) } + principal := session.Principal() results := make([]ReviewResult, len(targets)) for i, target := range targets { @@ -38,23 +38,26 @@ func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs } for _, verb := range verbs { - scope, err := r.authorizer.Scope(ctx, session.Principal(), verb, resourceType) - if err != nil { - return nil, serviceerrors.NewUnavailable("Failed to read the "+resourceType+" authorization scope", err) - } - matcher, err := CompileScope(scope) - if err != nil { - return nil, serviceerrors.NewInternal("Failed to apply the "+resourceType+" authorization scope", err) - } + var matcher *Matcher for i, target := range targets { var allowed bool - if target.Name == "" { - allowed = matcher.MatchesAnyName(target.Namespace) + if target.Name != "" { + allowed = r.authorizer.Check(ctx, principal, verb, auth.Resource{ + Type: resourceType, Namespace: target.Namespace, Name: target.Name, + }) == nil } else { - allowed = matcher.Matches(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{ - Namespace: target.Namespace, - Name: target.Name, - }}) + if matcher == nil { + scope, err := r.authorizer.Scope(ctx, principal, verb, resourceType) + if err != nil { + return nil, serviceerrors.NewUnavailable("Failed to read the "+resourceType+" authorization scope", err) + } + compiled, err := CompileScope(scope) + if err != nil { + return nil, serviceerrors.NewInternal("Failed to apply the "+resourceType+" authorization scope", err) + } + matcher = &compiled + } + allowed = matcher.MatchesAnyName(target.Namespace) } if allowed { results[i].AllowedVerbs = append(results[i].AllowedVerbs, verb) diff --git a/go/core/internal/service/kubeauth/review_test.go b/go/core/internal/service/kubeauth/review_test.go index cd532cdf6..b04c8794f 100644 --- a/go/core/internal/service/kubeauth/review_test.go +++ b/go/core/internal/service/kubeauth/review_test.go @@ -23,16 +23,28 @@ type scopeCall struct { resourceType string } +type checkCall struct { + principal auth.Principal + verb auth.Verb + resource auth.Resource +} + +type checkKey struct { + verb auth.Verb + resourceType, namespace, name string +} + type testAuthorizer struct { scopes map[auth.Verb]apiauthorization.AuthorizationScope scopeErrs map[auth.Verb]error scopeCalls []scopeCall - checkCalls int + checkErrs map[checkKey]error + checkCalls []checkCall } -func (a *testAuthorizer) Check(context.Context, auth.Principal, auth.Verb, auth.Resource) error { - a.checkCalls++ - return nil +func (a *testAuthorizer) Check(_ context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { + a.checkCalls = append(a.checkCalls, checkCall{principal: principal, verb: verb, resource: resource}) + return a.checkErrs[checkKey{verb: verb, resourceType: resource.Type, namespace: resource.Namespace, name: resource.Name}] } func (a *testAuthorizer) Scope(_ context.Context, principal auth.Principal, verb auth.Verb, resourceType string) (apiauthorization.AuthorizationScope, error) { @@ -43,6 +55,7 @@ func (a *testAuthorizer) Scope(_ context.Context, principal auth.Principal, verb func TestCheckAccessMatrix(t *testing.T) { principal := auth.Principal{User: auth.User{ID: "reader"}} ctx := auth.AuthSessionTo(t.Context(), testSession{principal: principal}) + denied := errors.New("denied") authorizer := &testAuthorizer{scopes: map[auth.Verb]apiauthorization.AuthorizationScope{ auth.VerbUpdate: { Kind: apiauthorization.ScopeAnyOf, @@ -57,6 +70,10 @@ func TestCheckAccessMatrix(t *testing.T) { {Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}, }}}, }, + }, checkErrs: map[checkKey]error{ + {verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate, namespace: "team-b", name: "assistant"}: denied, + {verb: auth.VerbCreate, resourceType: auth.ResourceAgentTemplate, namespace: "team-b", name: "assistant"}: denied, + {verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate, namespace: "team-a", name: "other"}: denied, }} targets := []kubeauth.ReviewTarget{ {Namespace: "team-a", Name: "assistant"}, @@ -82,7 +99,30 @@ func TestCheckAccessMatrix(t *testing.T) { {principal: principal, verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate}, {principal: principal, verb: auth.VerbCreate, resourceType: auth.ResourceAgentTemplate}, }, authorizer.scopeCalls) - assert.Zero(t, authorizer.checkCalls) + assert.Equal(t, []checkCall{ + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "assistant"}}, + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-b", Name: "assistant"}}, + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "other"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "assistant"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-b", Name: "assistant"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "other"}}, + }, authorizer.checkCalls) +} + +func TestCheckAccessNamedTargetsDoNotReadScope(t *testing.T) { + authorizer := &testAuthorizer{scopeErrs: map[auth.Verb]error{auth.VerbGet: errors.New("scope unavailable")}} + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + target := kubeauth.ReviewTarget{Namespace: "team-a", Name: "assistant"} + + results, err := kubeauth.NewAccessReviewer(authorizer).Review( + ctx, + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbGet}, + []kubeauth.ReviewTarget{target}, + ) + require.NoError(t, err) + assert.Equal(t, []kubeauth.ReviewResult{{Target: target, AllowedVerbs: []auth.Verb{auth.VerbGet}}}, results) + assert.Empty(t, authorizer.scopeCalls) } func TestCheckAccessScopeFailures(t *testing.T) { diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 891c3ee44..c23efee8a 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -89,9 +89,12 @@ const ( // Authz type Authorizer interface { + // Check returns nil only when the principal may perform the operation. Check(ctx context.Context, principal Principal, verb Verb, resource Resource) error } +// CollectionAuthorizer enumerates every namespace/name combination allowed for a verb. +// Scope must fail rather than return a partial result. type CollectionAuthorizer interface { Authorizer Scope(ctx context.Context, principal Principal, verb Verb, resourceType string) (authorization.AuthorizationScope, error) diff --git a/proto/kagent/api/v1alpha1/authorization.proto b/proto/kagent/api/v1alpha1/authorization.proto index 6023a5be8..181e44cc1 100644 --- a/proto/kagent/api/v1alpha1/authorization.proto +++ b/proto/kagent/api/v1alpha1/authorization.proto @@ -6,8 +6,8 @@ import "buf/validate/validate.proto"; option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1;apiv1alpha1"; -// AuthorizationService answers advisory access checks for catalog UI actions. -// The resource operation remains authoritative. +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. service AuthorizationService { rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); } @@ -60,8 +60,8 @@ message AccessTarget { max_len: 63 pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" }]; - // When omitted, the review checks whether any valid resource name in the - // namespace is permitted. + // When present, the review checks this exact resource identity. When omitted, + // it checks whether any valid resource name in the namespace is permitted. optional string name = 2 [(buf.validate.field).string = { min_len: 1 max_len: 253 diff --git a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts index ac9976835..8c324f7e1 100644 --- a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts @@ -50,8 +50,8 @@ export type AccessTarget = Message<"kagent.api.v1alpha1.AccessTarget"> & { namespace: string; /** - * When omitted, the review checks whether any valid resource name in the - * namespace is permitted. + * When present, the review checks this exact resource identity. When omitted, + * it checks whether any valid resource name in the namespace is permitted. * * @generated from field: optional string name = 2; */ @@ -172,8 +172,8 @@ export const AuthorizationVerbSchema: GenEnum = /*@__PURE__*/ enumDesc(file_kagent_api_v1alpha1_authorization, 1); /** - * AuthorizationService answers advisory access checks for catalog UI actions. - * The resource operation remains authoritative. + * AuthorizationService answers advisory access checks for catalog operations. + * Catalog operations remain authoritative. * * @generated from service kagent.api.v1alpha1.AuthorizationService */ From 4916a53b604806251fe4c4921e835293252007cf Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Tue, 22 Sep 2026 14:16:48 -0700 Subject: [PATCH 8/8] fix: align access reviews with operation policy Signed-off-by: Cody Hartsook --- design/EP-1270-scoped-authorization.md | 3 +- .../kagent/api/v1alpha1/authorization.pb.go | 15 +++--- go/core/internal/grpcserver/authorization.go | 47 +++++++++++-------- .../internal/grpcserver/authorization_test.go | 23 +++++++++ go/core/internal/grpcserver/interceptors.go | 14 +++--- .../internal/grpcserver/protovalidate_test.go | 4 ++ go/core/internal/service/kubeauth/review.go | 8 ++++ .../internal/service/kubeauth/review_test.go | 17 +++++++ go/core/pkg/auth/share.go | 5 ++ proto/kagent/api/v1alpha1/authorization.proto | 10 ++-- .../kagent/api/v1alpha1/authorization_pb.ts | 6 +-- 11 files changed, 111 insertions(+), 41 deletions(-) diff --git a/design/EP-1270-scoped-authorization.md b/design/EP-1270-scoped-authorization.md index 4f7ff2726..6e4528c85 100644 --- a/design/EP-1270-scoped-authorization.md +++ b/design/EP-1270-scoped-authorization.md @@ -64,7 +64,8 @@ A decision that permits no resources returns an empty collection. An authorizati ## Advisory access review An access review with a resource name uses the same exact authorization check as -the corresponding operation. A target without a name is an existential question: +the corresponding operation. GET requires a name. A target without a name is an +existential question: whether the complete authorization scope contains at least one valid resource name in that namespace. It is not an exact check with an empty or wildcard name. diff --git a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go index 3dbbba638..13e6f6248 100644 --- a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go @@ -192,8 +192,8 @@ func (x *CheckAccessRequest) GetTargets() []*AccessTarget { type AccessTarget struct { state protoimpl.MessageState `protogen:"open.v1"` Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` - // When present, the review checks this exact resource identity. When omitted, - // it checks whether any valid resource name in the namespace is permitted. + // A name checks this exact resource identity; omitting it asks whether any name is permitted. + // GET requires a name. Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -343,14 +343,15 @@ var File_kagent_api_v1alpha1_authorization_proto protoreflect.FileDescriptor const file_kagent_api_v1alpha1_authorization_proto_rawDesc = "" + "\n" + - "'kagent/api/v1alpha1/authorization.proto\x12\x13kagent.api.v1alpha1\x1a\x1bbuf/validate/validate.proto\"\xb8\x03\n" + + "'kagent/api/v1alpha1/authorization.proto\x12\x13kagent.api.v1alpha1\x1a\x1bbuf/validate/validate.proto\"\xbf\x04\n" + "\x12CheckAccessRequest\x12_\n" + "\rresource_type\x18\x01 \x01(\x0e2..kagent.api.v1alpha1.AuthorizationResourceTypeB\n" + - "\xbaH\a\x82\x01\x04\x10\x01 \x00R\fresourceType\x12S\n" + - "\x05verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbB\x15\xbaH\x12\x92\x01\x0f\b\x01\x10\x04\x18\x01\"\a\x82\x01\x04\x10\x01 \x00R\x05verbs\x12G\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\fresourceType\x12Q\n" + + "\x05verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbB\x13\xbaH\x10\x92\x01\r\b\x01\x18\x01\"\a\x82\x01\x04\x10\x01 \x00R\x05verbs\x12G\n" + "\atargets\x18\x03 \x03(\v2!.kagent.api.v1alpha1.AccessTargetB\n" + - "\xbaH\a\x92\x01\x04\b\x01\x10dR\atargets:\xa2\x01\xbaH\x9e\x01\x1a\x9b\x01\n" + - "\x18supported_resource_verbs\x126Harness supports only CREATE and DELETE access reviews\x1aGthis.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)\"\xca\x01\n" + + "\xbaH\a\x92\x01\x04\b\x01\x10dR\atargets:\xab\x02\xbaH\xa7\x02\x1a\x9b\x01\n" + + "\x18supported_resource_verbs\x126Harness supports only CREATE and DELETE access reviews\x1aGthis.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)\x1a\x86\x01\n" + + "\vget_targets\x12(GET access reviews require named targets\x1aMthis.verbs.all(verb, verb != 1) || this.targets.all(target, has(target.name))\"\xca\x01\n" + "\fAccessTarget\x12H\n" + "\tnamespace\x18\x01 \x01(\tB*\xbaH'r%\x10\x01\x18?2\x1f^[a-z0-9]([-a-z0-9]*[a-z0-9])?$R\tnamespace\x12g\n" + "\x04name\x18\x02 \x01(\tBN\xbaHKrI\x10\x01\x18\xfd\x012B^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$H\x00R\x04name\x88\x01\x01B\a\n" + diff --git a/go/core/internal/grpcserver/authorization.go b/go/core/internal/grpcserver/authorization.go index cb960c2cb..0f9ffdd00 100644 --- a/go/core/internal/grpcserver/authorization.go +++ b/go/core/internal/grpcserver/authorization.go @@ -2,45 +2,52 @@ package grpcserver import ( "context" + "fmt" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/pkg/auth" ) +var authorizationResourceTypes = map[apiv1alpha1.AuthorizationResourceType]string{ + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE: auth.ResourceAgentTemplate, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS: auth.ResourceHarness, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG: auth.ResourceModelConfig, +} + +var authorizationVerbs = map[apiv1alpha1.AuthorizationVerb]auth.Verb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET: auth.VerbGet, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE: auth.VerbCreate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE: auth.VerbUpdate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE: auth.VerbDelete, +} + type authorizationServer struct { apiv1alpha1.UnimplementedAuthorizationServiceServer reviewer *kubeauth.AccessReviewer } func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alpha1.CheckAccessRequest) (*apiv1alpha1.CheckAccessResponse, error) { - resourceTypes := map[apiv1alpha1.AuthorizationResourceType]string{ - apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE: auth.ResourceAgentTemplate, - apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS: auth.ResourceHarness, - apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG: auth.ResourceModelConfig, - } - verbs := map[apiv1alpha1.AuthorizationVerb]auth.Verb{ - apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET: auth.VerbGet, - apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE: auth.VerbCreate, - apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE: auth.VerbUpdate, - apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE: auth.VerbDelete, - } - authorizationVerbs := map[auth.Verb]apiv1alpha1.AuthorizationVerb{ - auth.VerbGet: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET, - auth.VerbCreate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, - auth.VerbUpdate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, - auth.VerbDelete: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE, + resourceType, ok := authorizationResourceTypes[request.GetResourceType()] + if !ok { + return nil, fmt.Errorf("authorization resource type %q has no domain mapping", request.GetResourceType()) } requestVerbs := make([]auth.Verb, len(request.GetVerbs())) - for i, verb := range request.GetVerbs() { - requestVerbs[i] = verbs[verb] + responseVerbs := make(map[auth.Verb]apiv1alpha1.AuthorizationVerb, len(request.GetVerbs())) + for i, apiVerb := range request.GetVerbs() { + domainVerb, ok := authorizationVerbs[apiVerb] + if !ok { + return nil, fmt.Errorf("authorization verb %q has no domain mapping", apiVerb) + } + requestVerbs[i] = domainVerb + responseVerbs[domainVerb] = apiVerb } requestTargets := make([]kubeauth.ReviewTarget, len(request.GetTargets())) for i, target := range request.GetTargets() { requestTargets[i] = kubeauth.ReviewTarget{Namespace: target.GetNamespace(), Name: target.GetName()} } - results, err := s.reviewer.Review(ctx, resourceTypes[request.GetResourceType()], requestVerbs, requestTargets) + results, err := s.reviewer.Review(ctx, resourceType, requestVerbs, requestTargets) if err != nil { return nil, err } @@ -54,7 +61,7 @@ func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alp } allowedVerbs := make([]apiv1alpha1.AuthorizationVerb, len(result.AllowedVerbs)) for j, verb := range result.AllowedVerbs { - allowedVerbs[j] = authorizationVerbs[verb] + allowedVerbs[j] = responseVerbs[verb] } response.Results[i] = &apiv1alpha1.ResourceAccess{Target: target, AllowedVerbs: allowedVerbs} } diff --git a/go/core/internal/grpcserver/authorization_test.go b/go/core/internal/grpcserver/authorization_test.go index ecff6e1c4..663b89154 100644 --- a/go/core/internal/grpcserver/authorization_test.go +++ b/go/core/internal/grpcserver/authorization_test.go @@ -37,6 +37,29 @@ func (a *accessReviewAuthorizer) Scope(_ context.Context, _ pkgauth.Principal, v return apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}, nil } +func TestAuthorizationMappingsComplete(t *testing.T) { + for name, number := range apiv1alpha1.AuthorizationResourceType_value { + if number == 0 { + continue + } + resourceType, ok := authorizationResourceTypes[apiv1alpha1.AuthorizationResourceType(number)] + require.True(t, ok, name) + assert.NotEmpty(t, resourceType, name) + } + + domainVerbs := make(map[pkgauth.Verb]struct{}, len(apiv1alpha1.AuthorizationVerb_value)-1) + for name, number := range apiv1alpha1.AuthorizationVerb_value { + if number == 0 { + continue + } + domainVerb, ok := authorizationVerbs[apiv1alpha1.AuthorizationVerb(number)] + require.True(t, ok, name) + assert.NotEmpty(t, domainVerb, name) + assert.NotContains(t, domainVerbs, domainVerb, name) + domainVerbs[domainVerb] = struct{}{} + } +} + func TestAuthorizationServiceGeneratedClient(t *testing.T) { authorizer := &accessReviewAuthorizer{} listener := bufconn.Listen(DefaultMaxMessageSize) diff --git a/go/core/internal/grpcserver/interceptors.go b/go/core/internal/grpcserver/interceptors.go index 5b7fdc689..2d5a0971f 100644 --- a/go/core/internal/grpcserver/interceptors.go +++ b/go/core/internal/grpcserver/interceptors.go @@ -89,18 +89,18 @@ func authenticate(ctx context.Context, fullMethod string, authenticator auth.Aut return ctx, status.Error(codes.Internal, "failed to validate share token") } // READ_WRITE also allows A2A send and cancel; anything else is read-only. - readOnly := instanceShare.Permission != apiv1alpha1.AgentInstanceSharePermission_AGENT_INSTANCE_SHARE_PERMISSION_READ_WRITE - if readOnly && access != auth.AccessPublic && access != auth.AccessRead { - return ctx, status.Error(codes.PermissionDenied, "this share link is read-only") - } - return auth.ShareContextTo(authenticatedContext, &auth.ShareContext{ + shareContext := &auth.ShareContext{ Token: shareToken, // The owner, not the visitor: the token widens what this account may reach // to what the owner can see, and the instance read runs as the owner. UserID: ownerUserID, - ReadOnly: readOnly, + ReadOnly: instanceShare.Permission != apiv1alpha1.AgentInstanceSharePermission_AGENT_INSTANCE_SHARE_PERMISSION_READ_WRITE, AgentInstanceID: instanceShare.GetAgentInstanceId(), - }), nil + } + if !shareContext.AllowsAccess(access) { + return ctx, status.Error(codes.PermissionDenied, "this share link is read-only") + } + return auth.ShareContextTo(authenticatedContext, shareContext), nil } func incomingHTTPHeaders(ctx context.Context) http.Header { diff --git a/go/core/internal/grpcserver/protovalidate_test.go b/go/core/internal/grpcserver/protovalidate_test.go index 82f8f30a4..9f4c489f6 100644 --- a/go/core/internal/grpcserver/protovalidate_test.go +++ b/go/core/internal/grpcserver/protovalidate_test.go @@ -139,6 +139,10 @@ func TestCheckAccessRequestValidation(t *testing.T) { name: "present empty name", request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &emptyName}}}, }, + { + name: "get without named target", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, { name: "unsupported harness verb", request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &name}}}, diff --git a/go/core/internal/service/kubeauth/review.go b/go/core/internal/service/kubeauth/review.go index 94abf1651..11a2d614e 100644 --- a/go/core/internal/service/kubeauth/review.go +++ b/go/core/internal/service/kubeauth/review.go @@ -31,6 +31,7 @@ func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) } principal := session.Principal() + share, shared := auth.ShareContextFrom(ctx) results := make([]ReviewResult, len(targets)) for i, target := range targets { @@ -38,6 +39,13 @@ func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs } for _, verb := range verbs { + access := auth.AccessMode(verb) + if verb == auth.VerbGet || verb == auth.VerbList { + access = auth.AccessRead + } + if shared && !share.AllowsAccess(access) { + continue + } var matcher *Matcher for i, target := range targets { var allowed bool diff --git a/go/core/internal/service/kubeauth/review_test.go b/go/core/internal/service/kubeauth/review_test.go index b04c8794f..fbb7a86a2 100644 --- a/go/core/internal/service/kubeauth/review_test.go +++ b/go/core/internal/service/kubeauth/review_test.go @@ -125,6 +125,23 @@ func TestCheckAccessNamedTargetsDoNotReadScope(t *testing.T) { assert.Empty(t, authorizer.scopeCalls) } +func TestCheckAccessHonorsReadOnlyShare(t *testing.T) { + authorizer := &testAuthorizer{} + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + ctx = auth.ShareContextTo(ctx, &auth.ShareContext{ReadOnly: true}) + target := kubeauth.ReviewTarget{Namespace: "team-a", Name: "assistant"} + + results, err := kubeauth.NewAccessReviewer(authorizer).Review( + ctx, + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbGet, auth.VerbCreate, auth.VerbUpdate, auth.VerbDelete}, + []kubeauth.ReviewTarget{target}, + ) + require.NoError(t, err) + assert.Equal(t, []kubeauth.ReviewResult{{Target: target, AllowedVerbs: []auth.Verb{auth.VerbGet}}}, results) + assert.Equal(t, []checkCall{{verb: auth.VerbGet, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "assistant"}}}, authorizer.checkCalls) +} + func TestCheckAccessScopeFailures(t *testing.T) { tests := []struct { name string diff --git a/go/core/pkg/auth/share.go b/go/core/pkg/auth/share.go index d8baf924c..d897cbd76 100644 --- a/go/core/pkg/auth/share.go +++ b/go/core/pkg/auth/share.go @@ -19,6 +19,11 @@ type ShareContext struct { AgentInstanceID string } +// AllowsAccess reports whether this share permits an RPC with the requested access. +func (s *ShareContext) AllowsAccess(access AccessMode) bool { + return s == nil || !s.ReadOnly || access == AccessPublic || access == AccessRead +} + // IsForAgentInstance reports whether this share grants access to the named instance. // // Asked rather than assumed: a session share reaching the A2A gateway must not be diff --git a/proto/kagent/api/v1alpha1/authorization.proto b/proto/kagent/api/v1alpha1/authorization.proto index 181e44cc1..233de1f56 100644 --- a/proto/kagent/api/v1alpha1/authorization.proto +++ b/proto/kagent/api/v1alpha1/authorization.proto @@ -33,13 +33,17 @@ message CheckAccessRequest { message: "Harness supports only CREATE and DELETE access reviews" expression: "this.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)" }; + option (buf.validate.message).cel = { + id: "get_targets" + message: "GET access reviews require named targets" + expression: "this.verbs.all(verb, verb != 1) || this.targets.all(target, has(target.name))" + }; AuthorizationResourceType resource_type = 1 [(buf.validate.field).enum = { defined_only: true not_in: 0 }]; repeated AuthorizationVerb verbs = 2 [(buf.validate.field).repeated = { min_items: 1 - max_items: 4 unique: true items: { enum: { @@ -60,8 +64,8 @@ message AccessTarget { max_len: 63 pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" }]; - // When present, the review checks this exact resource identity. When omitted, - // it checks whether any valid resource name in the namespace is permitted. + // A name checks this exact resource identity; omitting it asks whether any name is permitted. + // GET requires a name. optional string name = 2 [(buf.validate.field).string = { min_len: 1 max_len: 253 diff --git a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts index 8c324f7e1..6733a1ad7 100644 --- a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1alpha1/authorization.proto. */ export const file_kagent_api_v1alpha1_authorization: GenFile = /*@__PURE__*/ - fileDesc("CidrYWdlbnQvYXBpL3YxYWxwaGExL2F1dGhvcml6YXRpb24ucHJvdG8SE2thZ2VudC5hcGkudjFhbHBoYTEimgMKEkNoZWNrQWNjZXNzUmVxdWVzdBJRCg1yZXNvdXJjZV90eXBlGAEgASgOMi4ua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlQgq6SAeCAQQQASAAEkwKBXZlcmJzGAIgAygOMiYua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uVmVyYkIVukgSkgEPCAEQBBgBIgeCAQQQASAAEj4KB3RhcmdldHMYAyADKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLkFjY2Vzc1RhcmdldEIKukgHkgEECAEQZDqiAbpIngEamwEKGHN1cHBvcnRlZF9yZXNvdXJjZV92ZXJicxI2SGFybmVzcyBzdXBwb3J0cyBvbmx5IENSRUFURSBhbmQgREVMRVRFIGFjY2VzcyByZXZpZXdzGkd0aGlzLnJlc291cmNlX3R5cGUgIT0gMiB8fCB0aGlzLnZlcmJzLmFsbCh2ZXJiLCB2ZXJiID09IDIgfHwgdmVyYiA9PSA0KSK5AQoMQWNjZXNzVGFyZ2V0Ej0KCW5hbWVzcGFjZRgBIAEoCUIqukgnciUQARg/Mh9eW2EtejAtOV0oWy1hLXowLTldKlthLXowLTldKT8kEmEKBG5hbWUYAiABKAlCTrpIS3JJEAEY/QEyQl5bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyhbLl1bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPykqJEgAiAEBQgcKBV9uYW1lIksKE0NoZWNrQWNjZXNzUmVzcG9uc2USNAoHcmVzdWx0cxgBIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VBY2Nlc3MiggEKDlJlc291cmNlQWNjZXNzEjEKBnRhcmdldBgBIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuQWNjZXNzVGFyZ2V0Ej0KDWFsbG93ZWRfdmVyYnMYAiADKA4yJi5rYWdlbnQuYXBpLnYxYWxwaGExLkF1dGhvcml6YXRpb25WZXJiKs8BChlBdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlEisKJ0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9VTlNQRUNJRklFRBAAEi4KKkFVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9BR0VOVF9URU1QTEFURRABEicKI0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9IQVJORVNTEAISLAooQVVUSE9SSVpBVElPTl9SRVNPVVJDRV9UWVBFX01PREVMX0NPTkZJRxADKrABChFBdXRob3JpemF0aW9uVmVyYhIiCh5BVVRIT1JJWkFUSU9OX1ZFUkJfVU5TUEVDSUZJRUQQABIaChZBVVRIT1JJWkFUSU9OX1ZFUkJfR0VUEAESHQoZQVVUSE9SSVpBVElPTl9WRVJCX0NSRUFURRACEh0KGUFVVEhPUklaQVRJT05fVkVSQl9VUERBVEUQAxIdChlBVVRIT1JJWkFUSU9OX1ZFUkJfREVMRVRFEAQyeAoUQXV0aG9yaXphdGlvblNlcnZpY2USYAoLQ2hlY2tBY2Nlc3MSJy5rYWdlbnQuYXBpLnYxYWxwaGExLkNoZWNrQWNjZXNzUmVxdWVzdBooLmthZ2VudC5hcGkudjFhbHBoYTEuQ2hlY2tBY2Nlc3NSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_buf_validate_validate]); + fileDesc("CidrYWdlbnQvYXBpL3YxYWxwaGExL2F1dGhvcml6YXRpb24ucHJvdG8SE2thZ2VudC5hcGkudjFhbHBoYTEioQQKEkNoZWNrQWNjZXNzUmVxdWVzdBJRCg1yZXNvdXJjZV90eXBlGAEgASgOMi4ua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlQgq6SAeCAQQQASAAEkoKBXZlcmJzGAIgAygOMiYua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uVmVyYkITukgQkgENCAEYASIHggEEEAEgABI+Cgd0YXJnZXRzGAMgAygLMiEua2FnZW50LmFwaS52MWFscGhhMS5BY2Nlc3NUYXJnZXRCCrpIB5IBBAgBEGQ6qwK6SKcCGpsBChhzdXBwb3J0ZWRfcmVzb3VyY2VfdmVyYnMSNkhhcm5lc3Mgc3VwcG9ydHMgb25seSBDUkVBVEUgYW5kIERFTEVURSBhY2Nlc3MgcmV2aWV3cxpHdGhpcy5yZXNvdXJjZV90eXBlICE9IDIgfHwgdGhpcy52ZXJicy5hbGwodmVyYiwgdmVyYiA9PSAyIHx8IHZlcmIgPT0gNCkahgEKC2dldF90YXJnZXRzEihHRVQgYWNjZXNzIHJldmlld3MgcmVxdWlyZSBuYW1lZCB0YXJnZXRzGk10aGlzLnZlcmJzLmFsbCh2ZXJiLCB2ZXJiICE9IDEpIHx8IHRoaXMudGFyZ2V0cy5hbGwodGFyZ2V0LCBoYXModGFyZ2V0Lm5hbWUpKSK5AQoMQWNjZXNzVGFyZ2V0Ej0KCW5hbWVzcGFjZRgBIAEoCUIqukgnciUQARg/Mh9eW2EtejAtOV0oWy1hLXowLTldKlthLXowLTldKT8kEmEKBG5hbWUYAiABKAlCTrpIS3JJEAEY/QEyQl5bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyhbLl1bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPykqJEgAiAEBQgcKBV9uYW1lIksKE0NoZWNrQWNjZXNzUmVzcG9uc2USNAoHcmVzdWx0cxgBIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VBY2Nlc3MiggEKDlJlc291cmNlQWNjZXNzEjEKBnRhcmdldBgBIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuQWNjZXNzVGFyZ2V0Ej0KDWFsbG93ZWRfdmVyYnMYAiADKA4yJi5rYWdlbnQuYXBpLnYxYWxwaGExLkF1dGhvcml6YXRpb25WZXJiKs8BChlBdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlEisKJ0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9VTlNQRUNJRklFRBAAEi4KKkFVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9BR0VOVF9URU1QTEFURRABEicKI0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9IQVJORVNTEAISLAooQVVUSE9SSVpBVElPTl9SRVNPVVJDRV9UWVBFX01PREVMX0NPTkZJRxADKrABChFBdXRob3JpemF0aW9uVmVyYhIiCh5BVVRIT1JJWkFUSU9OX1ZFUkJfVU5TUEVDSUZJRUQQABIaChZBVVRIT1JJWkFUSU9OX1ZFUkJfR0VUEAESHQoZQVVUSE9SSVpBVElPTl9WRVJCX0NSRUFURRACEh0KGUFVVEhPUklaQVRJT05fVkVSQl9VUERBVEUQAxIdChlBVVRIT1JJWkFUSU9OX1ZFUkJfREVMRVRFEAQyeAoUQXV0aG9yaXphdGlvblNlcnZpY2USYAoLQ2hlY2tBY2Nlc3MSJy5rYWdlbnQuYXBpLnYxYWxwaGExLkNoZWNrQWNjZXNzUmVxdWVzdBooLmthZ2VudC5hcGkudjFhbHBoYTEuQ2hlY2tBY2Nlc3NSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_buf_validate_validate]); /** * @generated from message kagent.api.v1alpha1.CheckAccessRequest @@ -50,8 +50,8 @@ export type AccessTarget = Message<"kagent.api.v1alpha1.AccessTarget"> & { namespace: string; /** - * When present, the review checks this exact resource identity. When omitted, - * it checks whether any valid resource name in the namespace is permitted. + * A name checks this exact resource identity; omitting it asks whether any name is permitted. + * GET requires a name. * * @generated from field: optional string name = 2; */