|
| 1 | +# Credential Binding Enforcement |
| 2 | + |
| 3 | +## Purpose |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +This spec defines the resolver algorithm, authorization rules for creating bindings at each level, and the `credential:token-reader` grant lifecycle. |
| 8 | + |
| 9 | +### Terminology |
| 10 | + |
| 11 | +- **Agent-level binding**: A `scope=credential` RoleBinding with `credential_id`, `project_id`, and `agent_id` all set. Grants the credential to one specific agent. |
| 12 | +- **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. |
| 13 | +- **Global binding**: A `scope=credential` RoleBinding with `credential_id` set, `project_id` NULL, `agent_id` NULL. Grants the credential as a platform-wide default. |
| 14 | +- **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-<client-id>`). The control plane uses this identity to grant `credential:token-reader` bindings on behalf of the session pod. |
| 15 | + |
| 16 | +### Dependencies |
| 17 | + |
| 18 | +- **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. |
| 19 | + |
| 20 | +## Requirements |
| 21 | + |
| 22 | +### Requirement: Hierarchical Credential Resolution |
| 23 | + |
| 24 | +The control plane SHALL resolve credentials for a session by walking `scope=credential` RoleBindings from most-specific to least-specific scope: **agent → project → global**. |
| 25 | + |
| 26 | +For each credential provider (github, gitlab, google, jira, kubeconfig, vertex): |
| 27 | + |
| 28 | +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**). |
| 29 | +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**). |
| 30 | +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**). |
| 31 | +4. Otherwise, no credential is injected for this provider. |
| 32 | + |
| 33 | +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. |
| 34 | + |
| 35 | +#### Scenario: Agent-level binding overrides project-level |
| 36 | + |
| 37 | +- GIVEN credential A (provider=github) is bound to project P with `agent_id=NULL` |
| 38 | +- AND credential B (provider=github) is bound to project P with `agent_id=agent-1` |
| 39 | +- WHEN a session starts for agent-1 in project P |
| 40 | +- THEN the session receives credential B (agent-level wins) |
| 41 | + |
| 42 | +#### Scenario: Project-level binding used when no agent-level exists |
| 43 | + |
| 44 | +- GIVEN credential A (provider=github) is bound to project P with `agent_id=NULL` |
| 45 | +- AND no agent-level github binding exists for agent-1 in project P |
| 46 | +- WHEN a session starts for agent-1 in project P |
| 47 | +- THEN the session receives credential A (project-level fallback) |
| 48 | + |
| 49 | +#### Scenario: No binding means no injection |
| 50 | + |
| 51 | +- GIVEN credential A (provider=github) is bound to project P |
| 52 | +- AND no github credential is bound to project Q at any level |
| 53 | +- WHEN a session starts in project Q |
| 54 | +- THEN no github credential is injected into the session |
| 55 | + |
| 56 | +#### Scenario: Multiple providers resolved independently |
| 57 | + |
| 58 | +- GIVEN credential A (provider=github) is bound to project P at project-level |
| 59 | +- AND credential B (provider=jira) is bound to project P at agent-level for agent-1 |
| 60 | +- AND no google credential is bound to project P |
| 61 | +- WHEN a session starts for agent-1 in project P |
| 62 | +- THEN the session receives credential A (github, project-level) and credential B (jira, agent-level) |
| 63 | +- AND no google credential is injected |
| 64 | + |
| 65 | +#### Scenario: Global binding provides default |
| 66 | + |
| 67 | +- GIVEN credential A (provider=github) has a `scope=credential` binding with `project_id=NULL` and `agent_id=NULL` |
| 68 | +- AND no project-level or agent-level github binding exists for project P |
| 69 | +- WHEN a session starts in project P |
| 70 | +- THEN the session receives credential A (global fallback) |
| 71 | + |
| 72 | +#### Scenario: Agent-level binding overrides global |
| 73 | + |
| 74 | +- GIVEN credential A (provider=github) has a global binding (`project_id=NULL`, `agent_id=NULL`) |
| 75 | +- AND credential B (provider=github) is bound to project P with `agent_id=agent-1` |
| 76 | +- WHEN a session starts for agent-1 in project P |
| 77 | +- THEN the session receives credential B (agent-level overrides global) |
| 78 | + |
| 79 | +### Requirement: Credential Binding Authorization |
| 80 | + |
| 81 | +**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. |
| 82 | + |
| 83 | +#### Binding (create) |
| 84 | + |
| 85 | +**All credential bindings** require the caller to hold `credential:owner` on the target credential. You can only bind credentials you own. |
| 86 | + |
| 87 | +**Project-level and agent-level bindings** additionally require the caller to hold `project:editor` or higher on the target project. |
| 88 | + |
| 89 | +**Agent-level bindings** additionally require: |
| 90 | +1. The specified agent to belong to the project specified in the binding (`project_id`), validated by the API server |
| 91 | +2. The `project_id` to be non-NULL (agent-credential bindings without a project are invalid) |
| 92 | + |
| 93 | +**Global bindings** additionally require the caller to hold `platform:admin`. |
| 94 | + |
| 95 | +#### Unbinding (delete) |
| 96 | + |
| 97 | +**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. |
| 98 | + |
| 99 | +**Global bindings** require the caller to hold `platform:admin`. |
| 100 | + |
| 101 | +#### Scenario: Project owner binds own credential to project |
| 102 | + |
| 103 | +- GIVEN user A holds `credential:owner` on credential C |
| 104 | +- AND user A holds `project:owner` on project P |
| 105 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=NULL` |
| 106 | +- THEN the binding is created (201) |
| 107 | + |
| 108 | +#### Scenario: Project owner binds own credential to specific agent |
| 109 | + |
| 110 | +- GIVEN user A holds `credential:owner` on credential C |
| 111 | +- AND user A holds `project:owner` on project P |
| 112 | +- AND agent-1 belongs to project P |
| 113 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` |
| 114 | +- THEN the binding is created (201) |
| 115 | + |
| 116 | +#### Scenario: Non-credential-owner cannot bind |
| 117 | + |
| 118 | +- GIVEN user A does NOT hold `credential:owner` on credential C |
| 119 | +- AND user A holds `project:owner` on project P |
| 120 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` |
| 121 | +- THEN the request returns 403 Forbidden |
| 122 | + |
| 123 | +#### Scenario: Project editor binds own credential to project |
| 124 | + |
| 125 | +- GIVEN user A holds `credential:owner` on credential C |
| 126 | +- AND user A holds `project:editor` on project P |
| 127 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` |
| 128 | +- THEN the binding is created (201) |
| 129 | + |
| 130 | +#### Scenario: Project viewer cannot bind credentials |
| 131 | + |
| 132 | +- GIVEN user A holds `credential:owner` on credential C |
| 133 | +- AND user A holds `project:viewer` on project P |
| 134 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P` |
| 135 | +- THEN the request returns 403 Forbidden |
| 136 | + |
| 137 | +#### Scenario: Non-project-member cannot bind credential |
| 138 | + |
| 139 | +- GIVEN user A holds `credential:owner` on credential C |
| 140 | +- AND user A has no role on project P |
| 141 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` |
| 142 | +- THEN the request returns 403 Forbidden |
| 143 | + |
| 144 | +#### Scenario: Agent-credential binding requires project_id |
| 145 | + |
| 146 | +- GIVEN user A holds `credential:owner` on credential C |
| 147 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `agent_id=agent-1`, `project_id=NULL` |
| 148 | +- THEN the request returns 400 Bad Request |
| 149 | +- AND the error indicates that agent-scoped credential bindings require a project_id |
| 150 | + |
| 151 | +#### Scenario: Agent must belong to the specified project |
| 152 | + |
| 153 | +- GIVEN user A holds `credential:owner` on credential C |
| 154 | +- AND user A holds `project:owner` on project P |
| 155 | +- AND agent-1 belongs to project Q (not P) |
| 156 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=P`, `agent_id=agent-1` |
| 157 | +- THEN the request returns 400 Bad Request |
| 158 | + |
| 159 | +#### Scenario: Platform admin creates global credential binding |
| 160 | + |
| 161 | +- GIVEN user A holds `platform:admin` |
| 162 | +- AND user A holds `credential:owner` on credential C |
| 163 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` |
| 164 | +- THEN the binding is created (201) |
| 165 | + |
| 166 | +#### Scenario: Non-admin cannot create global credential binding |
| 167 | + |
| 168 | +- GIVEN user A holds `credential:owner` on credential C |
| 169 | +- AND user A does NOT hold `platform:admin` |
| 170 | +- WHEN user A creates a RoleBinding with `scope=credential`, `credential_id=C`, `project_id=NULL`, `agent_id=NULL` |
| 171 | +- THEN the request returns 403 Forbidden |
| 172 | + |
| 173 | +#### Scenario: Project editor unbinds credential they don't own |
| 174 | + |
| 175 | +- GIVEN user B (not credential owner) holds `project:editor` on project P |
| 176 | +- AND credential C (owned by user A) is bound to project P |
| 177 | +- WHEN user B deletes the `scope=credential` RoleBinding for credential C on project P |
| 178 | +- THEN the binding is deleted (204) |
| 179 | + |
| 180 | +#### Scenario: Project viewer cannot unbind |
| 181 | + |
| 182 | +- GIVEN user A holds `project:viewer` on project P |
| 183 | +- AND credential C is bound to project P |
| 184 | +- WHEN user A deletes the `scope=credential` RoleBinding for credential C on project P |
| 185 | +- THEN the request returns 403 Forbidden |
| 186 | + |
| 187 | +### Requirement: credential:token-reader Grant Lifecycle |
| 188 | + |
| 189 | +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. |
| 190 | + |
| 191 | +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. |
| 192 | + |
| 193 | +#### Scenario: Token-reader granted at session start |
| 194 | + |
| 195 | +- GIVEN credential A is resolved for a session via the hierarchical resolver |
| 196 | +- WHEN the control plane provisions the session pod |
| 197 | +- THEN a RoleBinding is created with `role=credential:token-reader`, `scope=credential`, `credential_id=A`, `user_id=<session-oidc-service-account>` |
| 198 | + |
| 199 | +#### Scenario: Token-reader revoked at session end |
| 200 | + |
| 201 | +- GIVEN a session was provisioned with `credential:token-reader` for credential A |
| 202 | +- WHEN the session terminates (Completed, Failed, or Stopped) |
| 203 | +- THEN the `credential:token-reader` RoleBinding for credential A is deleted |
| 204 | + |
| 205 | +#### Scenario: Sidecar can fetch token with granted role |
| 206 | + |
| 207 | +- GIVEN the control plane granted `credential:token-reader` for credential A to the session's service identity |
| 208 | +- WHEN the credential sidecar calls `GET /credentials/{A}/token` with the session's bearer token |
| 209 | +- THEN the API server returns the decrypted token (200) |
| 210 | + |
| 211 | +#### Scenario: Sidecar cannot fetch unbound credential token |
| 212 | + |
| 213 | +- GIVEN credential B was NOT resolved for this session (no binding) |
| 214 | +- AND no `credential:token-reader` was granted for credential B |
| 215 | +- WHEN the credential sidecar calls `GET /credentials/{B}/token` |
| 216 | +- THEN the API server returns 404 |
| 217 | + |
| 218 | +### Requirement: Binding Deletion Does Not Affect Running Sessions |
| 219 | + |
| 220 | +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. |
| 221 | + |
| 222 | +#### Scenario: Running session keeps credential after binding deleted |
| 223 | + |
| 224 | +- GIVEN a session is Running with credential A (bound at project-level) |
| 225 | +- WHEN the project-level binding for credential A is deleted |
| 226 | +- THEN the running session continues to use credential A |
| 227 | +- AND the `credential:token-reader` grant for this session is NOT revoked |
| 228 | + |
| 229 | +#### Scenario: New session does not receive deleted binding's credential |
| 230 | + |
| 231 | +- GIVEN the project-level binding for credential A on project P was deleted |
| 232 | +- WHEN a new session starts in project P |
| 233 | +- THEN credential A is NOT injected (resolver finds no matching binding) |
| 234 | + |
| 235 | +## Migration |
| 236 | + |
| 237 | +### Existing consumers |
| 238 | + |
| 239 | +| Consumer | Current behavior | Required change | |
| 240 | +|----------|-----------------|-----------------| |
| 241 | +| 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 | |
| 242 | +| 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`) | |
| 243 | +| Credential sidecar entrypoint | Fetches token via bearer token from CP token exchange | No change — consumes `CREDENTIAL_IDS` produced by CP | |
| 244 | +| Runner `populate_runtime_credentials` | Fetches tokens from `CREDENTIAL_IDS` env var | No change — consumes `CREDENTIAL_IDS` produced by CP | |
| 245 | +| UI binding matrix | Creates RoleBindings with `credential_id` + `project_id` ± `agent_id` | No change — already creates correct binding structure | |
| 246 | + |
| 247 | +### Specs requiring amendment |
| 248 | + |
| 249 | +| Spec | Amendment | |
| 250 | +|------|-----------| |
| 251 | +| `rbac-enforcement.spec.md` | Relax credential binding from `project:owner` to `project:editor`+; document bind/unbind asymmetry (editors can unbind without `credential:owner`) | |
| 252 | +| `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) | |
0 commit comments