From e1cb3587353bd42b85a9789f6af58c5ef4a2e67b Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 9 Jun 2026 13:41:36 -0400 Subject: [PATCH 1/4] spec(security): credential binding enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines how the control plane resolves credentials for sessions by honoring scope=credential RoleBindings. Covers hierarchical resolution (agent → project → global), authorization rules for each binding level, credential:token-reader grant lifecycle, and binding deletion semantics. Dependencies: project:admin role (rbac-enforcement.spec.md amendment), global credential binding pattern (ambient-model.spec.md amendment). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../credential-binding-enforcement.spec.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 specs/security/credential-binding-enforcement.spec.md diff --git a/specs/security/credential-binding-enforcement.spec.md b/specs/security/credential-binding-enforcement.spec.md new file mode 100644 index 0000000000..d59d62a052 --- /dev/null +++ b/specs/security/credential-binding-enforcement.spec.md @@ -0,0 +1,212 @@ +# Credential Binding Enforcement + +## Purpose + +Credentials are global resources. Access to a credential's token at session runtime is governed by `scope=credential` RoleBindings that link a credential to a project or a specific agent within a project. The control plane resolves which credentials a session receives by walking these bindings from most-specific to least-specific scope. A credential with no binding covering the session's project and agent is not injected. + +This spec defines the resolver algorithm, authorization rules for creating bindings at each level, and the `credential:token-reader` grant lifecycle. + +### Dependencies + +- **`project:admin` role**: This spec requires a new `project:admin` role (level 2) between `project:owner` (level 1) and `project:editor` (level 3). Credential binding authorization requires `project:admin` or higher. The role definition and hierarchy amendment belong in `rbac-enforcement.spec.md`; this spec assumes the role exists. +- **Global credential binding pattern**: The `scope=credential` binding with both `project_id=NULL` and `agent_id=NULL` is a new pattern. `ambient-model.spec.md` SHALL be amended to document this as valid for credential scope. +- **Session service identity**: The `credential:token-reader` lifecycle uses `user_id` in RoleBindings to represent the session's OIDC service account (e.g., `service-account-ambient-e2e`). This is the same identity the control plane already provisions via OIDC client_credentials grant — no new identity mechanism is required. + +## Requirements + +### Requirement: Hierarchical Credential Resolution + +The control plane SHALL resolve credentials for a session by walking `scope=credential` RoleBindings from most-specific to least-specific scope: **agent → project → global**. + +For each credential provider (github, jira, kubeconfig, google, gitlab, vertex): + +1. If a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` matches the session's project, AND `agent_id` matches the session's agent — use that credential (**agent-level binding**). +2. Otherwise, if a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` matches the session's project, AND `agent_id` is NULL — use that credential (**project-level binding**). +3. Otherwise, if a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` is NULL, AND `agent_id` is NULL — use that credential (**global binding**). +4. Otherwise, no credential is injected for this provider. + +The API server SHOULD reject creation of duplicate bindings at the same scope level for the same provider (same `credential.provider`, same `project_id`, same `agent_id`). If duplicates exist despite this, the binding with the earliest `created_at` timestamp wins. + +#### Scenario: Agent-level binding overrides project-level + +- GIVEN credential A (provider=github) is bound to project P with `agent_id=NULL` +- AND credential B (provider=github) is bound to project P with `agent_id=agent-1` +- WHEN a session starts for agent-1 in project P +- THEN the session receives credential B (agent-level wins) + +#### Scenario: Project-level binding used when no agent-level exists + +- GIVEN credential A (provider=github) is bound to project P with `agent_id=NULL` +- AND no agent-level github binding exists for agent-1 in project P +- WHEN a session starts for agent-1 in project P +- THEN the session receives credential A (project-level fallback) + +#### Scenario: No binding means no injection + +- GIVEN credential A (provider=github) is bound to project P +- AND no github credential is bound to project Q at any level +- WHEN a session starts in project Q +- THEN no github credential is injected into the session + +#### Scenario: Multiple providers resolved independently + +- GIVEN credential A (provider=github) is bound to project P at project-level +- AND credential B (provider=jira) is bound to project P at agent-level for agent-1 +- AND no google credential is bound to project P +- WHEN a session starts for agent-1 in project P +- THEN the session receives credential A (github, project-level) and credential B (jira, agent-level) +- AND no google credential is injected + +#### Scenario: Global binding provides default + +- GIVEN credential A (provider=github) has a `scope=credential` binding with `project_id=NULL` and `agent_id=NULL` +- AND no project-level or agent-level github binding exists for project P +- WHEN a session starts in project P +- THEN the session receives credential A (global fallback) + +#### Scenario: Agent-level binding overrides global + +- GIVEN credential A (provider=github) has a global binding (`project_id=NULL`, `agent_id=NULL`) +- AND credential B (provider=github) is bound to project P with `agent_id=agent-1` +- WHEN a session starts for agent-1 in project P +- THEN the session receives credential B (agent-level overrides global) + +### Requirement: Credential Binding Authorization + +Creating or deleting `scope=credential` RoleBindings SHALL require authorization that depends on the binding's scope level. + +**All credential bindings** require the caller to hold `credential:owner` on the target credential. + +**Project-level bindings** (`project_id` set, `agent_id` NULL) additionally require the caller to hold `project:admin` or higher (level ≤ 2) on the target project. + +**Agent-level bindings** (`project_id` set, `agent_id` set) additionally require: +1. The caller to hold `project:admin` or higher (level ≤ 2) on the project that owns the agent +2. The specified agent to belong to the specified project (validated by the API server) +3. The `project_id` to be non-NULL (agent-credential bindings without a project are invalid) + +**Global bindings** (`project_id` NULL, `agent_id` NULL) additionally require the caller to hold `platform:admin`. + +#### Scenario: Project admin binds credential to project + +- GIVEN user A holds `credential:owner` on credential C +- AND user A holds `project:admin` on project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=NULL` +- THEN the binding is created (201) + +#### Scenario: Project owner binds credential to specific agent + +- GIVEN user A holds `credential:owner` on credential C +- AND user A holds `project:owner` on project P +- AND agent-1 belongs to project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` +- THEN the binding is created (201) + +#### Scenario: Project editor cannot bind credentials + +- GIVEN user A holds `credential:owner` on credential C +- AND user A holds `project:editor` on project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` +- THEN the request returns 403 Forbidden + +#### Scenario: Non-project-member cannot bind credential to agent + +- GIVEN user A holds `credential:owner` on credential C +- AND user A has no role on project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` +- THEN the request returns 403 Forbidden + +#### Scenario: Agent-credential binding requires project_id + +- GIVEN user A holds `credential:owner` on credential C +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `agent_id=agent-1`, `project_id=NULL` +- THEN the request returns 400 Bad Request +- AND the error indicates that agent-scoped credential bindings require a project_id + +#### Scenario: Agent must belong to the specified project + +- GIVEN user A holds `credential:owner` on credential C +- AND user A holds `project:owner` on project P +- AND agent-1 belongs to project Q (not P) +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` +- THEN the request returns 400 Bad Request + +#### Scenario: Platform admin creates global credential binding + +- GIVEN user A holds `platform:admin` +- AND user A holds `credential:owner` on credential C +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` +- THEN the binding is created (201) + +#### Scenario: Non-admin cannot create global credential binding + +- GIVEN user A holds `credential:owner` on credential C +- AND user A does NOT hold `platform:admin` +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` +- THEN the request returns 403 Forbidden + +### Requirement: credential:token-reader Grant Lifecycle + +The control plane SHALL grant `credential:token-reader` to the session's OIDC service identity for each credential resolved by the hierarchical resolver. This grant SHALL be scoped to the specific credential and SHALL be revoked when the session terminates. + +The control plane authenticates as a platform service account and creates these bindings via the standard `POST /role_bindings` API. Because `credential:token-reader` is an internal role, only service callers (not human users) can create these bindings. + +#### Scenario: Token-reader granted at session start + +- GIVEN credential A is resolved for a session via the hierarchical resolver +- WHEN the control plane provisions the session pod +- THEN a RoleBinding is created with `role=credential:token-reader`, `scope=credential`, `credential_id=A`, `user_id=` + +#### Scenario: Token-reader revoked at session end + +- GIVEN a session was provisioned with `credential:token-reader` for credential A +- WHEN the session terminates (Completed, Failed, or Stopped) +- THEN the `credential:token-reader` RoleBinding for credential A is deleted + +#### Scenario: Sidecar can fetch token with granted role + +- GIVEN the control plane granted `credential:token-reader` for credential A to the session's service identity +- WHEN the credential sidecar calls `GET /credentials/{A}/token` with the session's bearer token +- THEN the API server returns the decrypted token (200) + +#### Scenario: Sidecar cannot fetch unbound credential token + +- GIVEN credential B was NOT resolved for this session (no binding) +- AND no `credential:token-reader` was granted for credential B +- WHEN the credential sidecar calls `GET /credentials/{B}/token` +- THEN the API server returns 404 + +### Requirement: Binding Deletion Does Not Affect Running Sessions + +Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions that were provisioned with the previously-bound credential. The credential remains available for the session's lifetime via its existing `credential:token-reader` grant. New sessions started after the binding deletion SHALL NOT receive the credential. + +#### Scenario: Running session keeps credential after binding deleted + +- GIVEN a session is Running with credential A (bound at project-level) +- WHEN the project-level binding for credential A is deleted +- THEN the running session continues to use credential A +- AND the `credential:token-reader` grant for this session is NOT revoked + +#### Scenario: New session does not receive deleted binding's credential + +- GIVEN the project-level binding for credential A on project P was deleted +- WHEN a new session starts in project P +- THEN credential A is NOT injected (resolver finds no matching binding) + +## Migration + +### Existing consumers + +| Consumer | Current behavior | Required change | +|----------|-----------------|-----------------| +| Control plane `resolveCredentialIDs` | Lists all credentials via `sdk.Credentials().ListAll()`, picks first per provider | Query `scope=credential` RoleBindings filtered by `project_id` and `agent_id`, implement hierarchical resolution | +| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Add validation for agent-level bindings (verify agent belongs to project, caller has `project:admin`+), global bindings (require `platform:admin`), and reject `agent_id` without `project_id` | +| Credential sidecar entrypoint | Fetches token via bearer token from CP token exchange | No change — consumes `CREDENTIAL_IDS` produced by CP | +| Runner `populate_runtime_credentials` | Fetches tokens from `CREDENTIAL_IDS` env var | No change — consumes `CREDENTIAL_IDS` produced by CP | +| UI binding matrix | Creates RoleBindings with `credential_id` + `project_id` ± `agent_id` | No change — already creates correct binding structure | + +### Specs requiring amendment + +| Spec | Amendment | +|------|-----------| +| `rbac-enforcement.spec.md` | Add `project:admin` role at level 2; update credential binding authorization to require `project:admin`+ instead of `project:owner` | +| `ambient-model.spec.md` | Document global credential binding pattern (`scope=credential` with `project_id=NULL`, `agent_id=NULL`); add credential binding scope terms (agent-level, project-level, global) | From b8eae79e4e2596b81d4713bf33ccb33d700f2df9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 9 Jun 2026 14:11:04 -0400 Subject: [PATCH 2/4] spec(security): permission-based credential auth + CR fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace project:admin role hierarchy with project:credentials permission per Mark's feedback — any role can carry the permission without requiring a new hierarchy level. Loosely coupled permissions > rigid hierarchies. Also addresses CodeRabbit review: - Clarify agent-level auth targets binding's project_id (not agent's owner) - Upgrade duplicate binding rejection from SHOULD to SHALL Co-Authored-By: Claude Opus 4.6 (1M context) --- .../credential-binding-enforcement.spec.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/specs/security/credential-binding-enforcement.spec.md b/specs/security/credential-binding-enforcement.spec.md index d59d62a052..0e50d7a261 100644 --- a/specs/security/credential-binding-enforcement.spec.md +++ b/specs/security/credential-binding-enforcement.spec.md @@ -8,7 +8,7 @@ This spec defines the resolver algorithm, authorization rules for creating bindi ### Dependencies -- **`project:admin` role**: This spec requires a new `project:admin` role (level 2) between `project:owner` (level 1) and `project:editor` (level 3). Credential binding authorization requires `project:admin` or higher. The role definition and hierarchy amendment belong in `rbac-enforcement.spec.md`; this spec assumes the role exists. +- **`project:credentials` permission**: This spec requires a new `project:credentials` permission that gates credential binding operations on a project. Any role can carry this permission — `project:owner` includes it by default, `project:editor` does not. The permission definition belongs in `rbac-enforcement.spec.md`; this spec assumes it exists. - **Global credential binding pattern**: The `scope=credential` binding with both `project_id=NULL` and `agent_id=NULL` is a new pattern. `ambient-model.spec.md` SHALL be amended to document this as valid for credential scope. - **Session service identity**: The `credential:token-reader` lifecycle uses `user_id` in RoleBindings to represent the session's OIDC service account (e.g., `service-account-ambient-e2e`). This is the same identity the control plane already provisions via OIDC client_credentials grant — no new identity mechanism is required. @@ -25,7 +25,7 @@ For each credential provider (github, jira, kubeconfig, google, gitlab, vertex): 3. Otherwise, if a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` is NULL, AND `agent_id` is NULL — use that credential (**global binding**). 4. Otherwise, no credential is injected for this provider. -The API server SHOULD reject creation of duplicate bindings at the same scope level for the same provider (same `credential.provider`, same `project_id`, same `agent_id`). If duplicates exist despite this, the binding with the earliest `created_at` timestamp wins. +The API server SHALL reject creation of duplicate bindings at the same scope level for the same provider (same `credential.provider`, same `project_id`, same `agent_id`). If duplicates exist despite this (e.g., from prior data), the binding with the earliest `created_at` timestamp wins. #### Scenario: Agent-level binding overrides project-level @@ -77,34 +77,34 @@ Creating or deleting `scope=credential` RoleBindings SHALL require authorization **All credential bindings** require the caller to hold `credential:owner` on the target credential. -**Project-level bindings** (`project_id` set, `agent_id` NULL) additionally require the caller to hold `project:admin` or higher (level ≤ 2) on the target project. +**Project-level bindings** (`project_id` set, `agent_id` NULL) additionally require the caller to hold the `project:credentials` permission on the target project. **Agent-level bindings** (`project_id` set, `agent_id` set) additionally require: -1. The caller to hold `project:admin` or higher (level ≤ 2) on the project that owns the agent -2. The specified agent to belong to the specified project (validated by the API server) +1. The caller to hold the `project:credentials` permission on the project specified in the binding (`project_id`) +2. The specified agent to belong to that same project (validated by the API server) 3. The `project_id` to be non-NULL (agent-credential bindings without a project are invalid) **Global bindings** (`project_id` NULL, `agent_id` NULL) additionally require the caller to hold `platform:admin`. -#### Scenario: Project admin binds credential to project +#### Scenario: User with project:credentials permission binds credential to project - GIVEN user A holds `credential:owner` on credential C -- AND user A holds `project:admin` on project P +- AND user A holds a role on project P that includes the `project:credentials` permission - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=NULL` - THEN the binding is created (201) #### Scenario: Project owner binds credential to specific agent - GIVEN user A holds `credential:owner` on credential C -- AND user A holds `project:owner` on project P +- AND user A holds `project:owner` on project P (which includes `project:credentials`) - AND agent-1 belongs to project P - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` - THEN the binding is created (201) -#### Scenario: Project editor cannot bind credentials +#### Scenario: User without project:credentials cannot bind credentials - GIVEN user A holds `credential:owner` on credential C -- AND user A holds `project:editor` on project P +- AND user A holds `project:editor` on project P (which does NOT include `project:credentials`) - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` - THEN the request returns 403 Forbidden @@ -199,7 +199,7 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Consumer | Current behavior | Required change | |----------|-----------------|-----------------| | Control plane `resolveCredentialIDs` | Lists all credentials via `sdk.Credentials().ListAll()`, picks first per provider | Query `scope=credential` RoleBindings filtered by `project_id` and `agent_id`, implement hierarchical resolution | -| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Add validation for agent-level bindings (verify agent belongs to project, caller has `project:admin`+), global bindings (require `platform:admin`), and reject `agent_id` without `project_id` | +| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Check caller holds `project:credentials` permission (not just `project:owner`), add agent-level validation (verify agent belongs to project), global bindings (require `platform:admin`), and reject `agent_id` without `project_id` | | Credential sidecar entrypoint | Fetches token via bearer token from CP token exchange | No change — consumes `CREDENTIAL_IDS` produced by CP | | Runner `populate_runtime_credentials` | Fetches tokens from `CREDENTIAL_IDS` env var | No change — consumes `CREDENTIAL_IDS` produced by CP | | UI binding matrix | Creates RoleBindings with `credential_id` + `project_id` ± `agent_id` | No change — already creates correct binding structure | @@ -208,5 +208,5 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Spec | Amendment | |------|-----------| -| `rbac-enforcement.spec.md` | Add `project:admin` role at level 2; update credential binding authorization to require `project:admin`+ instead of `project:owner` | +| `rbac-enforcement.spec.md` | Add `project:credentials` permission; assign it to `project:owner` by default; update credential binding authorization to check permission instead of role level | | `ambient-model.spec.md` | Document global credential binding pattern (`scope=credential` with `project_id=NULL`, `agent_id=NULL`); add credential binding scope terms (agent-level, project-level, global) | From f21ab194f3a1813708385b4621002d1a92903747 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 9 Jun 2026 14:20:47 -0400 Subject: [PATCH 3/4] spec(security): simplify auth model, add bind/unbind asymmetry Per Mark's feedback: keep simple roles, don't introduce permission granularity. Drop project:credentials permission in favor of existing project:owner checks. Key change: bind requires credential:owner + project:owner (you control both sides). Unbind requires only project:owner (you can remove any credential from your project regardless of who bound it). Also adds terminology section, clarifies session OIDC service identity, and standardizes provider enum to alphabetical order. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../credential-binding-enforcement.spec.md | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/specs/security/credential-binding-enforcement.spec.md b/specs/security/credential-binding-enforcement.spec.md index 0e50d7a261..fdda299eb4 100644 --- a/specs/security/credential-binding-enforcement.spec.md +++ b/specs/security/credential-binding-enforcement.spec.md @@ -6,11 +6,16 @@ Credentials are global resources. Access to a credential's token at session runt This spec defines the resolver algorithm, authorization rules for creating bindings at each level, and the `credential:token-reader` grant lifecycle. +### Terminology + +- **Agent-level binding**: A `scope=credential` RoleBinding with `credential_id`, `project_id`, and `agent_id` all set. Grants the credential to one specific agent. +- **Project-level binding**: A `scope=credential` RoleBinding with `credential_id` and `project_id` set, `agent_id` NULL. Grants the credential to all agents in the project. +- **Global binding**: A `scope=credential` RoleBinding with `credential_id` set, `project_id` NULL, `agent_id` NULL. Grants the credential as a platform-wide default. +- **Session OIDC service identity**: A machine identity provisioned by the control plane at session start via OIDC `client_credentials` grant. Materializes as a `user_id` in RoleBinding records (e.g., `service-account-`). The control plane uses this identity to grant `credential:token-reader` bindings on behalf of the session pod. + ### Dependencies -- **`project:credentials` permission**: This spec requires a new `project:credentials` permission that gates credential binding operations on a project. Any role can carry this permission — `project:owner` includes it by default, `project:editor` does not. The permission definition belongs in `rbac-enforcement.spec.md`; this spec assumes it exists. - **Global credential binding pattern**: The `scope=credential` binding with both `project_id=NULL` and `agent_id=NULL` is a new pattern. `ambient-model.spec.md` SHALL be amended to document this as valid for credential scope. -- **Session service identity**: The `credential:token-reader` lifecycle uses `user_id` in RoleBindings to represent the session's OIDC service account (e.g., `service-account-ambient-e2e`). This is the same identity the control plane already provisions via OIDC client_credentials grant — no new identity mechanism is required. ## Requirements @@ -18,7 +23,7 @@ This spec defines the resolver algorithm, authorization rules for creating bindi The control plane SHALL resolve credentials for a session by walking `scope=credential` RoleBindings from most-specific to least-specific scope: **agent → project → global**. -For each credential provider (github, jira, kubeconfig, google, gitlab, vertex): +For each credential provider (github, gitlab, google, jira, kubeconfig, vertex): 1. If a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` matches the session's project, AND `agent_id` matches the session's agent — use that credential (**agent-level binding**). 2. Otherwise, if a `scope=credential` binding exists where `credential_id` references a credential of this provider, `project_id` matches the session's project, AND `agent_id` is NULL — use that credential (**project-level binding**). @@ -73,42 +78,56 @@ The API server SHALL reject creation of duplicate bindings at the same scope lev ### Requirement: Credential Binding Authorization -Creating or deleting `scope=credential` RoleBindings SHALL require authorization that depends on the binding's scope level. +**Binding** (creating) and **unbinding** (deleting) `scope=credential` RoleBindings have asymmetric authorization rules. Binding grants access to a secret and requires ownership of both sides. Unbinding revokes access from a project and only requires ownership of the destination. + +#### Binding (create) + +**All credential bindings** require the caller to hold `credential:owner` on the target credential. You can only bind credentials you own. + +**Project-level and agent-level bindings** additionally require the caller to hold `project:owner` on the target project. -**All credential bindings** require the caller to hold `credential:owner` on the target credential. +**Agent-level bindings** additionally require: +1. The specified agent to belong to the project specified in the binding (`project_id`), validated by the API server +2. The `project_id` to be non-NULL (agent-credential bindings without a project are invalid) -**Project-level bindings** (`project_id` set, `agent_id` NULL) additionally require the caller to hold the `project:credentials` permission on the target project. +**Global bindings** additionally require the caller to hold `platform:admin`. -**Agent-level bindings** (`project_id` set, `agent_id` set) additionally require: -1. The caller to hold the `project:credentials` permission on the project specified in the binding (`project_id`) -2. The specified agent to belong to that same project (validated by the API server) -3. The `project_id` to be non-NULL (agent-credential bindings without a project are invalid) +#### Unbinding (delete) -**Global bindings** (`project_id` NULL, `agent_id` NULL) additionally require the caller to hold `platform:admin`. +**Project-level and agent-level bindings** require the caller to hold `project:owner` on the binding's project. The caller does NOT need `credential:owner` — a project owner can remove any credential from their project regardless of who bound it. -#### Scenario: User with project:credentials permission binds credential to project +**Global bindings** require the caller to hold `platform:admin`. + +#### Scenario: Project owner binds own credential to project - GIVEN user A holds `credential:owner` on credential C -- AND user A holds a role on project P that includes the `project:credentials` permission +- AND user A holds `project:owner` on project P - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=NULL` - THEN the binding is created (201) -#### Scenario: Project owner binds credential to specific agent +#### Scenario: Project owner binds own credential to specific agent - GIVEN user A holds `credential:owner` on credential C -- AND user A holds `project:owner` on project P (which includes `project:credentials`) +- AND user A holds `project:owner` on project P - AND agent-1 belongs to project P - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` - THEN the binding is created (201) -#### Scenario: User without project:credentials cannot bind credentials +#### Scenario: Non-credential-owner cannot bind + +- GIVEN user A does NOT hold `credential:owner` on credential C +- AND user A holds `project:owner` on project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` +- THEN the request returns 403 Forbidden + +#### Scenario: Project editor cannot bind credentials - GIVEN user A holds `credential:owner` on credential C -- AND user A holds `project:editor` on project P (which does NOT include `project:credentials`) +- AND user A holds `project:editor` on project P - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` - THEN the request returns 403 Forbidden -#### Scenario: Non-project-member cannot bind credential to agent +#### Scenario: Non-project-member cannot bind credential - GIVEN user A holds `credential:owner` on credential C - AND user A has no role on project P @@ -144,6 +163,21 @@ Creating or deleting `scope=credential` RoleBindings SHALL require authorization - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` - THEN the request returns 403 Forbidden +#### Scenario: Project owner unbinds credential they don't own + +- GIVEN user B (not credential owner) holds `project:owner` on project P +- AND credential C (owned by user A) is bound to project P +- WHEN user B deletes the `scope=credential` RoleBinding for credential C on project P +- THEN the binding is deleted (204) + +#### Scenario: Non-project-owner cannot unbind + +- GIVEN user A holds `credential:owner` on credential C +- AND user A does NOT hold `project:owner` on project P +- AND credential C is bound to project P +- WHEN user A deletes the `scope=credential` RoleBinding for credential C on project P +- THEN the request returns 403 Forbidden + ### Requirement: credential:token-reader Grant Lifecycle The control plane SHALL grant `credential:token-reader` to the session's OIDC service identity for each credential resolved by the hierarchical resolver. This grant SHALL be scoped to the specific credential and SHALL be revoked when the session terminates. @@ -199,7 +233,7 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Consumer | Current behavior | Required change | |----------|-----------------|-----------------| | Control plane `resolveCredentialIDs` | Lists all credentials via `sdk.Credentials().ListAll()`, picks first per provider | Query `scope=credential` RoleBindings filtered by `project_id` and `agent_id`, implement hierarchical resolution | -| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Check caller holds `project:credentials` permission (not just `project:owner`), add agent-level validation (verify agent belongs to project), global bindings (require `platform:admin`), and reject `agent_id` without `project_id` | +| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Add agent-level validation (verify agent belongs to project), global bindings (require `platform:admin`), reject `agent_id` without `project_id`, and asymmetric unbind auth (project:owner can unbind without credential:owner) | | Credential sidecar entrypoint | Fetches token via bearer token from CP token exchange | No change — consumes `CREDENTIAL_IDS` produced by CP | | Runner `populate_runtime_credentials` | Fetches tokens from `CREDENTIAL_IDS` env var | No change — consumes `CREDENTIAL_IDS` produced by CP | | UI binding matrix | Creates RoleBindings with `credential_id` + `project_id` ± `agent_id` | No change — already creates correct binding structure | @@ -208,5 +242,5 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Spec | Amendment | |------|-----------| -| `rbac-enforcement.spec.md` | Add `project:credentials` permission; assign it to `project:owner` by default; update credential binding authorization to check permission instead of role level | +| `rbac-enforcement.spec.md` | Document bind/unbind authorization asymmetry for credential bindings; update credential binding scenarios to reflect project:owner can unbind without credential:owner | | `ambient-model.spec.md` | Document global credential binding pattern (`scope=credential` with `project_id=NULL`, `agent_id=NULL`); add credential binding scope terms (agent-level, project-level, global) | From f08d79ea58c08b092272427e007d6829c303559b Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 9 Jun 2026 14:22:22 -0400 Subject: [PATCH 4/4] spec(security): relax credential binding to project:editor+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Mark: keep it simple. Editors (project composers) can bind/unbind credentials. Viewers (consumers) cannot. No new permissions or roles needed — uses existing role hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../credential-binding-enforcement.spec.md | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/specs/security/credential-binding-enforcement.spec.md b/specs/security/credential-binding-enforcement.spec.md index fdda299eb4..29906be4ac 100644 --- a/specs/security/credential-binding-enforcement.spec.md +++ b/specs/security/credential-binding-enforcement.spec.md @@ -84,7 +84,7 @@ The API server SHALL reject creation of duplicate bindings at the same scope lev **All credential bindings** require the caller to hold `credential:owner` on the target credential. You can only bind credentials you own. -**Project-level and agent-level bindings** additionally require the caller to hold `project:owner` on the target project. +**Project-level and agent-level bindings** additionally require the caller to hold `project:editor` or higher on the target project. **Agent-level bindings** additionally require: 1. The specified agent to belong to the project specified in the binding (`project_id`), validated by the API server @@ -94,7 +94,7 @@ The API server SHALL reject creation of duplicate bindings at the same scope lev #### Unbinding (delete) -**Project-level and agent-level bindings** require the caller to hold `project:owner` on the binding's project. The caller does NOT need `credential:owner` — a project owner can remove any credential from their project regardless of who bound it. +**Project-level and agent-level bindings** require the caller to hold `project:editor` or higher on the binding's project. The caller does NOT need `credential:owner` — a project editor/owner can remove any credential from their project regardless of who bound it. **Global bindings** require the caller to hold `platform:admin`. @@ -120,11 +120,18 @@ The API server SHALL reject creation of duplicate bindings at the same scope lev - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` - THEN the request returns 403 Forbidden -#### Scenario: Project editor cannot bind credentials +#### Scenario: Project editor binds own credential to project - GIVEN user A holds `credential:owner` on credential C - AND user A holds `project:editor` on project P - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` +- THEN the binding is created (201) + +#### Scenario: Project viewer cannot bind credentials + +- GIVEN user A holds `credential:owner` on credential C +- AND user A holds `project:viewer` on project P +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` - THEN the request returns 403 Forbidden #### Scenario: Non-project-member cannot bind credential @@ -163,17 +170,16 @@ The API server SHALL reject creation of duplicate bindings at the same scope lev - WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` - THEN the request returns 403 Forbidden -#### Scenario: Project owner unbinds credential they don't own +#### Scenario: Project editor unbinds credential they don't own -- GIVEN user B (not credential owner) holds `project:owner` on project P +- GIVEN user B (not credential owner) holds `project:editor` on project P - AND credential C (owned by user A) is bound to project P - WHEN user B deletes the `scope=credential` RoleBinding for credential C on project P - THEN the binding is deleted (204) -#### Scenario: Non-project-owner cannot unbind +#### Scenario: Project viewer cannot unbind -- GIVEN user A holds `credential:owner` on credential C -- AND user A does NOT hold `project:owner` on project P +- GIVEN user A holds `project:viewer` on project P - AND credential C is bound to project P - WHEN user A deletes the `scope=credential` RoleBinding for credential C on project P - THEN the request returns 403 Forbidden @@ -233,7 +239,7 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Consumer | Current behavior | Required change | |----------|-----------------|-----------------| | Control plane `resolveCredentialIDs` | Lists all credentials via `sdk.Credentials().ListAll()`, picks first per provider | Query `scope=credential` RoleBindings filtered by `project_id` and `agent_id`, implement hierarchical resolution | -| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Add agent-level validation (verify agent belongs to project), global bindings (require `platform:admin`), reject `agent_id` without `project_id`, and asymmetric unbind auth (project:owner can unbind without credential:owner) | +| RBAC middleware (credential binding creation) | Validates `credential:owner` + `project:owner` for project-level bindings | Relax bind check to `project:editor`+, add agent-level validation (verify agent belongs to project), global bindings (require `platform:admin`), reject `agent_id` without `project_id`, and asymmetric unbind auth (`project:editor`+ can unbind without `credential:owner`) | | Credential sidecar entrypoint | Fetches token via bearer token from CP token exchange | No change — consumes `CREDENTIAL_IDS` produced by CP | | Runner `populate_runtime_credentials` | Fetches tokens from `CREDENTIAL_IDS` env var | No change — consumes `CREDENTIAL_IDS` produced by CP | | UI binding matrix | Creates RoleBindings with `credential_id` + `project_id` ± `agent_id` | No change — already creates correct binding structure | @@ -242,5 +248,5 @@ Deleting a `scope=credential` RoleBinding SHALL NOT terminate running sessions t | Spec | Amendment | |------|-----------| -| `rbac-enforcement.spec.md` | Document bind/unbind authorization asymmetry for credential bindings; update credential binding scenarios to reflect project:owner can unbind without credential:owner | +| `rbac-enforcement.spec.md` | Relax credential binding from `project:owner` to `project:editor`+; document bind/unbind asymmetry (editors can unbind without `credential:owner`) | | `ambient-model.spec.md` | Document global credential binding pattern (`scope=credential` with `project_id=NULL`, `agent_id=NULL`); add credential binding scope terms (agent-level, project-level, global) |