Skip to content

Commit c5de4fc

Browse files
jsell-rhclaude
andauthored
spec(security): credential binding enforcement (#1670)
## Summary - Defines how the control plane resolves credentials for sessions by honoring `scope=credential` RoleBindings - Hierarchical resolution: agent → project → global (most-specific wins) - Authorization uses `project:credentials` permission (not role hierarchy) — any role can carry it, `project:owner` includes it by default, `project:editor` does not - `credential:token-reader` least-privilege grant/revoke lifecycle - Binding deletion does not affect running sessions **Root cause this addresses:** The CP currently calls `ListAll()` and picks the first credential per provider, ignoring all bindings. The UI creates bindings correctly but they're never read at session provisioning time. **Dependencies:** - `project:credentials` permission — amendment to `rbac-enforcement.spec.md` - Global credential binding pattern — amendment to `ambient-model.spec.md` ## Test plan - [ ] Review spec requirements and scenarios for completeness - [ ] Verify no conflicts with existing `rbac-enforcement.spec.md` requirements - [ ] Verify `ambient-model.spec.md` amendment scope is accurate 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1c23b65 commit c5de4fc

1 file changed

Lines changed: 252 additions & 0 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)