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:
hsiegeln
2026-04-29 11:03:19 +02:00
parent c8aab1f90e
commit ad4be0d7a6
2 changed files with 57 additions and 8 deletions

View File

@@ -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
View 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.