Skip to content

feat(server): add user-owned DefenseClaw access#250

Open
vineethsai7 wants to merge 11 commits into
agentcontrol:feature/68338-add-defense-claw-evaluatorfrom
vineethsai7:codex/defenseclaw-api-key-rbac
Open

feat(server): add user-owned DefenseClaw access#250
vineethsai7 wants to merge 11 commits into
agentcontrol:feature/68338-add-defense-claw-evaluatorfrom
vineethsai7:codex/defenseclaw-api-key-rbac

Conversation

@vineethsai7

@vineethsai7 vineethsai7 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Stack note: the feature work is stacked on #249 through 265f0bd. #249 now also contains the CI-only dc3f0d3 fork-secret guard; the equivalent guard is included here. Merge #249 first; the review-only feature range is 265f0bd..98a69ba. GitHub cannot select the fork-owned #249 head as an upstream base branch, so the combined Files view also contains #249 until that dependency merges.

Problem

DefenseClaw needs one durable user identity for centralized rule assignment and enforcement history, plus one rotatable credential that works in both the SDK and UI. The prior implementation exposed the underlying one-to-many credential model, attached bucket grants to individual keys, and placed DefenseClaw activity under an internal synchronization agent. That made rotation risky, made the normal UI agent-centric, and did not match the intended one-key-per-user product contract.

Solution

One user, one active credential

  • Creating a user atomically issues their first API key and returns the secret once.
  • A user can have at most one enabled, unrevoked credential. The database enforces that invariant with a partial unique index.
  • Issue, Rotate, and Revoke are the supported lifecycle operations; the UI does not offer “Create another key.”
  • Rotation locks the user row, revokes the old key, flushes that state, then issues the replacement in the same transaction.
  • Revoked credentials remain as audit records but cannot authenticate.
  • The same active key authenticates the DefenseClaw SDK and the Agent Control UI.

User-owned controls and history

  • Rule-bucket grants belong to access_user_id, not a credential, so they survive rotation and revocation.
  • Enforcement events remain authorized and queried by access_user_id; the credential ID is retained only as an audit/filter dimension.
  • Administrators see every member’s enforcement spans with the server-resolved user name and stable user ID; member responses strip the reserved owner field.
  • Members see only their own enforcement spans, limited to controls currently granted to them.
  • Exact input is displayed when DefenseClaw sent unredacted content; redacted and metadata-only events are labeled explicitly.

DefenseClaw-first navigation

  • Authenticated users land on top-level Controls and Monitor pages.
  • Administrators also see Access management.
  • The internal defenseclaw-policy-sync SDK identity remains available to the implementation but is hidden from normal authenticated navigation.
  • Generic/no-auth Agent Control deployments retain the existing agent inventory experience.
flowchart LR
  Admin["Administrator"] --> Create["Create user + first key"]
  Create --> User["User identity"]
  User --> Grants["Assigned rule buckets"]
  User --> History["Complete enforcement history"]
  User --> Key["One active API key"]
  Key --> SDK["DefenseClaw SDK"]
  Key --> UI["Agent Control UI"]
  Key --> Rotate["Rotate: revoke old + issue replacement"]
  Rotate --> User
  SDK --> History
  History --> AdminView["Admin: all namespace members"]
  History --> MemberView["Member: own granted events only"]
Loading

Security boundaries

  • Bootstrap administrator keys remain environment-provisioned and namespace-wide; administrators cannot receive bucket grants.
  • Raw API-key secrets are returned once and stored only as SHA-256 hashes.
  • Runtime tokens re-resolve credential and user state on every request, so disable, revoke, expiry, and grant changes take effect immediately.
  • Cookie-authenticated state changes enforce normalized same-origin/explicit-CORS checks; authenticated wildcard CORS is rejected.
  • User and grant mutations serialize on the user row; integrity races become 409 responses rather than leaked 500s.
  • Soft-deleting a control removes grants atomically.
  • UI query state is cleared across login, logout, and 401 transitions so exact spans cannot survive an account switch in browser memory.

What changed

Area Change
server/.../models.py, Alembic One-live-key constraint, user-owned grants, event credential provenance, user/credential indexes
server/.../services/access.py Credential resolution and active controls through the owning user
server/.../endpoints/admin_access.py Atomic create+key, issue/rotate/revoke, user grant API, locking and conflicts
Server observability paths Preserve api_key_id provenance, resolve trusted administrator attribution, and strip owner metadata from member responses
Server authorization/endpoints Admin/member write boundaries, scoped reads, owned event ingest/query, idempotent SDK registration
ui/.../access-management One credential card, one-time create/issue/rotate secret, revoke and user-owned grants
ui/pages/controls.tsx, monitor.tsx DefenseClaw-first top-level authenticated navigation
UI auth/control flows Cross-account cache clearing, paginated bucket loading, direct-URL write gating
README files One-key lifecycle and user-owned grant/history operator contract

Breaking changes

  • This unmerged first-release schema now enforces one live API key per user and stores grants at the user level.
  • Creating a user returns the initial API key and secret in the same response.
  • Key grants endpoints are replaced by user control-grant endpoints.
  • AGENT_CONTROL_API_KEYS is rejected when authentication is enabled. Use bootstrap AGENT_CONTROL_ADMIN_API_KEYS plus database-managed user credentials.
  • Authenticated deployments must configure an explicit AGENT_CONTROL_CORS_ORIGINS allowlist; wildcard credentialed CORS is rejected at startup.

Open issues / follow-ups

None.

Test plan

  • Final GitHub Actions on 98a69ba7 successful, 0 failed, 0 pending; merge state clean.
  • make lint — passed.
  • make typecheck — passed.
  • make -C server test868 passed with coverage XML generated.
  • cd ui && pnpm lint && pnpm typecheck && pnpm prettify:check && pnpm build — passed.
  • cd ui && CI=1 pnpm exec playwright test --workers=2156 passed against the production build.
  • Empty PostgreSQL migration: upgrade prerequisite → upgrade head → inspect tables, FKs, columns, and partial unique index → downgrade this feature revision → upgrade head — passed.
  • Focused rotation/provenance tests — passed, including old-key invalidation, history/grant retention, and credential audit attribution.
  • Focused scoped-deletion regression — 2 passed; ungranted IDs return 404 before deletion and granted IDs remain deletable.
  • CodeRabbit review/fix/review — the clean-snapshot pass identified a missing defense-in-depth grant check on control deletion; the route now rejects ungranted IDs before locking and has focused regression coverage. The earlier temporary untracked coverage report was deleted before commit and never pushed.
  • Claude Opus high-effort review — runtime, authorization, concurrency, and UX contracts passed. Its migration-history suggestion was intentionally not applied because this is an unmerged first-release schema.
  • Live DefenseClaw SDK sync — one member key registered the internal sync identity and received 12 effective controls with zero delivery drops.
  • Live rotation — old credential was revoked, one active credential remained, and 12 grants / 26 owned events survived with two credential audit records.
  • Refreshed live browser scope — four real DefenseClaw denies were owned by defenseclaw-demo; admin Monitor showed the server-resolved user name and ID on every finding, while the member API returned the same four owned events with no owner-attribution metadata.
  • Live privacy — admin redacted span retained the exact rule/bucket metadata; member exact span showed the full prompt only for an event produced while DefenseClaw global redaction was explicitly disabled. Redaction was restored afterward.

@vineethsai7 vineethsai7 changed the title feat(auth): add scoped API-key RBAC for DefenseClaw feat(auth): add user-owned DefenseClaw access Jul 9, 2026
@vineethsai7 vineethsai7 marked this pull request as ready for review July 9, 2026 23:44

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b8571bd54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to +109
Index(
"uq_api_key_credentials_one_live_per_user",
"namespace_key",
"user_id",
unique=True,
postgresql_where=text("enabled IS TRUE AND revoked_at IS NULL"),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the SQLite predicate for live-key uniqueness

In SQLite deployments/tests, postgresql_where is ignored, so this becomes an unconditional unique index on (namespace_key, user_id). After a user rotates or revokes a key, the historical credential row remains in api_key_credentials, and inserting the replacement for the same user will violate this index even though the old row is no longer live. Add the matching sqlite_where predicate here and in the Alembic index so SQLite preserves credential history like Postgres.

Useful? React with 👍 / 👎.

Comment on lines +23 to +25
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: accessQueryKeys.users });
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached grants after role changes

When a member is promoted to admin, the server deletes that user's control grants, but this mutation only invalidates the users list. If the operator had already expanded that user and later changes them back to member in the same session, useUserControlGrants can reuse the stale pre-promotion grant cache and show assignments that no longer exist on the server, with the Save button disabled because the stale signature matches. Invalidate accessQueryKeys.controlGrants(userId) when the role changes (or after every user update).

Useful? React with 👍 / 👎.

@vineethsai7 vineethsai7 changed the title feat(auth): add user-owned DefenseClaw access feat(server): add user-owned DefenseClaw access Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant