docs(rules): document AgentOwnershipGuard, RBAC revocation invariant, and universal SSE signing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,9 +77,9 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Agent-only (JWT-authoritative, intentionally flat)
|
||||
|
||||
- `AgentRegistrationController` — POST `/register` (requires `environmentId` in body; 400 if missing), POST `/{id}/refresh` (rejects tokens with no `env` claim), POST `/{id}/heartbeat` (env from body preferred, JWT fallback; 400 if neither), POST `/{id}/deregister`.
|
||||
- `AgentSseController` — GET `/{id}/events` (SSE connection).
|
||||
- `AgentCommandController` — POST `/{agentId}/commands`, POST `/groups/{group}/commands`, POST `/commands` (broadcast), POST `/{agentId}/commands/{commandId}/ack`, POST `/{agentId}/replay`.
|
||||
- `AgentRegistrationController` — POST `/register` (requires `environmentId` in body; 400 if missing), POST `/{id}/refresh` (rejects tokens with no `env` claim), POST `/{id}/heartbeat` (env from body preferred, JWT fallback; 400 if neither), POST `/{id}/deregister`. `heartbeat` and `deregister` enforce `AgentOwnershipGuard.requireAgentOwnership(...)` as their first statement. `register` (bootstrap-token-protected) and `refresh` are not gated by the guard.
|
||||
- `AgentSseController` — GET `/{id}/events` (SSE connection). JWT subject must equal path `id` — enforced via `AgentOwnershipGuard.requireAgentOwnership(...)` BEFORE any registry side effect. Auto-heal branch is safe to run unconditionally because the guard rejected null/mismatched subjects with 403 first.
|
||||
- `AgentCommandController` — POST `/{agentId}/commands`, POST `/groups/{group}/commands`, POST `/commands` (broadcast), POST `/{agentId}/commands/{commandId}/ack`, POST `/{agentId}/replay`. `acknowledgeCommand` enforces `AgentOwnershipGuard.requireAgentOwnership(...)`. The operator fan-out endpoints (`POST /{id}/commands`, `POST /groups/{group}/commands`, broadcast `POST /commands`) and `replayExchange` are not gated by the guard — they're operator-role endpoints with their own RBAC checks.
|
||||
- `AgentConfigController` — GET `/api/v1/agents/config`. Agent-authoritative config read: resolves (app, env) from JWT subject → registry (registry miss falls back to JWT env claim; no registry entry → 404 since application can't be derived).
|
||||
|
||||
### Ingestion (agent-only, JWT-authoritative)
|
||||
@@ -96,9 +96,9 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Admin (cross-env, flat)
|
||||
|
||||
- `UserAdminController` — CRUD `/api/v1/admin/users`, POST `/{id}/roles`, POST `/{id}/set-password`.
|
||||
- `RoleAdminController` — CRUD `/api/v1/admin/roles`.
|
||||
- `GroupAdminController` — CRUD `/api/v1/admin/groups`.
|
||||
- `UserAdminController` — CRUD `/api/v1/admin/users`, POST `/{id}/roles`, POST `/{id}/set-password`. Mutations (`assignRoleToUser`, `removeRoleFromUser`, `addUserToGroup`, `removeUserFromGroup`, `deleteUser`, `resetPassword`) bump `users.token_revoked_before` (using `Instant.now().plusMillis(1)`) so outstanding access AND refresh tokens are invalidated.
|
||||
- `RoleAdminController` — CRUD `/api/v1/admin/roles`. `deleteRole` bulk-bumps `users.token_revoked_before` for every user with that role (uses `RbacService.getEffectivePrincipalsForRole(roleId)`).
|
||||
- `GroupAdminController` — CRUD `/api/v1/admin/groups`. Group mutations (`deleteGroup`, `assignRoleToGroup`, `removeRoleFromGroup`) bulk-bump `users.token_revoked_before` for every user in the affected group (uses `RbacService.getEffectiveUserIdsForGroup(groupId)`).
|
||||
- `OidcConfigAdminController` — GET/POST `/api/v1/admin/oidc`, POST `/test`.
|
||||
- `OutboundConnectionAdminController` — `/api/v1/admin/outbound-connections`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/test` / GET `{id}/usage`. RBAC: list/get/usage ADMIN|OPERATOR; mutations + test ADMIN.
|
||||
- `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
|
||||
@@ -115,7 +115,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Auth (flat)
|
||||
|
||||
- `UiAuthController` — `/api/v1/auth` (login, refresh, me, logout). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. `POST /logout` is permitAll — controller resolves the user from the access token if present, bumps `users.token_revoked_before = now().plusMillis(1)` to invalidate all outstanding refresh + access tokens (enforced by `JwtAuthenticationFilter`), audits `AuditCategory.AUTH / logout`, returns 204. Best-effort: 204 also when called without a token so the SPA's logout never fails on already-expired sessions. The +1ms guards against same-millisecond races (JWT `iat` is ms-quantised, filter check is strict `isBefore`).
|
||||
- `UiAuthController` — `/api/v1/auth` (login, refresh, me, logout). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. `POST /logout` is permitAll — controller resolves the user from the access token if present, bumps `users.token_revoked_before = now().plusMillis(1)` to invalidate all outstanding refresh + access tokens (enforced by `JwtAuthenticationFilter`), audits `AuditCategory.AUTH / logout`, returns 204. Best-effort: 204 also when called without a token so the SPA's logout never fails on already-expired sessions. The +1ms guards against same-millisecond races (JWT `iat` is ms-quantised, filter check is strict `isBefore`). `refresh` re-reads effective roles from the DB (`rbacService.getSystemRoleNames(userId)`) — never preserves the refresh token's `roles` claim — and rejects refresh tokens whose `iat` is before `users.token_revoked_before` (mirrors `JwtAuthenticationFilter.tryInternalToken`'s access-path check). VIEWER is the empty-roles default.
|
||||
- `OidcAuthController` — `/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
|
||||
- `AuthCapabilitiesController` — `GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
|
||||
|
||||
@@ -185,7 +185,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
- `SseConnectionManager` — manages per-agent SSE connections, delivers commands
|
||||
- `AgentLifecycleMonitor` — @Scheduled 10s, LIVE->STALE->DEAD transitions
|
||||
- `SsePayloadSigner` — Ed25519 signs SSE payloads for agent verification
|
||||
- `SsePayloadSigner` — Ed25519 signs SSE payloads for agent verification. Throws `IllegalArgumentException` on null/empty/blank/non-object input — never returns an unsigned payload. Input must be Jackson-canonical JSON (build via `objectMapper.writeValueAsString(...)`) so the agent's parse-remove-signature-reserialize verification flow byte-matches the signed bytes.
|
||||
|
||||
## retention/ — JAR cleanup
|
||||
|
||||
|
||||
49
.claude/rules/security.md
Normal file
49
.claude/rules/security.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Security invariants
|
||||
|
||||
## Agent self-service endpoint ownership
|
||||
|
||||
Every endpoint under `/api/v1/agents/{id}/...` that is intended for agent self-service
|
||||
must enforce `AgentOwnershipGuard.requireAgentOwnership(id, httpRequest)` as its FIRST
|
||||
statement, before any registry lookup, audit log, or other side effect.
|
||||
|
||||
Currently enforced on: SSE `events`, heartbeat, deregister, command-ack.
|
||||
Currently NOT enforced (out of scope): `register` (bootstrap-token-gated), `refresh`,
|
||||
`replayExchange`. Adding the guard there is a follow-up.
|
||||
|
||||
## RBAC revocation invariant
|
||||
|
||||
Any code path that mutates the effective role set of one or more users — including:
|
||||
- `user_roles` (direct role assignment)
|
||||
- `user_groups` (group membership)
|
||||
- `group_roles` (group's role list)
|
||||
- Role deletion
|
||||
- User deletion
|
||||
- Password reset
|
||||
|
||||
— MUST bump `users.token_revoked_before` for each affected user (use
|
||||
`Instant.now().plusMillis(1)` to guard against same-millisecond races with token
|
||||
issuance, since JWT `iat` is millisecond-quantized).
|
||||
|
||||
`UiAuthController.refresh` and `JwtAuthenticationFilter.tryInternalToken` both
|
||||
consult `users.token_revoked_before`; without the bump on RBAC mutation, refresh-
|
||||
token role-retention privesc is possible (Vuln 2 of the 2026-04-29 review).
|
||||
|
||||
`ClaimMappingAdminController` mutations are EXEMPT — they affect role assignment at
|
||||
the next OIDC login (via `OidcAuthController.applyClaimMappings`), not currently-
|
||||
issued tokens.
|
||||
|
||||
## SSE command signing
|
||||
|
||||
Every command emitted via `SseConnectionManager.onCommandReady` is signed with
|
||||
Ed25519 by `SsePayloadSigner`. The signer THROWS on null/empty/blank/non-object
|
||||
input — there is no silent unsigned fallback.
|
||||
|
||||
Emit-site contract: build the payload via `objectMapper.writeValueAsString(...)`
|
||||
so the input is in Jackson's canonical (no-whitespace, insertion-order) form.
|
||||
The agent verifies by parsing the wire JSON, removing the `signature` field,
|
||||
re-serializing, and Ed25519-verifying against the resulting bytes — this requires
|
||||
byte-equivalence between the server-signed bytes and the agent-reconstructed bytes.
|
||||
|
||||
The `requireSignedCommands` capability bit on `AgentInfo` (set from
|
||||
`AgentRegistrationRequest`) is currently informational only — server-side gating
|
||||
on this bit is a future release per the agent team's migration sequence step 4.
|
||||
Reference in New Issue
Block a user