security-architect
Projeta quem pode chamar o quê: o mecanismo, as authorities, a filter chain e as respostas de falha.
32-seguranca.md, bloco a bloco
Um parcial real: UC-006-anonymize-customer, no bank-app, em trechos. Passe o mouse ou clique num bloco para ver o que a skill fixa ali. As seções do arquivo também são chips: clique em um para ir até ela.
UC-006 · Security
Partial ofdocs/use-cases/UC-006-anonymize-customer/. Owner:security-architect. Inherits the endpoints from30-rest.mdand the domain from10-dominio.md— doesn't redecide them. Contains no code. Form references in.claude/skills/security-architect/templates/. Rules applied, never reproduced:@.claude/rules/authorization.md,@.claude/rules/api-rest.md§ Errors,@.claude/rules/transport-security.md§ HSTS.
| Field | Value |
|---|---|
| Mother spec | 00-caso-de-uso.md — Access: only callers with the DATA_PROTECTION_OFFICER role; JWT issued by the bank's identity provider |
| Why this partial exists | Access names a role, and the project has no SecurityFilterChain yet — first secured case, so this partial also owns the project-wide setup and every endpoint already on disk |
| Stack | servlet (spring-boot-starter-webmvc in pom.xml, Boot 4.1.1) |
| Wiring package | infrastructure.config.security (blueprint packages.map, entry infrastructure.config) |
| Divergences from other partials | none. 00-caso-de-uso.md listed "caller not identified, or without the role" as one situation; here it is two statuses, 401 and 403 (§ 6) |
## 1 · Access per endpoint
| Method | Path | operationId | Rule | Enforced in | Meta-annotation | No credential | Without authority | With authority |
|---|---|---|---|---|---|---|---|---|
DELETE | /api/v1/customers/{customerId} | anonymizeCustomer | role DATA_PROTECTION_OFFICER | chain: request rule DELETE /api/v1/customers/* → hasRole(Roles.DATA_PROTECTION_OFFICER) | — | 401 | 403 | 204 |
POST | /api/v1/customers | createCustomer (UC-001) | public | chain: permitAll | — | 201 | — | 201 |
GET | /api/v1/customers/{customerId} | getCustomer (UC-003) | authenticated (changed, user's decision) | chain: deny by default (anyRequest().authenticated()) | — | 401 | — (no role) | 200 |
The role check is a request rule in the chain, not method security: one endpoint, one role, matched by method and path. No @PreAuthorize in the project yet, so no MethodSecurityConfig and no method-security denial to translate — a later case that needs one adds them. A 403 is not replaced by 404: the resource's existence is not sensitive (the DELETE answers 204 for an unknown id anyway, 30-rest.md).
Public rows of the project after this case (each with its reason):
| Path | Reason |
|---|---|
POST /api/v1/customers | registration of a new customer — UC-001's decision, kept by the user in this run. Protected only by Idempotency-Key and its own validation |
/v3/api-docs/, /swagger-ui/, /swagger-ui.html | OpenAPI document and UI — public by the rule's default; the user kept it |
/actuator/health/**, /actuator/info | liveness and readiness probes of the platform; details only for OPERATOR |
Request rules, specific to general: docs permitAll → health and info permitAll → /actuator/** hasRole(OPERATOR) → POST /api/v1/customers permitAll → DELETE /api/v1/customers/* hasRole(DATA_PROTECTION_OFFICER) → anyRequest().authenticated().
## 2 · Mechanism
| Decision | Value | Why |
|---|---|---|
| Mechanism | OAuth2 resource server, signed token (JWT) | references/mechanism-decision-matrix.md § 2 row 3: the bank's identity provider issues signed tokens (00-caso-de-uso.md, Access) |
| Inherited? | no — first secured case | no security starter in pom.xml, no chain on disk, no earlier 32-seguranca.md |
| Exemplars | SecurityConfig.java.example + JwtResourceServerConfig.java.example | — |
Rejected, one line each: opaque token — the provider issues JWTs, and introspection adds a call per request. Users owned by the application — the bank's provider owns the accounts. API key — no machine caller in this case.
## 3 · Authorities
| Fact | Value | Source |
|---|---|---|
| Roles in the token | flat top-level roles array → prefix ROLE_ | asked in this run |
| Groups in the token | not used | no Access names a group |
| Scopes | scope → prefix SCOPE_ (framework default, kept) | — |
| Audience expected | bank-app | asked in this run |
| Principal name | sub | decided — the subject is the stable id; no actor travels in this case's command |
| Role catalog | Roles.DATA_PROTECTION_OFFICER, Roles.OPERATOR — NEW, in infrastructure.config.security | first roles of the project |
## 4 · Project-wide setup
| Component | State | Exemplar | Decision |
|---|---|---|---|
SecurityConfig + SecurityAccessProperties | NEW | SecurityConfig.java.example | stateless; CSRF off (bearer token, no cookie authenticates); default headers kept — HSTS included; request rules of § 1; deny by default |
JwtResourceServerConfig + TokenAuthoritiesProperties + ClaimPathAuthoritiesConverter | NEW | JwtResourceServerConfig.java.example | issuer and audience validated by Boot's issuer-uri + audiences; converter reads roles → ROLE_, scope → SCOPE_; no JwtDecoder bean of our own |
ProblemDetailAuthenticationEntryPoint, ProblemDetailAccessDeniedHandler | NEW | ProblemDetailSecurityHandlers.java.example | 401 with WWW-Authenticate: Bearer, 403, Problem Details with traceId |
SecurityExceptionHandler | not needed | SecurityExceptionHandler.java.example | no method security → no AccessDeniedException reaches MVC; added by the first case with @PreAuthorize |
MethodSecurityConfig | not needed | MethodSecurityAnnotations.java.example | same reason |
Roles | NEW | MethodSecurityAnnotations.java.example (constants only) | DATA_PROTECTION_OFFICER, OPERATOR |
SecurityAuditListener | NEW | SecurityAuditListener.java.example | WARN line + security.authentication.failures{reason}, security.authorization.denied{kind}; never the token |
OpenApiSecurityConfig | NEW | OpenApiSecurityConfig.java.example | bearer scheme, required globally; CreateCustomerOpenApiDocs overrides with an empty requirement (public) |
HstsHeaderFilter | REUSE — becomes inert | — | @ConditionalOnMissingClass(SecurityFilterChain) drops it when the starter arrives; Spring Security's HstsHeaderWriter is the single writer, same value max-age=31536000 ; includeSubDomains, secure requests only (@.claude/rules/transport-security.md § HSTS). The chain must not call headers().disable() nor httpStrictTransportSecurity().disable() |
| CORS | NEW | SecurityConfig.java.example | origins from app.security.cors.allowed-origins (CORS_ALLOWED_ORIGINS), empty → no mapping; methods GET, POST, DELETE; headers Authorization, Content-Type, Idempotency-Key; no credentials (bearer, not cookies) |
| Actuator | CHANGE | — | /actuator/metrics and health details → OPERATOR (@.claude/rules/authorization.md § Default access; user confirmed); exposure list unchanged (health,info,metrics) |
## 5 · Ownership
none — Access is a role, not the owner. No actor travels in AnonymizeCustomerCommand.
## 6 · Failure responses
| Situation | Status | Header | Body |
|---|---|---|---|
| no token, malformed, expired, wrong issuer or audience — any protected path | 401 | WWW-Authenticate: Bearer | Problem Details, no reason in detail |
token without ROLE_DATA_PROTECTION_OFFICER on anonymizeCustomer | 403 | — | Problem Details |
token without ROLE_OPERATOR on /actuator/metrics or other non-public actuator | 403 | — | Problem Details |
No errorCode on 401/403: they are not domain errors. The 401 and 403 rows of 30-rest.md § 3 are these.
## 7 · Schema requirements
none — the identity provider holds the credentials.
## 8 · Declared dependencies
| Dependency | Version | Why |
|---|---|---|
org.springframework.boot:spring-boot-starter-security | (managed by the Boot parent) | filter chain |
org.springframework.boot:spring-boot-starter-security-oauth2-resource-server | (managed by the Boot parent) | JWT decoding and validation — the Boot 4 name (Initializr id oauth2-resource-server) |
The test support (spring-boot-starter-security-test, spring-boot-starter-security-oauth2-resource-server-test) is declared by 40-testes.md § 5.
## 9 · Configuration
| Property | Value | Secret? |
|---|---|---|
spring.security.oauth2.resourceserver.jwt.issuer-uri | ${OAUTH2_ISSUER_URI:http://localhost:8180/realms/bank} | no — the default points at the local Keycloak deferred in § 11 |
spring.security.oauth2.resourceserver.jwt.audiences | ${OAUTH2_AUDIENCE:bank-app} | no |
app.security.api-docs-public | true | no |
app.security.actuator-role | OPERATOR | no |
app.security.cors.allowed-origins | ${CORS_ALLOWED_ORIGINS:} — comma-separated, empty by default | no |
app.security.token.principal-claim | sub | no |
app.security.token.roles-claims | roles | no |
app.security.token.scopes-claims | scope | no |
management.endpoint.health.roles | OPERATOR (show-details: when-authorized already set) | no |
One spring: root and one app: root in application.yml — merged, never a second root. Shape in templates/application-security.yml.example. OAUTH2_ISSUER_URI, OAUTH2_AUDIENCE and CORS_ALLOWED_ORIGINS go into .env.example with their defaults. With issuer-uri, Boot resolves the provider's keys lazily: the application starts without a reachable provider, and the first protected call answers 401 until one exists. The test profile keeps the chain on and uses the test support's jwt() request post-processor — never a live provider.
## 10 · Contract test cases
| Endpoint | Case | Expected |
|---|---|---|
anonymizeCustomer | no token | 401, WWW-Authenticate present, use case never called |
anonymizeCustomer | token with ROLE_OPERATOR only | 403, use case never called |
anonymizeCustomer | token with ROLE_DATA_PROTECTION_OFFICER | 204 |
createCustomer (UC-001, impact) | no token | 201 — public proven public |
getCustomer (UC-003, impact) | no token · any token | 401 · 200 |
/actuator/health | no token | 200 |
/v3/api-docs | no token | 200 |
/actuator/metrics | no token · token without ROLE_OPERATOR · token with it | 401 · 403 · 200 |
CORS preflight OPTIONS /api/v1/customers/{id} | Origin listed · Origin not listed | allowed headers present · 403 |
| HSTS | secure request (X-Forwarded-Proto: https from a trusted proxy) | Strict-Transport-Security: max-age=31536000 ; includeSubDomains, exactly one header |
ClaimPathAuthoritiesConverter | token with roles: ["DATA_PROTECTION_OFFICER"] | authority ROLE_DATA_PROTECTION_OFFICER; no roles claim → no role authority |
## 11 · Deferred and requirements to other partials
| Decided | Missing | Norm | Intended owner |
|---|---|---|---|
the local issuer default is http://localhost:8180/realms/bank | a Keycloak service in docker-compose.yml with a bank realm, audience bank-app, a flat roles claim mapper, and test users holding DATA_PROTECTION_OFFICER and OPERATOR — until it exists, a local ./mvnw spring-boot:run answers 401 on every protected call | @.claude/rules/authorization.md § Mechanism (a provider the tests never call, a local one for the developer) | backlog — user's decision ("local Keycloak later"); consolidation assigns the BL-NN |
Requirements to other partials: none — no actor, no credential store.
## Design patterns
none — one claims source per kind of authority, no growing branch over authentication types.
## Impact on approved use cases
| Case | Endpoint | Before | After | Decision |
|---|---|---|---|---|
UC-001-create-customer | POST /api/v1/customers | public (no chain existed) | public — permitAll with its reason | kept public — asked in this run |
UC-001-create-customer | CreateCustomerOpenApiDocs | no security requirement | empty @SecurityRequirements — overrides the global bearer requirement | the document must say the operation is public |
UC-003-get-customer | GET /api/v1/customers/{customerId} | public — accepted risk: anyone with an id read personal data | authenticated (any valid token, no role) | protected from now on — asked in this run. UC-003's accepted risk narrows to any authenticated caller of the bank's provider |
UC-003-get-customer | GetCustomerOpenApiDocs | no 401 | gains a 401 @ApiResponse | the operation can now answer it |
UC-001, UC-003 | CustomerControllerTest | ran with no filter chain | imports SecurityConfig and JwtResourceServerConfig; getCustomer cases send a jwt(); createCustomer cases send none; new cases of § 10 | listed for test-architect |
| all | Actuator /actuator/metrics | public (no chain existed) | OPERATOR only | @.claude/rules/authorization.md § Default access, user confirmed |
## Implementation order
- The dependencies of § 8
Roles,ProblemDetailAuthenticationEntryPoint,ProblemDetailAccessDeniedHandlerJwtResourceServerConfig(+ properties, converter), thenSecurityConfig(+ properties),SecurityAuditListener,OpenApiSecurityConfig- OpenAPI changes on
CreateCustomerOpenApiDocsandGetCustomerOpenApiDocs— after the REST block - Properties in
application.yml, variables in.env.example - Tests —
40-testes.md
O que lê, escreve e recusa
O contrato da própria skill, do SKILL.md, no texto original.
Class
design — the territory is skill_classes.design in @.claude/schemas/extensions.json, and ArchHook.java guard enforces it. Writes inside the use case folder and nothing else; a SecurityConfig written under src/ is refused with exit 2.
Reads
docs/use-cases/UC-NNN-<slug>/00-use-case.md, 10-domain.md, and 30-rest.md or 31-graphql.md (mandatory — stops without the one the trigger names), earlier cases' 32-security.md, pom.xml (or the Gradle build), src/main/resources/application*.yml, the security wiring on disk, the active blueprint's packages.map, @.claude/rules/authorization.md, @.claude/rules/api-rest.md, @.claude/rules/api-graphql.md (§ Access, when the trigger names GraphQL), @.claude/rules/architecture-ddd.md, @.claude/rules/logging.md, @.claude/rules/observability.md, and references/.
Writes
docs/use-cases/UC-NNN-<slug>/32-security.md. Nothing else.
Owns
the project's authentication mechanism, the claims-to-authorities mapping and the role and group catalog, each endpoint's access rule and where it is enforced, the permitAll list and its reasons, the project-wide filter chain, the 401 and 403 responses, CORS, and the OpenAPI and Actuator exposure.
Does not decide
the use case boundary or its Access fact (00-use-case.md), the actor field, the owner field or the ownership invariant (10-domain.md), endpoints and domain error rows (30-rest.md), tables and columns (20-persistence.md), or the test shape (40-tests.md). Does not write Java, pom.xml or application.yml — the executor does, from this partial. Doesn't touch .claude/rules/**. Does not design reactive (WebFlux) security.
Fonte: SKILL.md § Contract, no nerviz e4cae27.
O que o guard faz com ela
design é a classe da skill. O ArchHook abre uma fase quando a skill é chamada e confere cada escrita contra o território da classe. O texto à direita é o do próprio hook.
write_allow da classe design
- ✓
docs/use-cases/UC-*/** - ✓
docs/use-cases/BACKLOG.md src/**: migrations e classes são do executordocker-compose.yml: arquivo de outra classecaso de uso já aprovado: as specs ficam congeladas
A recusa sai com código 2 e a mensagem volta para o modelo.
PreToolUse Skill(security-architect) audit call exit 0 guard call exit 0 # a skill of another class replaces the phase # write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md
PreToolUse Write(docs/use-cases/UC-005-record-kyc-verdict/32-seguranca.md) guard write exit 0 PostToolUse audit file exit 0
PreToolUse Write(docker-compose.yml) guard write exit 2 ❌ `security-architect` is class `design` — docker-compose.yml is outside its territory. write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md Put the content in the spec as a code block, or finish the run and invoke the skill that owns this path. If the path is legitimately this skill's, widen skill_classes.design in .claude/schemas/extensions.json — retrying will not help.
# UC-005-record-kyc-verdict-spec.md: status: approved PreToolUse Edit(docs/use-cases/UC-005-record-kyc-verdict/32-seguranca.md) guard write exit 2 ❌ UC-005-record-kyc-verdict is approved — its specs are immutable. Record the change in the new use case's "Impact on approved use cases" section, and the one-line entry in UC-005-record-kyc-verdict/CHANGELOG.md, which stays writable.
Onde ela fica no trilho
Cada cartão é um passo do /new-feature; a seta leva o parcial que um passo entrega ao seguinte. Passos tracejados só rodam quando o caso de uso precisa.
/new-feature orchestrator · opus/new-feature-implement Quem lê o parcial depoisDepois da aprovação, o /new-feature-implement faz o executor construir o código. A spec é consolidada por referência e vale onde ela e um parcial divergem; o parcial é lido, por seção, para o detalhe.