Staff Engineer Onboarding Guide
You are not here to add a dependency. You are here because you need to reason about authorization correctness, extend the policy engine, or integrate CoSec into a runtime it has never seen. This guide gives you the architectural spine in one pass.
Executive Summary
CoSec is a reactive, multi-tenant, policy-based authorization framework for the JVM. Its mental model is lifted directly from AWS IAM: every request is evaluated against an ordered sequence of Policy documents, each containing Statement rules with ALLOW or DENY effects. DENY always wins. The default outcome is IMPLICIT_DENY.
The framework splits cleanly into two layers:
| Layer | Module | Responsibility |
|---|---|---|
| API | cosec-api | Pure interfaces, zero framework dependencies. Defines the security model contract. |
| Core | cosec-core | Default implementations, policy evaluation, authentication orchestration. |
| Integration | cosec-webflux, cosec-webmvc, cosec-gateway | Adapters that wire the core into Spring runtimes. |
| Auto-config | cosec-spring-boot-starter | Bean assembly via @ConditionalOn* annotations. |
The critical invariant: cosec-api has no Spring, Reactor, or Jackson imports. Every public contract lives there. Every implementation lives in cosec-core or an integration module. If you are adding a Spring import to cosec-api, you are in the wrong module.
Core Architectural Insight
The entire authorization decision reduces to a single recursive pattern: condition gate, then DENY-first scan, then ALLOW scan. This pattern appears identically at three levels: Policy, Statement, and the top-level SimpleAuthorization orchestrator.
# Pseudocode for the DENY-first evaluation pattern
def evaluate_deny_first(items, get_effect, verify):
# Phase 1: Check all DENY rules first
for item in filter(items, lambda i: get_effect(i) == DENY):
if verify(item) == EXPLICIT_DENY:
return EXPLICIT_DENY # Short-circuit on first deny
# Phase 2: Check ALLOW rules
for item in filter(items, lambda i: get_effect(i) == ALLOW):
if verify(item) == ALLOW:
return ALLOW # Short-circuit on first allow
# Phase 3: Default deny
return IMPLICIT_DENY
# Policy.verify() applies this pattern to its statements
# SimpleAuthorization.authorize() applies it to the full policy chainThe same function evaluateDenyFirst in SimpleAuthorization is used for both policy statements and role permissions. The architecture is fractal: the same pattern at every level.
System Architecture
graph TB
subgraph Runtime["Runtime Layer"]
GW["cosec-gateway<br>AuthorizationGatewayFilter"]
WF["cosec-webflux<br>ReactiveAuthorizationFilter"]
WM["cosec-webmvc<br>AuthorizationFilter"]
end
subgraph Core["Core Layer"]
RSF["ReactiveSecurityFilter<br>filterInternal()"]
AUTH["Authorization<br>SimpleAuthorization"]
SCP["SecurityContextParser<br>DefaultSecurityContextParser"]
PE["PolicyEvaluator<br>DefaultPolicyEvaluator"]
end
subgraph API["API Layer - Pure Interfaces"]
AUTH_IF["Authorization"]
PRINC_IF["CoSecPrincipal"]
POL_IF["Policy / Statement"]
ACT_IF["ActionMatcher"]
COND_IF["ConditionMatcher"]
end
subgraph Infra["Infrastructure"]
PR["PolicyRepository"]
ARPR["AppRolePermissionRepository"]
BC["BlacklistChecker"]
TV["TokenVerifier"]
end
GW --> RSF
WF --> RSF
WM --> AUTH
RSF --> SCP
RSF --> AUTH
AUTH --> PR
AUTH --> ARPR
AUTH --> BC
AUTH --> PE
PE --> POL_IF
POL_IF --> ACT_IF
POL_IF --> COND_IF
style Runtime fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Core fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style API fill:#161b22,stroke:#30363d,color:#e6edf3
style Infra fill:#161b22,stroke:#30363d,color:#e6edf3
style RSF fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style AUTH fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Key observation: all three runtime adapters (Gateway, WebFlux, WebMvc) funnel through the same Authorization.authorize() interface. The only difference is how they extract a Request from the HTTP exchange. This is the Strategy pattern applied at the framework boundary.
Domain Model
erDiagram
CoSecPrincipal {
String id PK
Map attributes
Boolean anonymous
Boolean authenticated
}
CoSecPrincipal ||--o{ RoleId : "roles"
CoSecPrincipal ||--o{ PolicyId : "policies"
TenantPrincipal {
String tenantId FK
}
CoSecPrincipal ||--|| TenantPrincipal : "extends"
TenantPrincipal }|--|| Tenant : "belongs to"
Tenant {
String tenantId PK
Boolean isPlatformTenant
Boolean isDefaultTenant
Boolean isUserTenant
}
Policy {
String id PK
String name
String category
PolicyType type
}
Policy ||--|| ConditionMatcher : "condition"
Policy ||--|{ Statement : "statements"
Policy }|--|| Tenant : "scoped to"
Statement {
String name
Effect effect
}
Statement ||--|| ActionMatcher : "action"
Statement ||--|| ConditionMatcher : "condition"
SecurityContext {
CoSecPrincipal principal
Tenant tenant
MutableMap attributes
}
SecurityContext ||--|| CoSecPrincipal : "holds"
SecurityContext ||--|| Tenant : "holds"
Request {
String path
String method
String remoteIp
String appId
String spaceId
String requestId
}Identity Hierarchy
The identity model has three tiers, controlled by the Tenant interface at cosec-api/src/main/kotlin/me/ahoo/cosec/api/tenant/Tenant.kt:
| Tier | tenantId Value | Meaning |
|---|---|---|
| Platform | "(platform)" | Root platform operator. Can manage all tenants. |
| Default | "(0)" | Shared/default tenant. No tenant isolation. |
| User | Any other string | A specific customer tenant with isolation. |
The CoSecPrincipal.ROOT_ID defaults to "cosec" but can be overridden via the system property cosec.root (CoSecPrincipal.kt:80). Root principals bypass the entire authorization chain.
Special Identity Constants
| Constant | Value | Where Defined | Purpose |
|---|---|---|---|
ROOT_ID | "cosec" (configurable) | CoSecPrincipal.kt:80 | Bypasses all authorization |
ANONYMOUS_ID | "(0)" | CoSecPrincipal.kt:87 | Unauthenticated principal |
PLATFORM_TENANT_ID | "(platform)" | Tenant.kt:56 | Platform-level tenant |
DEFAULT_TENANT_ID | "(0)" | Tenant.kt:57 | Default shared tenant |
Component Types
API Contracts (cosec-api)
Every public contract is an interface in cosec-api. No implementations, no framework coupling. The module defines the security algebra:
| Interface | File | Purpose |
|---|---|---|
CoSecPrincipal | CoSecPrincipal.kt | Identity: id, roles, policies, attributes |
Authentication<C, P> | Authentication.kt | Credential verification, returns Mono<P> |
Authorization | Authorization.kt | Request evaluation, returns Mono<AuthorizeResult> |
Policy | Policy.kt | Statement collection with condition gate |
Statement | Statement.kt | Single rule: Effect + ActionMatcher + ConditionMatcher |
ActionMatcher | ActionMatcher.kt | Matches request actions (HTTP method + path) |
ConditionMatcher | ConditionMatcher.kt | Matches contextual conditions |
SecurityContext | SecurityContext.kt | Principal + tenant + mutable attributes |
Request | Request.kt | HTTP request abstraction |
Core Implementations (cosec-core)
| Class | Responsibility |
|---|---|
SimpleAuthorization | Orchestrates the full 6-step authorization flow |
DefaultPolicyEvaluator | Validates policy structure at load time |
DefaultSecurityContextParser | Extracts SecurityContext from JWT in request headers |
DefaultAuthenticationProvider | Registry mapping credential types to Authentication instances |
CompositeAuthentication | Dispatches to the correct Authentication based on credential type |
TokenCompositeAuthentication | Wraps CompositeAuthentication, also converts principal to token |
LocalPolicyLoader | Loads policy JSON files from classpath resources |
SimpleSecurityContext | Thread-safe SecurityContext with ConcurrentHashMap attributes |
Integration Adapters
Each adapter follows the same pattern: parse the HTTP exchange into a Request, invoke Authorization.authorize(), then either forward or reject.
| Adapter | Class | Filter Type | Order |
|---|---|---|---|
| WebFlux | ReactiveAuthorizationFilter | WebFilter | 1000 |
| Gateway | AuthorizationGatewayFilter | GlobalFilter | HIGHEST_PRECEDENCE + 10 |
| WebMvc | AuthorizationFilter | jakarta.servlet.Filter | N/A |
The Gateway filter runs at HIGHEST_PRECEDENCE + 10 (AuthorizationGatewayFilter.kt:42), before route-specific filters. The WebFlux filter runs at order 1000 (ReactiveAuthorizationFilter.kt:49), after CORS but before application logic.
Gateway vs WebFlux: A Critical Distinction
The Gateway filter does not extend ReactiveAuthorizationFilter. It directly implements GlobalFilter and extends ReactiveSecurityFilter. The key behavioral difference: the Gateway filter mutates the exchange to inject the requestId header into downstream requests (AuthorizationGatewayFilter.kt:47-49), whereas the WebFlux filter does not. This matters for distributed tracing.
Request Lifecycle
This is the most important sequence diagram in the entire system. Read it top to bottom, then read the source code the same way.
sequenceDiagram
participant Client
participant Filter as Runtime Filter<br>Gateway/WebFlux/WebMvc
participant Parser as SecurityContextParser
participant Auth as SimpleAuthorization
participant PR as PolicyRepository
participant ARPR as AppRolePermissionRepository
participant BC as BlacklistChecker
Client->>Filter: HTTP Request
Filter->>Filter: RequestParser.parse(exchange)
Note right of Filter: Converts to CoSec Request
Filter->>Parser: parse(request)
alt Token present
Parser->>Parser: Extract JWT from header/query/cookie
Parser-->>Filter: SecurityContext(principal)
else No token
Parser-->>Filter: SecurityContext(anonymous)
end
Filter->>Auth: authorize(request, context)
Note right of Auth: Step 1: Root check
alt principal.isRoot
Auth-->>Filter: ALLOW
else Not root
Auth->>BC: check(request, context)
alt Blocked
BC-->>Auth: false
Auth-->>Filter: EXPLICIT_DENY
else Allowed
BC-->>Auth: true
Note right of Auth: Step 3: Global policies
Auth->>PR: getGlobalPolicy()
PR-->>Auth: List of Policy
Auth->>Auth: evaluateDenyFirst(statements)
alt Match found
Auth-->>Filter: ALLOW or EXPLICIT_DENY
else No match
Note right of Auth: Step 4: Principal policies
Auth->>PR: getPolicies(principal.policies)
PR-->>Auth: List of Policy
Auth->>Auth: evaluateDenyFirst(statements)
alt Match found
Auth-->>Filter: ALLOW or EXPLICIT_DENY
else No match
Note right of Auth: Step 5: Role permissions
Auth->>ARPR: getAppRolePermission(appId, spaceId, roles)
ARPR-->>Auth: AppRolePermission
Auth->>Auth: evaluateDenyFirst(permissions)
end
end
end
end
Note right of Auth: Step 6: Default IMPLICIT_DENY
Auth-->>Filter: AuthorizeResult
alt authorized == true
Filter->>Client: Forward to downstream
else authenticated == false
Filter->>Client: 401 Unauthorized
else authenticated == true
Filter->>Client: 403 Forbidden
endThe ReactiveSecurityFilter.filterInternal() method (ReactiveSecurityFilter.kt:66) contains the error handling matrix:
# Error handling matrix (pseudocode from ReactiveSecurityFilter)
if authorized:
forward_with_principal()
elif not authenticated:
return_401()
else:
return_403()
# Exception handling:
TooManyRequestsException -> 429
TokenVerificationException -> use token error as reason
Generic exception -> 500 with IMPLICIT_DENY bodyState Transitions
Authorization Decision State Machine
stateDiagram-v2
[*] --> RootCheck: authorize() called
RootCheck --> ALLOW: principal.id == ROOT_ID
RootCheck --> BlacklistCheck: not root
BlacklistCheck --> EXPLICIT_DENY: blocked
BlacklistCheck --> GlobalPolicies: allowed
GlobalPolicies --> EXPLICIT_DENY: DENY statement matched
GlobalPolicies --> ALLOW: ALLOW statement matched
GlobalPolicies --> PrincipalPolicies: no match
PrincipalPolicies --> EXPLICIT_DENY: DENY statement matched
PrincipalPolicies --> ALLOW: ALLOW statement matched
PrincipalPolicies --> RolePermissions: no match
RolePermissions --> EXPLICIT_DENY: DENY permission matched
RolePermissions --> ALLOW: ALLOW permission matched
RolePermissions --> IMPLICIT_DENY: no match
ALLOW --> [*]
EXPLICIT_DENY --> [*]
IMPLICIT_DENY --> [*]Policy Evaluation State Machine
Each individual Policy.verify() has its own state transition:
stateDiagram-v2
[*] --> ConditionGate: verify() called
ConditionGate --> IMPLICIT_DENY: condition.match() == false
ConditionGate --> DenyScan: condition.match() == true
DenyScan --> EXPLICIT_DENY: DENY statement matches action + condition
DenyScan --> AllowScan: no DENY match
AllowScan --> ALLOW: ALLOW statement matches action + condition
AllowScan --> IMPLICIT_DENY: no ALLOW match
IMPLICIT_DENY --> [*]
EXPLICIT_DENY --> [*]
ALLOW --> [*]Statement Verification
A single Statement.verify() (Statement.kt:60) follows this logic:
def statement_verify(statement, request, context):
if not statement.action.match(request, context):
return IMPLICIT_DENY
if not statement.condition.match(request, context):
return IMPLICIT_DENY
if statement.effect == ALLOW:
return ALLOW
else:
return EXPLICIT_DENYAction match runs first as a fast path. If the action pattern does not match, the condition is never evaluated. This ordering matters for rate limiter conditions that have side effects.
Decision Log
These are the architectural decisions that shape every interaction with the codebase.
| # | Decision | Rationale | Where Visible |
|---|---|---|---|
| D1 | API module has zero framework deps | Interfaces must be implementable without Spring on classpath | cosec-api/build.gradle.kts |
| D2 | DENY-first evaluation order | Prevents privilege escalation: a broad ALLOW cannot override a targeted DENY | SimpleAuthorization.kt:61 |
| D3 | SPI-based matcher discovery | Allows third-party extensions without core changes | META-INF/services/me.ahoo.cosec.policy.action.ActionMatcherFactory |
| D4 | Reactive throughout (Mono<T>) | Non-blocking authorization scales under load; consistent with Spring WebFlux | Authorization.kt:43 |
| D5 | Root bypass is identity-based, not role-based | Root check (principal.isRoot) happens before all other checks, including blacklist | SimpleAuthorization.kt:146-154 |
| D6 | SecurityContext attributes are mutable and concurrent | Downstream components can write path variables, rate limit counters, etc. | SimpleSecurityContext.kt:41 |
| D7 | Token extraction from header, query, or cookie | Supports browser-based, API, and legacy clients | DefaultSecurityContextParser.kt:27-31 |
| D8 | Gateway filter order is near-max priority | Authorization must run before route filters that might modify the exchange | AuthorizationGatewayFilter.kt:42 |
Dependency Rationale
Module Dependency Graph
graph TD
API["cosec-api<br>Pure interfaces"]
CORE["cosec-core<br>Default implementations"]
JWT["cosec-jwt"]
CACHE["cosec-cocache<br>Redis caching"]
SOCIAL["cosec-social<br>OAuth via JustAuth"]
IP2R["cosec-ip2region<br>IP geolocation"]
OTEL["cosec-opentelemetry<br>Tracing"]
OAPI["cosec-openapi<br>Swagger docs"]
WEBFLUX["cosec-webflux<br>WebFilter adapter"]
WEBMVC["cosec-webmvc<br>Servlet adapter"]
GW["cosec-gateway<br>GlobalFilter adapter"]
STARTER["cosec-spring-boot-starter<br>Auto-configuration"]
GWSRV["cosec-gateway-server<br>Standalone app"]
DEPS["cosec-dependencies<br>Version catalog"]
BOM["cosec-bom<br>BOM for consumers"]
API --> CORE
CORE --> JWT
CORE --> CACHE
CORE --> SOCIAL
CORE --> IP2R
CORE --> OTEL
CORE --> OAPI
CORE --> WEBFLUX
CORE --> WEBMVC
CORE --> GW
CORE --> STARTER
STARTER --> GWSRV
DEPS --> BOM
style API fill:#161b22,stroke:#30363d,color:#e6edf3
style CORE fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style STARTER fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style GWSRV fill:#2d333b,stroke:#8b949e,color:#8b949eVersion Strategy
All dependency versions are centralized in gradle/libs.versions.toml. The cosec-dependencies module consumes this catalog, and cosec-bom re-exports it as a Maven BOM for downstream consumers.
| Dependency | Version | Why |
|---|---|---|
| Kotlin | 2.3.20 | Language version; -Xjsr305=strict for null-safety interop |
| Spring Boot | 4.0.5 | Runtime; drives WebFlux/WebMvc/Gateway API compatibility |
| Spring Cloud | 2025.1.1 | Gateway filter integration |
| auth0/java-jwt | 4.5.1 | JWT token creation and verification |
| JustAuth | 1.16.7 | Multi-provider OAuth (WeChat, GitHub, etc.) |
| OGNL | 3.4.11 | Expression-based condition matching |
| CoCache | 4.0.2 | Two-level distributed caching (local + Redis) |
| CosId | 3.0.5 | Distributed ID generation |
| Guava RateLimiter | 33.5.0-jre | Token-bucket rate limiting in conditions |
Why OGNL and SpEL Both Exist
CoSec supports two expression languages for condition matchers: OGNL (OgnlConditionMatcher.kt) and SpEL (SpelConditionMatcher.kt). OGNL is the default for policy expressions because it is simpler and has no Spring dependency. SpEL is available for teams already invested in the Spring ecosystem. Both are registered as SPI condition matchers.
Storage/Data Architecture
CoSec itself does not own a database. It defines repository interfaces that you implement against your data store.
Repository SPI
| Interface | Module | Methods | Purpose |
|---|---|---|---|
PolicyRepository | cosec-core | getGlobalPolicy(), getPolicies(ids), setPolicy() | Stores and retrieves policy documents |
AppRolePermissionRepository | cosec-core | getAppRolePermission(appId, spaceId, roles) | Maps app+role to permission sets |
Both return Mono<T>, meaning the backing store can be reactive (R2DBC, Redis, etc.). The cosec-cocache module provides Redis-backed caching implementations using the CoCache two-level cache pattern.
Local Policy Loading
For scenarios where policies are static and file-based, LocalPolicyLoader (LocalPolicyLoader.kt) reads JSON policy files from the classpath. Configured via:
cosec:
authorization:
local-policy:
enabled: true
locations: "classpath:cosec-policy/*-policy.json"
init-repository: true
force-refresh: falseWhen init-repository is true, the LocalPolicyInitializer pushes loaded policies into the PolicyRepository at startup (CoSecAuthorizationAutoConfiguration.kt:71-87).
Policy JSON Format
A policy document follows this structure (as defined by the serialization layer in cosec-core/src/main/kotlin/me/ahoo/cosec/serialization/):
{
"id": "global-policy",
"name": "Global Access Policy",
"category": "system",
"type": "GLOBAL",
"tenantId": "(platform)",
"condition": {},
"statements": [
{
"name": "deny-admin-mutation",
"effect": "DENY",
"action": { "path": { "pattern": "/admin/**" } },
"condition": { "authenticated": true }
},
{
"name": "allow-public-read",
"effect": "ALLOW",
"action": { "path": { "pattern": ["/api/public/**", "/health"] } },
"condition": {}
}
]
}The JSON is deserialized through custom Jackson serializers registered via CoSecModule (CoSecModule.kt). Each matcher type has its own serializer that delegates to the SPI factory.
Failure Modes
Understanding failure modes is critical for production operation.
| Failure | Behavior | Source |
|---|---|---|
| JWT expired | TokenVerificationException caught in filterInternal, context falls back to anonymous, then authorization proceeds (likely 401) | ReactiveSecurityFilter.kt:73-79 |
| Rate limit exceeded | TooManyRequestsException thrown from condition matcher, caught and returns 429 | ReactiveSecurityFilter.kt:106-108 |
| PolicyRepository unavailable | Mono propagates the error; caught by onErrorResume returning 500 | ReactiveSecurityFilter.kt:109-114 |
| No matching policy | Returns IMPLICIT_DENY (default deny). Authenticated user gets 403, anonymous gets 401 | SimpleAuthorization.kt:207-210 |
| Blacklist blocks request | Immediate EXPLICIT_DENY before any policy evaluation | SimpleAuthorization.kt:221-228 |
| Malformed policy JSON | CoSecJsonSerializer.readValue throws; caught by LocalPolicyLoader and logged, policy skipped | LocalPolicyLoader.kt:63-67 |
| Unknown action matcher type | SPI lookup fails at deserialization time; policy load fails | ActionMatcherFactoryProvider |
Failure Mode Decision Matrix
graph TD
REQ["Incoming Request"]
PARSE["Parse SecurityContext"]
ROOT{"isRoot?"}
BL{"Blacklisted?"}
GP{"Global Policy<br>Match?"}
PP{"Principal Policy<br>Match?"}
RP{"Role Permission<br>Match?"}
REQ --> PARSE
PARSE -->|TokenExpired| ANON["Anonymous Context"]
PARSE -->|Success| CTX["Authenticated Context"]
ANON --> ROOT
CTX --> ROOT
ROOT -->|Yes| ALLOW["ALLOW<br>200"]
ROOT -->|No| BL
BL -->|Yes| DENY403["EXPLICIT_DENY<br>403"]
BL -->|No| GP
GP -->|DENY| DENY403
GP -->|ALLOW| ALLOW
GP -->|No match| PP
PP -->|DENY| DENY403
PP -->|ALLOW| ALLOW
PP -->|No match| RP
RP -->|DENY| DENY403
RP -->|ALLOW| ALLOW
RP -->|No match| IMPLICIT["IMPLICIT_DENY<br>401 or 403"]
IMPLICIT -->|anonymous| R401["401 Unauthorized"]
IMPLICIT -->|authenticated| R403["403 Forbidden"]
style ALLOW fill:#2d333b,stroke:#238636,color:#e6edf3
style DENY403 fill:#2d333b,stroke:#da3633,color:#e6edf3
style IMPLICIT fill:#2d333b,stroke:#d29922,color:#e6edf3
style R401 fill:#2d333b,stroke:#d29922,color:#e6edf3
style R403 fill:#2d333b,stroke:#da3633,color:#e6edf3API Surface
Core Authorization API
The total public API surface of cosec-api is intentionally small. Here is every interface you need to know:
# The complete authorization API in pseudocode
class Authorization:
def authorize(request: Request, context: SecurityContext) -> Mono[AuthorizeResult]
class Authentication[C, P]:
supportCredentials: Class[C]
def authenticate(credentials: C) -> Mono[P]
class Policy:
id, name, category, description, type, condition, statements
def verify(request, context) -> VerifyResult # ALLOW, EXPLICIT_DENY, IMPLICIT_DENY
class Statement:
name, effect, action, condition
def verify(request, context) -> VerifyResult
class ActionMatcher(RequestMatcher):
def match(request, context) -> bool
class ConditionMatcher(RequestMatcher):
def match(request, context) -> bool
class CoSecPrincipal(Principal):
id, roles, policies, attributes, anonymous, authenticated
class SecurityContext(TenantCapable):
principal, tenant, attributes
def getAttributeValue(key) -> V
def setAttributeValue(key, value) -> SecurityContext
class Request:
path, method, remoteIp, appId, spaceId, deviceId, requestId
def getHeader(key) -> str
def getQuery(key) -> str
def getCookieValue(key) -> strSPI Extension Points
To extend CoSec with a custom matcher, you implement two things:
- A
ConditionMatcherFactory(orActionMatcherFactory) with a uniquetypestring - Register it in
META-INF/services/me.ahoo.cosec.policy.condition.ConditionMatcherFactory
The built-in registrations are visible in the SPI file at cosec-core/src/main/resources/META-INF/services/me.ahoo.cosec.policy.condition.ConditionMatcherFactory:
| Type | Class | Category |
|---|---|---|
authenticated | AuthenticatedConditionMatcherFactory | Context |
inRole | InRoleConditionMatcherFactory | Context |
inTenant | InTenantConditionMatcherFactory | Context |
eq | EqConditionMatcherFactory | Path/String |
contains | ContainsConditionMatcherFactory | Path/String |
startsWith | StartsWithConditionMatcherFactory | Path/String |
endsWith | EndsWithConditionMatcherFactory | Path/String |
in | InConditionMatcherFactory | Path/String |
regular | RegularConditionMatcherFactory | Path/String |
path | PathConditionMatcherFactory | Path/String |
spel | SpelConditionMatcherFactory | Expression |
ognl | OgnlConditionMatcherFactory | Expression |
rateLimiter | RateLimiterConditionMatcherFactory | Rate Limiting |
groupedRateLimiter | GroupedRateLimiterConditionMatcherFactory | Rate Limiting |
bool | BoolConditionMatcherFactory | Logical Composition |
all | AllConditionMatcherFactory | Match All |
For action matchers, the SPI file at cosec-core/src/main/resources/META-INF/services/me.ahoo.cosec.policy.action.ActionMatcherFactory registers:
| Type | Class | Purpose |
|---|---|---|
path | PathActionMatcherFactory | Spring PathPattern matching with variable extraction |
all | AllActionMatcherFactory | Matches every action |
| (composite) | CompositeActionMatcherFactory | OR-composite of multiple patterns |
Path Matching Internals
The PathActionMatcher (PathActionMatcher.kt:42) uses Spring's PathPatternParser for URL matching. When a pattern matches, extracted path variables are stored in SecurityContext.attributes under the key "PATH_VARIABLES" (PathActionMatcher.kt:28). This means downstream condition matchers can reference path variables in expressions.
The ReplaceablePathActionMatcher (PathActionMatcher.kt:61) supports SpEL template expressions in the pattern itself, allowing dynamic patterns like #{request.path} that resolve at evaluation time.
Configuration
Properties Reference
All properties are prefixed with cosec.:
| Property | Default | Module | Purpose |
|---|---|---|---|
cosec.enabled | true | starter | Master switch via @ConditionalOnCoSecEnabled |
cosec.authorization.enabled | true | starter | Authorization feature toggle |
cosec.authorization.local-policy.enabled | false | starter | Enable classpath policy loading |
cosec.authorization.local-policy.locations | classpath:cosec-policy/*-policy.json | starter | Resource pattern for policy files |
cosec.authorization.local-policy.init-repository | false | starter | Push local policies to repository at startup |
cosec.authorization.local-policy.force-refresh | false | starter | Overwrite existing policies in repository |
cosec.authentication.enabled | true | starter | Authentication feature toggle |
cosec.jwt.enabled | true | starter | JWT token handling toggle |
cosec.social.enabled | false | starter | OAuth social login toggle |
cosec.gateway.enabled | true | starter | Gateway filter toggle |
Auto-Configuration Hierarchy
graph TD
CA["CoSecAutoConfiguration<br>@ConditionalOnCoSecEnabled"]
CA --> CAA["CoSecAuthenticationAutoConfiguration"]
CA --> CAZ["CoSecAuthorizationAutoConfiguration"]
CA --> CAJ["CoSecJwtAutoConfiguration"]
CA --> CAI["InjectSecurityContextAutoConfiguration"]
CA --> CAOT["CoSecOpenTelemetryAutoConfiguration"]
CA --> CAOA["CoSecOpenAPIAutoConfiguration"]
CA --> CAI2["Ip2RegionAutoConfiguration"]
CAZ --> CG["CoSecGatewayAuthorizationAutoConfiguration<br>@ConditionalOnClass: GlobalFilter"]
CAZ --> CPC["CoSecPolicyCacheAutoConfiguration<br>@ConditionalOnCacheEnabled"]
CAZ --> CAPC["CoSecPermissionCacheAutoConfiguration<br>@ConditionalOnCacheEnabled"]
CAZ --> RP["CoSecRequestParserAutoConfiguration"]
CAA --> CSA["CoSecSocialAuthenticationAutoConfiguration<br>@ConditionalOnSocialAuthenticationEnabled"]
style CA fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style CAZ fill:#161b22,stroke:#30363d,color:#e6edf3The CoSecAutoConfiguration runs before JacksonAutoConfiguration (CoSecAutoConfiguration.kt:35) to ensure the CoSecModule Jackson serializer is registered before any JSON binding occurs.
The MatcherFactoryRegister bean (CoSecAutoConfiguration.kt:43) scans the Spring ApplicationContext for ActionMatcherFactory and ConditionMatcherFactory beans and merges them with the SPI-discovered factories. This means you can override built-in matchers by registering a bean.
Conditional Loading Strategy
The auto-configuration uses a layered conditional system:
# Pseudocode for conditional loading
if on_classpath("GlobalFilter"):
# Spring Cloud Gateway is present
register(AuthorizationGatewayFilter)
elif on_classpath("ReactiveAuthorizationFilter"):
# WebFlux is present but not Gateway
register(ReactiveAuthorizationFilter)
if on_classpath("AuthorizationFilter"):
# Servlet API is present
register(AuthorizationFilter)The key conditional is @ConditionalOnMissingClass("org.springframework.cloud.gateway.filter.GlobalFilter") on the WebFlux filter bean definition (CoSecAuthorizationAutoConfiguration.kt:131). This ensures the WebFlux filter does not register when Gateway is present, because the Gateway filter already provides authorization.
Performance
Policy Evaluation Complexity
The authorization chain has a worst-case linear scan through all policies and their statements. For a principal with p policies, each containing s statements:
# Worst-case: no match found anywhere
# Each level is O(n) where n is total statements across all matched policies
total_evaluations = global_policy_statements + principal_policy_statements + role_permissions
# Each statement: O(1) action match + O(1) condition match (for built-in matchers)
# Total: O(total_evaluations)The evaluateDenyFirst method makes two passes over the same collection: one for DENY, one for ALLOW. This is intentional: it guarantees that every DENY statement is evaluated before any ALLOW, regardless of statement ordering in the policy JSON.
Rate Limiter Performance
The RateLimiterConditionMatcher (RateLimiterConditionMatcher.kt) uses Guava's RateLimiter.create(permitsPerSecond) which is a token-bucket algorithm. It is created once per condition matcher instance and shared across all requests matching that condition. For per-user rate limiting, GroupedRateLimiterConditionMatcher maintains separate rate limiters per group key.
Caching Layer
The cosec-cocache module adds a two-level cache (local Caffeine + Redis) around PolicyRepository and AppRolePermissionRepository. This transforms the authorization flow from:
Request -> PolicyRepository (DB call) -> EvaluateInto:
Request -> Local Cache -> (miss) -> Redis -> (miss) -> DB -> populate cache -> EvaluateCache configuration is managed via CoSecPolicyCacheAutoConfiguration and CoSecPermissionCacheAutoConfiguration, gated by @ConditionalOnCacheEnabled.
JMH Benchmarks
Every module includes JMH benchmark support via the me.champeau.jmh Gradle plugin (libs.versions.toml:53). Run benchmarks with:
./gradlew :cosec-core:jmh -PjmhIncludes=*.SomeBenchmarkThe JMH dependency version is 1.37 (libs.versions.toml:21).
Security Model
Invariants You Must Never Violate
DENY always wins. The
evaluateDenyFirstpattern inSimpleAuthorization.kt:61must never be changed to allow ALLOW to override DENY.Root bypass is absolute. If
principal.isRootreturns true, the authorization returns ALLOW immediately (SimpleAuthorization.kt:217-219). This includes bypassing the blacklist. This is by design.Default is deny. If no policy matches, the result is always
IMPLICIT_DENY(SimpleAuthorization.kt:207-210).Anonymous principal has no roles or policies. The
SimpleTenantPrincipal.ANONYMOUS(SimpleTenantPrincipal.kt:42) wrapsSimplePrincipal.ANONYMOUSwith the default tenant. It can only access resources where a GLOBAL policy explicitly allows anonymous access.Token extraction is multi-source. The
DefaultSecurityContextParserchecks header, query parameter, and cookie in that order (DefaultSecurityContextParser.kt:27-31). Any of these can carry the JWT.Rate limiter exceptions are terminal. When a
RateLimiterConditionMatcherfails to acquire a permit, it throwsTooManyRequestsException(RateLimiterConditionMatcher.kt:49). This exception propagates out of the authorization chain and is caught by the filter layer, returning 429.
Security Context Isolation
SimpleSecurityContext uses ConcurrentHashMap for attributes (SimpleSecurityContext.kt:41). In a reactive context, the SecurityContext is created per-request and should not be shared across requests. However, the concurrent map protects against accidental sharing in async chains within a single request.
Tenant Isolation
Policies are scoped to tenants via the Tenant interface. The Policy interface extends Tenant, meaning every policy document carries a tenantId. When PolicyRepository.getGlobalPolicy() returns global policies, they apply regardless of tenant. When PolicyRepository.getPolicies(principal.policies) returns principal-specific policies, they should be filtered by the current tenant context. The repository implementation is responsible for enforcing this isolation.
Testing Strategy
Test Stack
| Tool | Version | Purpose |
|---|---|---|
| JUnit 5 | 6.0.3 | Test framework |
| MockK | 1.14.9 | Kotlin-native mocking |
| FluentAssert | 0.2.6 | Fluent test assertions (me.ahoo.test.asserts.assert) |
| Hamcrest | 3.0 | Matcher library |
| Reactor Test | (from Spring Boot) | StepVerifier for Mono/Flux testing |
| JMH | 1.37 | Performance benchmarks |
Running Tests
# All tests across all modules
./gradlew test
# Single module
./gradlew :cosec-core:test
# Single test class
./gradlew :cosec-core:test --tests "me.ahoo.cosec.authorization.SimpleAuthorizationTest"
# Single test method
./gradlew :cosec-core:test --tests "me.ahoo.cosec.authorization.SimpleAuthorizationTest.authorize"
# Detekt static analysis
./gradlew detekt
# Code coverage
./gradlew :code-coverage-report:codeCoverageReportTesting Conventions
- All tests use FluentAssert (
me.ahoo.test.asserts.assert), not AssertJ'sassertThat() - Reactive tests use
StepVerifierfromreactor-test - Mocking uses MockK, not Mockito
- Test classes follow the
<ClassUnderTest>Testnaming convention - Detekt configuration is at
config/detekt/detekt.yml
Key Test Files to Read
| Test | Tests What | Why It Matters |
|---|---|---|
SimpleAuthorizationTest | Full authorization flow | Covers all 6 steps of the authz chain |
CoSecAutoConfigurationTest | Bean assembly | Validates conditional bean creation |
CoSecJsonSerializerTest | Policy JSON round-trip | Validates serialization correctness |
DefaultAppPermissionEvaluatorTest | Permission evaluation | Tests the role-permission evaluation path |
AuthorizationGatewayFilterTest | Gateway integration | Tests the GlobalFilter adapter |
Technical Debt and Known Concerns
Explicit Technical Debt
Root bypasses blacklist. The root check (
SimpleAuthorization.kt:217) returns ALLOW before the blacklist check. This is intentional but should be documented to prevent surprise. A compromised root principal has no secondary defense.Rate limiter side effects in evaluation. The
RateLimiterConditionMatcherthrowsTooManyRequestsException(RateLimiterConditionMatcher.kt:49) as control flow. This means a rate limiter in a DENY statement can prevent ALLOW statements from being evaluated, which is correct behavior but subtle.Default
BlacklistCheckeris NoOp. The auto-configuration defaults toBlacklistChecker.NoOp(CoSecAuthorizationAutoConfiguration.kt:91). Teams must provide their own implementation or live without blacklisting.Thread-safety of policy objects.
PolicyandStatementinterfaces do not enforce immutability. If aPolicyRepositoryreturns mutable policy objects, concurrent evaluations could interfere. ThePolicyDataimplementation should be a data class withvalproperties.No circuit breaker on repository calls. If
PolicyRepository.getGlobalPolicy()returns an error, it propagates as a 500. There is no built-in fallback to cached policies. Thecosec-cocachemodule mitigates this but is optional.
Architectural Observations
The
Policy.verify()default method does real work. Unlike most interface default methods that are trivial,Policy.kt:76-103contains the complete DENY-first evaluation logic. This means everyPolicyimplementation inherits this behavior, and overriding it requires care.Statement.verify()is also a default method. Same concern asPolicy.verify(). The action-condition-effect evaluation logic is in the interface atStatement.kt:60-74.DefaultPolicyEvaluatoruses a mock request. The evaluator creates anEvaluateRequestwith a fixed path/policies/test(DefaultPolicyEvaluator.kt:65). This validates that matchers do not throw on construction, but does not validate semantic correctness against real requests.
Where to Go Deep
If you need to understand a specific aspect of the system, here are the entry points ranked by impact:
| Goal | Start Here | Then Read |
|---|---|---|
| Understand the full authz flow | SimpleAuthorization.kt | ReactiveSecurityFilter.kt |
| Write a custom condition matcher | ConditionMatcherFactory | AuthenticatedConditionMatcher.kt (simplest example) |
| Understand path matching | PathActionMatcher.kt | PathPatternParsers.kt |
| Integrate into a new Spring runtime | CoSecAuthorizationAutoConfiguration.kt | ReactiveAuthorizationFilter.kt |
| Debug policy JSON deserialization | CoSecJsonSerializer.kt | JsonPolicySerializer.kt |
| Understand multi-tenancy | Tenant.kt | SimpleTenantPrincipal.kt |
| Rate limiting internals | RateLimiterConditionMatcher.kt | GroupedRateLimiterConditionMatcher.kt |
| Expression-based conditions | OgnlConditionMatcher.kt | SpelConditionMatcher.kt |
| Authentication chain | DefaultAuthenticationProvider.kt | CompositeAuthentication.kt |
Comparison to Familiar Systems
If you have experience with these systems, here is how CoSec maps:
| Concept | AWS IAM | Spring Security | CoSec |
|---|---|---|---|
| Policy document | IAM Policy | SecurityConfig chain | Policy (JSON) |
| Permission rule | Statement | authorizeHttpRequests DSL | Statement |
| Effect | Allow/Deny | permitAll/denyAll | Effect.ALLOW/DENY |
| Action | Action: "s3:GetObject" | requestMatchers("/api/**") | ActionMatcher (path pattern) |
| Condition | Condition: StringEquals | @PreAuthorize SpEL | ConditionMatcher (SPI) |
| Principal | IAM User/Role | Authentication | CoSecPrincipal |
| Default | Implicit deny | Deny by default | IMPLICIT_DENY |
| Root/Admin | Account root | ROLE_ADMIN | ROOT_ID bypass |
| Tenant | AWS Account | N/A (external) | Tenant (built-in) |
| Rate limiting | API Gateway throttling | RateLimiter filter | RateLimiterConditionMatcher |
The critical difference from Spring Security: CoSec does not use a filter chain for authorization logic. Instead, it evaluates a policy document tree. This makes authorization decisions auditable (you can serialize the policy JSON), portable (same policy works in WebFlux, WebMvc, and Gateway), and testable in isolation without starting a web server.
This guide covers the architectural spine of CoSec. The codebase is around 15 modules with clear separation of concerns. When in doubt, start from the cosec-api interfaces and trace implementations in cosec-core. The SPI files in META-INF/services/ tell you every built-in extension. The auto-configuration in cosec-spring-boot-starter tells you how the pieces assemble.