7.1 KiB
AGENT.md
Violin IAM service: unified login, authentication, and authorization
microservice for the Violin suite.
Rules
1. Do not use PowerShell to read or write files. Prefer the built-in
file tools or Python instead.
2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII
natural language inside source files.
Boot rules
The very first time I ran java -cp ... cn.violin.iam.ViolinIamApplication,
Spring failed with "Parameter 0 of constructor in
cn.violin.core.config.WebMvcConfig required a bean of type
cn.violin.core.interceptor.AuthenticationInterceptor that could not be
found". The unit-test suite was green but had never actually booted the
context. Concrete gotchas to fix:
-
@SpringBootApplicationMUST setscanBasePackages = {"cn.violin"}explicitly. The default only scans the app's own sub-package (cn.violin.iam), which excludescn.violin.core.interceptor.AuthenticationInterceptor,CurrentUserHandlerMethodArgumentResolver,TraceIdFilter, and other framework beans. Spring Boot auto-config registers the@AutoConfigurationclasses themselves, but their@Beandependencies that are not themselves exposed via another@Beanstill need component-scan coverage. Compare withviolin-auth, which has always usedscanBasePackages = {"cn.violin"}; it never tripped over this. -
@MapperScan("cn.violin.*.mapper")— never use barecn.violin. The broad form hoovers up every interface in the project, including framework-internal ones likeJwksClient, and MyBatis then complains at runtime:Invalid bound statement (not found): cn.violin.core.security.JwksClient.loadKeys. Narrowing the pattern prevents MyBatis from treating non-mapper interfaces as proxies. -
@Serviceimpls whose interface is in the same package should be marked@PrimarywhenscanBasePackagesis widened. Oncecn.violin.*is scanned, Spring ends up holding twoBeanDefinitions of the same bean type (the impl + some derived candidate), and@Autowiredof the interface fails withNoUniqueBeanDefinitionException: expected single matching bean but found 2: OAuthServiceImpl,OAuthService. Adding@Primaryto the impl collapses that to a single candidate. -
Spring 6 forbids
@PostConstructmethods with parameters.SnowflakeId.init(SnowflakeProperties props)looked innocuous butIllegalStateException: Lifecycle annotation requires a no-arg methodhit on@SpringBootTest. UseInitializingBean+@Autowiredfor the setter (properties injection remains valid viawireProperties). Same pattern applies to any other singleton that needs configuration at boot. -
@ConfigurationPropertieson Javarecordtypes needs@ConstructorBindingwhen the canonical constructor does not match Spring's expected discovery. Without it, Spring fails withNo default constructor found. -
Adding
@SpringBootTestearly catches all four of these. Place a smoke test (ApplicationContextLoadsTest) insrc/test/java/.../boot/and runmvn test. If the unit tests are green but the boot test fails, the bug is precisely the kind that silently passes when scanning only@Component-annotated classes but blows up at full startup.
Design notes (do not skip)
Design notes (do not skip)
JWT revocation propagation — what we tried, what we picked, and why
The naive question "should we drop customerId from the JWT claim so
downstream must call IAM every request?" was the wrong frame. Below is
the actual decision tree we walked.
Problem
A user's t_user.customer_id is migrated from tenant A to tenant B. The
JWT issued at login still carries customerId=A. Without propagation
the user keeps operating under A's RBAC scope (and SAR cache) for the
remainder of the token TTL.
Industry patterns surveyed
-
Short TTL + RFC 7662 introspection (Google / AWS / GitHub / Slack). Token TTL = 5-15 min; each request consults an introspection endpoint (with local cache) to check the token is still live. Revocation propagates in <1 s via shared cache / pub-sub.
-
revokedCustomerBindingscache (medium-scale).Cache<userId, oldCustomerId>with TTL covering the longest possible token lifetime.UserService.changeCustomer(...)writes the DB row then publishes(userId, oldCustomerId)into the cache. The cache TTL ensures stale entries eventually fall out without a cron. -
Active binding flag (per-user cache of currently-valid customerId). cache miss → fall back to DB lookup. cache hit but cache.customerId != JWT claim → treat as revoke signal.
-
Kafka / event-driven revocation (large clusters, >10 services). IAM emits
user.tenant.changedto a topic; subscribers invalidate local cache on receipt. Strictly over-engineered for a 1-issuer / 5-subscriber fleet.
What we picked and why
We picked option 2 (revokedCustomerBindings cache) and combined
it with token TTL reduction from 24 h to 1 h. Net result:
- Worst-case staleness window =
min(token TTL, cache TTL)= 1 h. - No per-request IAM call (the user's framing-question was "is the extra IAM call worth it?" — answer: no, because it moves IAM onto the hot path and turns it into a single point of failure).
- Single-instance IAM means cache state is authoritative without sticky sessions or shared cache.
What we did NOT do (and the reasons)
- Drop
customerIdfrom JWT claim. Would force every permission check to callPOST /api/v1/internal/check-permission, adding 2-5 ms latency per request and making IAM the hot path. Token claim is now treated as informational only — never trusted for authorization. All decisions go through IAM SAR. - Re-sign JWT on every customer change. Requires Kafka or webhook infra to broadcast — engineering cost far exceeds risk reduction given the 1 h TTL already covers the window.
- Use Authentik's session revocation directly. Adds an upstream dependency IAM cannot fail without.
Implementation hooks already in place
| File | Role |
|---|---|
cn.violin.common.context.RequestContext |
exposes setCustomerIdHint(...) and getVerifiedCustomerId() — verified provenance is now mandatory for SAR / PermissionAspect / RbacController / ResourcePermissionAspect |
SubjectAccessReviewRequest.customerVerified |
explicit boolean; SAR denies on false |
PermissionAspect |
reads getVerifiedCustomerId() first, throws UnauthorizedException if null |
ResourcePermissionAspect |
same |
RbacController.access-check |
same |
InternalController.check-permission |
accepts only verified (set from HMAC + ServiceAllowlist mapping) |
Followup still pending (backlog, not on the critical path)
Add CustomerBindingRevocationCache and have UserService.changeCustomer(...)
push (sub, oldCustomerId) into it. Have PermissionAspect consult the cache
as a secondary check (after verified gate). Reduce default expirationMs
in ViolinJwtProperties from 86_400_000 (24 h) to 3_600_000 (1 h).
These three together close the revocation window to the bound of TTL.