Skip to content

Commit 450aabf

Browse files
markturanskyclaudeAmbient Code Bot
authored
spec(rbac): typed nullable FKs on RoleBinding + workflow and coverage improvements (#1580)
## Summary - **RoleBinding model redesign**: Replace polymorphic `scope_id string` with typed nullable FK columns (`user_id`, `project_id`, `agent_id`, `session_id`, `credential_id`). `scope` remains as the discriminator. `user_id` is nullable — only set for user-specific (ownership) bindings; null for project-level grants. Credential→project bindings set both `credential_id` and `project_id` with `user_id=NULL`. - **CLI flag update**: `acpctl credential bind <name> --project <project>` replaces `--scope project --scope-id <project>`. `create role-binding` now uses explicit typed flags instead of a generic `--scope-id`. - **Workflow improvements**: Step 3 gap analysis now requires a three-way field-level diff (spec ↔ OpenAPI ↔ model). Integration tests must use the spec-correct route, not an implementation-convenient one. - **Coverage matrix fixes**: `apply` for Credential kind marked ✅ (already implemented); stale project-scoped token-fetch path corrected to `/credentials/{id}/token`. - **Security spec**: Credential access requirement and design decisions updated to name the exact FK columns used in credential→project bindings. - **NetworkPolicy fix**: `runner-networkpolicy.yaml` allows all ingress (`- {}` wildcard) so NodePort traffic reaches the frontend pod (E2E fix). ## Test plan - [ ] Spec ERD Mermaid renders correctly (no broken relationship lines) - [ ] `acpctl credential bind` command shape matches the new `--project` flag in spec - [ ] `POST /role_bindings` body shape documented in spec matches the nullable FK design - [ ] No references to `scope_id` remain in RoleBinding model spec or security spec 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Credentials are now documented as globally scoped resources (not project-scoped). * CLI examples updated: credential bind uses --project; role-binding creation examples show explicit id flags for user/project/agent/session/credential. * API coverage and token endpoint documentation updated to the global credential path. * Workflow guidance strengthened: stricter field-level spec↔model↔OpenAPI validation and tests. * **Behavior Changes** * RBAC/credential access semantics clarified: credential grants resolved via credential-scoped role bindings for project access. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ambient Code Bot <bot@ambient-code.local>
1 parent 82c4026 commit 450aabf

3 files changed

Lines changed: 91 additions & 39 deletions

File tree

specs/api/ambient-model.spec.md

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The Ambient API server provides a coordination layer for orchestrating fleets of
1818
- **Message** — a single AG-UI event in the LLM conversation. Append-only; the canonical record of what happened in a session.
1919
- **Inbox** — a persistent message queue on an Agent. Messages survive across sessions and are drained into the start context at the next run.
2020
- **Credential** — a global secret. Stores a Personal Access Token or equivalent for an external provider (GitHub, GitLab, Jira, Google, Vertex AI, Kubeconfig). Consumed by runners at session start. Bound to Projects via RoleBindings — a single Credential can be shared across multiple Projects without duplication.
21-
- **RoleBinding** — binds a Resource to a Role at a given scope (`global`, `project`, `agent`, `session`). Ownership and access for all Kinds is expressed through RoleBindings.
21+
- **RoleBinding** — binds a Role to a subject (user or project) at a given scope. Ownership and access for all Kinds is expressed through RoleBindings. The subject and scope are each represented as typed nullable FKs — exactly one FK is non-null, determined by `scope`.
2222

2323
The stable address of an agent is `{project_name}/{agent_name}`. It holds the inbox and links to the active session.
2424

@@ -172,10 +172,13 @@ erDiagram
172172
173173
RoleBinding {
174174
string ID PK
175-
string user_id FK
176175
string role_id FK
177-
string scope "global | project | agent | session | credential"
178-
string scope_id "empty for global"
176+
string scope "global | project | agent | session | credential"
177+
string user_id FK "nullable — set when scope identifies a user subject"
178+
string project_id FK "nullable — set when scope=project"
179+
string agent_id FK "nullable — set when scope=agent"
180+
string session_id FK "nullable — set when scope=session"
181+
string credential_id FK "nullable — set when scope=credential"
179182
time created_at
180183
time updated_at
181184
time deleted_at
@@ -225,12 +228,14 @@ erDiagram
225228
226229
Project ||--o{ ProjectSettings : "has"
227230
Project ||--o{ Agent : "owns"
228-
RoleBinding }o--o| Credential : "grants_access"
231+
RoleBinding }o--o| Credential : "credential_id"
229232
Project ||--o{ ScheduledSession : "owns"
230233
231-
User ||--o{ RoleBinding : "bound_to"
234+
User }o--o{ RoleBinding : "user_id"
235+
Project }o--o{ RoleBinding : "project_id"
232236
233-
RoleBinding }o--o| Agent : "owns"
237+
RoleBinding }o--o| Agent : "agent_id"
238+
RoleBinding }o--o| Session : "session_id"
234239
235240
Agent ||--o{ Session : "runs"
236241
Agent ||--o| Session : "current_session"
@@ -456,7 +461,7 @@ The `acpctl` CLI mirrors the API 1-for-1. Every REST operation has a correspondi
456461
| `PATCH /credentials/{cred_id}` | `acpctl credential update <id> [--token <t>] [--description <d>]` | ✅ implemented |
457462
| `DELETE /credentials/{cred_id}` | `acpctl credential delete <id> --confirm` | ✅ implemented |
458463
| `GET /credentials/{cred_id}/token` | `acpctl credential token <id>` | ✅ implemented |
459-
| `POST /role_bindings` | `acpctl credential bind <cred-name> --scope project --scope-id <project>` | 🔲 planned |
464+
| `POST /role_bindings` | `acpctl credential bind <cred-name> --project <project>` | 🔲 planned |
460465

461466
#### RBAC
462467

@@ -468,7 +473,7 @@ The `acpctl` CLI mirrors the API 1-for-1. Every REST operation has a correspondi
468473
| `DELETE /roles/{id}` | `acpctl delete role <id>` | ✅ implemented |
469474
| `GET /role_bindings` | `acpctl get role-bindings` | ✅ implemented |
470475
| `GET /role_bindings/{id}` | `acpctl get role-bindings <id>` | ✅ implemented |
471-
| `POST /role_bindings` | `acpctl create role-binding --user-id <u> --role-id <r> --scope <s> [--scope-id <id>]` | ✅ implemented |
476+
| `POST /role_bindings` | `acpctl create role-binding --role-id <r> --scope <s> [--user-id <u>] [--project-id <p>] [--agent-id <a>] [--session-id <s>] [--credential-id <c>]` | ✅ implemented |
472477
| `DELETE /role_bindings/{id}` | `acpctl delete role-binding <id>` | ✅ implemented |
473478

474479
#### Auth & Context
@@ -781,7 +786,7 @@ DELETE /api/ambient/v1/credentials/{cred_id} soft d
781786
GET /api/ambient/v1/credentials/{cred_id}/token fetch raw token — restricted to credential:token-reader
782787
```
783788

784-
> **Note:** `credential bind` (via `POST /role_bindings` with `scope=credential`) is planned but not yet implemented.
789+
> **Note:** `credential bind` (via `POST /role_bindings` with `scope=credential`, `credential_id`, and `project_id`) is planned but not yet implemented.
785790
786791
`token` is accepted on `POST` and `PATCH` but **never returned** by standard read endpoints.
787792
`GET .../token` is gated by `credential:token-reader`. See
@@ -816,26 +821,48 @@ When a runner fetches a credential, the response payload shape is consistent acr
816821

817822
## RBAC
818823

824+
### RoleBinding — Nullable FK Design
825+
826+
`RoleBinding` is a typed nullable FK table. Each row has exactly one non-null FK, determined by `scope`. There is no polymorphic `scope_id` string — every FK points to a real table with referential integrity.
827+
828+
| `scope` value | Non-null FK | Meaning |
829+
|---|---|---|
830+
| `global` | _(none)_ | Role applies across the entire platform |
831+
| `project` | `project_id` | Role applies within a specific project |
832+
| `agent` | `agent_id` | Role applies to a specific agent |
833+
| `session` | `session_id` | Role applies to a specific session run |
834+
| `credential` | `credential_id` | Role governs access to a specific credential |
835+
836+
`user_id` is a **separate, independently nullable FK** — it identifies the user who holds the binding when the grant is user-specific. It is null when the grant is project-level (not tied to a specific human):
837+
838+
| Use case | `user_id` | scope FK | Meaning |
839+
|---|---|---|---|
840+
| User A owns Credential Y | `user_id=A` | `credential_id=Y` | A can CRUD credential Y |
841+
| Credential Y bound to Project X | `user_id=NULL` | `credential_id=Y` + `project_id=X` | Project X can access credential Y |
842+
| User A is project:owner of Project X | `user_id=A` | `project_id=X` | A owns project X |
843+
| Global platform:admin grant | `user_id=A` | _(none)_ | A has platform-wide admin |
844+
845+
For credential→project bindings, both `credential_id` and `project_id` are non-null. This is the one exception to the "single FK per row" pattern — a credential binding names both the credential (the resource) and the project (the recipient). `user_id` is null because the grant is not user-specific; it applies to the entire project.
846+
819847
### Scopes
820848

821-
| Scope | Meaning |
822-
|---|---|
823-
| `global` | Applies across the entire platform |
824-
| `project` | Applies to all resources in a project (Agents, Sessions) and Credentials bound to the project |
825-
| `agent` | Applies to one Agent and all its sessions |
826-
| `session` | Applies to one session run only |
827-
| `credential` | Grants access to a specific Credential (used to bind credentials to projects) |
849+
| Scope | FK set | Meaning |
850+
|---|---|---|
851+
| `global` | _(none)_ | Applies across the entire platform |
852+
| `project` | `project_id` | Applies to all resources in a specific project |
853+
| `agent` | `agent_id` | Applies to a specific Agent and all its sessions |
854+
| `session` | `session_id` | Applies to one session run only |
855+
| `credential` | `credential_id` | Governs access to a specific Credential |
828856

829857
Effective permissions = union of all applicable bindings (global ∪ project ∪ agent ∪ session). No deny rules.
830858

831859
#### Credential Access — Global with RoleBinding Grants
832860

833-
Credentials are global resources. Access is granted via RoleBindings — bind a credential to a
834-
Project, Agent, or Session scope. At session start, the resolver lists all credentials the
835-
caller has access to (via RoleBindings) and returns matching credentials for each requested
836-
provider. A single Credential can be shared across multiple Projects without duplication.
837-
See [Security Spec — Credential Access via RoleBindings](../security/security.spec.md#requirement-credential-access-via-rolebindings) for
838-
runtime authorization semantics.
861+
Credentials are global resources. A credential is made accessible to a Project by creating a RoleBinding with `scope=credential`, `credential_id=<cred>`, `project_id=<project>`, and `user_id=NULL`. At session start, the resolver finds all `scope=credential` bindings where `project_id` matches the session's project and returns the matching credentials.
862+
863+
A single Credential can be shared across multiple Projects by creating one binding per project — no duplication of the Credential record.
864+
865+
See [Security Spec — Credential Access via RoleBindings](../security/security.spec.md#requirement-credential-access-via-rolebindings) for runtime authorization semantics.
839866

840867
### Built-in Roles
841868

@@ -1127,7 +1154,7 @@ This structure means you can define and compose bespoke agent suites — entire
11271154
|---|---|
11281155
| Agent is project-scoped, not global | Simplicity. An agent's identity and prompt are contextual to the project it serves. No indirection via a global registry. |
11291156
| Agent.prompt is mutable | Prompt editing is a routine operational task. RBAC controls who can change it. No versioning overhead. |
1130-
| Agent ownership via RBAC, not a hardcoded FK | Ownership is expressed as a RoleBinding (`scope=agent`, `scope_id=agent_id`). Enables multi-owner and delegated ownership consistently across all Kinds. |
1157+
| Agent ownership via RBAC, not a hardcoded FK | Ownership is expressed as a RoleBinding (`scope=agent`, `agent_id=<id>`, `user_id=<owner>`). Enables multi-owner and delegated ownership consistently across all Kinds. |
11311158
| One active Session per Agent | Avoids concurrent conflicting runs; start is idempotent |
11321159
| Inbox on Agent, not Session | Messages persist across re-ignitions; addressed to the agent, not the run |
11331160
| Inbox drained at start | Unread messages become part of the start context; session picks up where things left off |
@@ -1157,10 +1184,10 @@ echo "$GITLAB_PAT" | acpctl credential create --name my-gitlab-pat --provider gi
11571184
--token @- --url https://gitlab.myco.com
11581185

11591186
# Bind credential to a project (grants access to all agents in the project)
1160-
acpctl credential bind my-gitlab-pat --scope project --scope-id my-project
1187+
acpctl credential bind my-gitlab-pat --project my-project
11611188

11621189
# Bind the same credential to another project (no duplication)
1163-
acpctl credential bind my-gitlab-pat --scope project --scope-id other-project
1190+
acpctl credential bind my-gitlab-pat --project other-project
11641191

11651192
# List credentials (filtered by caller's RoleBindings)
11661193
acpctl credential list
@@ -1190,7 +1217,7 @@ acpctl apply -f credential.yaml
11901217
# credential/platform-gitlab-pat created
11911218

11921219
# Then bind to the desired project
1193-
acpctl credential bind platform-gitlab-pat --scope project --scope-id my-project
1220+
acpctl credential bind platform-gitlab-pat --project my-project
11941221
```
11951222

11961223
---
@@ -1235,15 +1262,15 @@ _Last updated: 2026-04-28. Use this as the authoritative index — click into co
12351262
| **RBAC — role bindings** | ✅ full CRUD |`RoleBindingAPI` |`create role-binding`, `get role-bindings`, `get role-bindings <id>`, `delete role-binding` | |
12361263
| **RBAC — scoped role_bindings queries** | ✅ agents only; 🔲 users/projects/sessions/credentials | n/a | n/a | `GET /projects/{id}/agents/{agent_id}/role_bindings` implemented; other 4 scoped endpoints not yet |
12371264
| **Credentials — CRUD** |`plugins/credentials/` (global at `/credentials`) |`credential_api.go` + `credential_extensions.go` |`credential list/get/create/update/delete/token` | `credential bind` not yet implemented. |
1238-
| **Credentials — token fetch** |`GET /projects/{id}/credentials/{cred_id}/token` |`GetToken()` in `credential_extensions.go` |`credential token <id>` | Gated by `credential:token-reader`; granted to runner SA by operator |
1265+
| **Credentials — token fetch** |`GET /credentials/{cred_id}/token` |`GetToken()` in `credential_extensions.go` |`credential token <id>` | Gated by `credential:token-reader`; granted to runner SA by operator |
12391266
| **ScheduledSessions — CRUD** | ✅ scheduledSessions plugin |`ScheduledSessionAPI.{List,Get,Create,Update,Delete,GetByName}` |`scheduled-session list/get/create/update/delete` | |
12401267
| **ScheduledSessions — lifecycle** | ✅ suspend/resume/trigger/runs handlers |`ScheduledSessionAPI.{Suspend,Resume,Trigger,Runs}` |`scheduled-session suspend/resume/trigger/runs` | |
12411268
| **Generic proxy — project config** | ✅ proxy plugin (`plugins/proxy`); forwards non-`/api/ambient/` paths to `BACKEND_URL` | n/a | 🔲 raw HTTP fallback | Permissions, keys, MCP servers, secrets, feature flags |
12421269
| **Generic proxy — repo operations** | ✅ proxy plugin | n/a | 🔲 raw HTTP fallback | Tree, blob, branches, seed, forks |
12431270
| **Generic proxy — auth integrations** | ✅ proxy plugin | n/a | n/a | GitHub/GitLab/Google/Jira/Gerrit/CodeRabbit/MCP OAuth flows |
12441271
| **Generic proxy — cluster/platform** | ✅ proxy plugin | n/a | 🔲 `acpctl version`, `acpctl cluster-info` | cluster-info, version, health, LDAP, OOTB workflows |
12451272
| **Declarative apply** | n/a | uses SDK |`apply -f`, `apply -k` | Upsert semantics; supports inbox seeding |
1246-
| **Declarative apply — Credential kind** | n/a | 🔲 | 🔲 | Planned; global resource; token sourced from env var in YAML |
1273+
| **Declarative apply — Credential kind** | n/a | uses SDK |`apply -f credential.yaml` | Global resource; token sourced from env var in YAML |
12471274
| **Declarative apply — ScheduledSession kind** | n/a | 🔲 | 🔲 | Planned; schedule and agent reference in YAML |
12481275

12491276
### Labels/Annotations — SDK Ergonomics Gap
@@ -1259,7 +1286,7 @@ All Kinds with `labels`/`annotations` store them as JSON strings in the DB (`*st
12591286
| Command | Status | Path to close |
12601287
|---|---|---|
12611288
| Project/Agent/Session label subcommands | 🔲 no `acpctl label`/`acpctl annotate` | add typed label helpers to SDK first, then CLI |
1262-
| `acpctl credential bind` | 🔲 not implemented | depends on global credential path migration |
1289+
| `acpctl credential bind` | 🔲 not implemented | `POST /role_bindings` with `scope=credential`; global migration complete, command not yet written |
12631290
| Session workspace/files/git/repos subcommands | 🔲 planned | see Session Operations table above |
12641291

12651292

specs/security/security.spec.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,11 @@ API endpoints, and provider enum definitions, see the
270270

271271
### Requirement: Credential Access via RoleBindings
272272

273-
Credentials SHALL be global resources. Access SHALL be granted via RoleBindings with
274-
`credential` scope — bind a credential to a Project to make it available to all agents in
275-
that Project. At session start, the resolver SHALL list all credentials the session's
276-
Project has access to (via RoleBindings) and return the matching credential for each
277-
requested provider.
273+
Credentials SHALL be global resources. Access SHALL be granted via a RoleBinding with
274+
`scope=credential`, `credential_id=<cred>`, and `project_id=<project>``user_id` is
275+
null because the grant is project-level, not user-specific. At session start, the resolver
276+
SHALL list all `scope=credential` RoleBindings where `project_id` matches the session's
277+
project and return the matching credential for each requested provider.
278278

279279
This follows the Kubernetes resource model:
280280

@@ -386,8 +386,9 @@ These endpoints MUST validate the caller is cluster-internal to prevent token ex
386386

387387
| Decision | Rationale |
388388
|----------|-----------|
389-
| Agent ownership via RBAC, not a hardcoded FK | Ownership is expressed as a RoleBinding (`scope=agent`, `scope_id=agent_id`). Enables multi-owner and delegated ownership consistently across all Kinds. |
390-
| Credential is global, bound via RoleBindings | Credentials are global resources. Access is granted by binding a credential to a Project via a RoleBinding with `credential` scope. A single credential can be shared across multiple Projects without duplication. |
389+
| Agent ownership via RBAC, not a hardcoded FK | Ownership is expressed as a RoleBinding (`scope=agent`, `agent_id=<id>`, `user_id=<owner>`). Enables multi-owner and delegated ownership consistently across all Kinds. |
390+
| Credential is global, bound via RoleBindings | Credentials are global resources. Access is granted by a RoleBinding with `scope=credential`, `credential_id=<cred>`, `project_id=<project>`, `user_id=NULL`. A single credential can be shared across multiple Projects without duplication. |
391+
| RoleBinding uses typed nullable FKs, not a polymorphic scope_id string | Each FK (`user_id`, `project_id`, `agent_id`, `session_id`, `credential_id`) is nullable. `scope` discriminates which FK identifies the bound resource. Enables real referential integrity constraints; `user_id` is null for non-user grants (e.g. project-level credential access). |
391392
| Credential token is write-only | Prevents token exfiltration via the standard REST API. Raw token only surfaced to runners via the runtime credentials path, not to end users. |
392393
| Five-scope RBAC (`global`, `project`, `agent`, `session`, `credential`) | Credential access is explicit via RoleBindings with `credential` scope. Enables cross-project sharing without credential duplication. |
393394
| Credential CRUD governed by credential roles | `credential:owner` manages CRUD and bindings. `credential:viewer` reads metadata. Self-service: users create their own credentials without admin intervention. |

0 commit comments

Comments
 (0)