87 added, 365 removed. Audit A to A.
---
name: cosec-policy-author
- description: "Use when writing, validating, explaining, or debugging CoSec policy JSON, policy statements, ALLOW/DENY rules, action matchers, condition matchers, role-based authorization, tenant scoping, or rate limiting policies."
+ description: Write, review, validate, or explain CoSec policy JSON, including allow/deny precedence, action and condition matchers, tenant and role rules, and local or Redis rate limits. Do not use for Spring Boot setup.
---
# CoSec Policy Authoring
- This skill helps you write, validate, and explain CoSec security policy JSON files. CoSec uses an AWS IAM-like policy model where policies contain statements that define ALLOW or DENY rules matched against requests.
-
- ## Policy JSON Structure
+ Produce the smallest policy that expresses the requested access rule. Establish the endpoint paths and methods, anonymous/authenticated behavior, roles or principal attributes, tenant scope, and explicit deny cases before writing JSON.
- A policy file is a single JSON object placed in `src/main/resources/cosec-policy/`. The file naming convention is `*-policy.json`.
+ ## Minimal complete policy
```json
{
- "id": "unique-policy-id",
- "name": "Human-readable name",
- "category": "optional-category",
- "description": "What this policy does",
+ "id": "orders-api",
+ "name": "Orders API",
+ "category": "orders",
+ "description": "Order endpoint access",
"type": "global",
"tenantId": "(platform)",
- "condition": { ... },
- "statements": [ ... ]
- }
- ```
-
- ### Fields
-
- | Field | Required | Description |
- |-------|----------|-------------|
- | `id` | Yes | Unique identifier for the policy |
- | `name` | Yes | Human-readable display name |
- | `category` | No | Logical grouping label |
- | `description` | No | Detailed description |
- | `type` | Yes | `global` (applies to all requests), `system` (system-level), or `custom` (user/role-specific) |
- | `tenantId` | Yes | Tenant scope. Use `(platform)` for global/system policies |
- | `condition` | No | Policy-level ConditionMatcher — if present and doesn't match, this policy is **skipped** (other policies still evaluate) |
- | `statements` | Yes | Array of Statement objects |
-
- ## Statement Structure
-
- Each statement defines a single permission rule:
-
- ```json
- {
- "name": "StatementName",
- "effect": "allow",
- "action": "...",
- "condition": { ... }
+ "statements": [
+ {
+ "name": "ReadOwnOrder",
+ "action": {
+ "path": {
+ "method": "GET",
+ "pattern": "/users/{userId}/orders/*"
+ }
+ },
+ "condition": {
+ "eq": {
+ "part": "request.path.var.userId",
+ "value": "#{principal.id}"
+ }
+ }
+ }
+ ]
}
```
- | Field | Required | Default | Description |
- |-------|----------|---------|-------------|
- | `name` | No | `""` | Descriptive name for the statement |
- | `effect` | No | `"allow"` | `"allow"` or `"deny"`. DENY takes precedence over ALLOW |
- | `action` | Yes | — | ActionMatcher definition (see below) |
- | `condition` | No | match-all | ConditionMatcher definition (see below) |
-
- ## Evaluation Order
-
- Within one evaluation tier (global policies, or principal-attached policies), CoSec pools **all statements from all policies whose policy-level condition matched**, then evaluates them deny-first:
-
- 1. Policies whose policy-level `condition` doesn't match are skipped entirely — this does NOT deny the request
- 2. **All DENY statements are checked first** — any match returns EXPLICIT_DENY immediately, even if an ALLOW statement in another policy would also match
- 3. **ALLOW statements are checked next** — any match returns ALLOW
- 4. If nothing matched in this tier, the next tier is tried; if no tier matches, the default is IMPLICIT_DENY
+ Use the full field set above so both runtime deserialization and the bundled JSON Schema accept the policy. Runtime requires `id`, `name`, `type`, and `tenantId`, while the current schema requires `category`, `name`, `description`, `tenantId`, `type`, and `statements` but omits `id`. A statement requires `action`; `effect` defaults to `allow` and `condition` defaults to match-all. Local files normally live under `src/main/resources/cosec-policy/` and match `*-policy.json`.
- A practical consequence: a DENY statement in ANY policy of the same tier overrides an ALLOW in ANY other policy. Write DENY statements before ALLOW rules in the statements array for readability, though the framework pools statements before evaluating.
+ ## Evaluation semantics
- ## Action Matchers
+ Authorization evaluates in tiers: root bypass, blacklist, global policies, principal-attached policies, then role permissions. The first tier that produces a result stops evaluation.
- The `action` field defines which requests a statement applies to. There are several formats:
+ Within a policy tier, CoSec first evaluates every matched policy-level condition, pools their statements, checks all `deny` statements, then all `allow` statements. Therefore:
- ### Simple string — path pattern
- ```json
- "action": "/api/users"
- ```
- Matches the exact path. Supports Spring path patterns with wildcards and variables.
+ - a matching deny overrides every allow in the same tier, regardless of file or array order;
+ - a false policy-level condition skips that policy; it does not deny the request;
+ - an allow in an earlier tier prevents later tiers from running, so a principal or role deny cannot override a global allow;
+ - if no statement in any tier matches, the result is implicit deny.
- ### String with SpEL template
- ```json
- "action": "/user/#{principal.id}/*"
- ```
- `#{principal.id}` is evaluated at match time against the current security context principal.
+ Order deny statements before allow statements for readability, not behavior.
- ### String with path variables
- ```json
- "action": "/user/{id}"
- ```
- Matches path segments. Access the variable in conditions via `request.path.var.id`.
+ ## Action forms
- ### Array — multiple paths (OR logic)
```json
- "action": ["/auth/register", "/auth/login", "/auth/logout"]
+ "action": "/api/users/{id}"
```
- Matches if ANY path in the array matches. An array containing `"*"` matches all requests.
- ### Wildcard
```json
- "action": "*"
+ "action": ["/auth/login", "/auth/refresh"]
```
- Matches all requests. Use with conditions to restrict scope.
- ### Object — path matcher with options
```json
"action": {
"path": {
- "method": "GET",
- "pattern": "/api/users/*",
- "options": {
- "caseSensitive": false,
- "separator": "/",
- "decodeAndParseSegments": false
- }
- }
- }
- ```
-
- The `method` field can be a single string or array: `"method": ["GET", "POST"]`.
-
- The `pattern` field can be a single string or array of patterns.
-
- ### Object — all matcher with method filter
- ```json
- "action": {
- "all": {
- "method": "GET"
- }
- }
- ```
- Matches all GET requests regardless of path.
-
- ### Object — composite matcher (OR logic across different matcher types)
- ```json
- "action": {
- "composite": [
- "/api/public/*",
- {
- "path": {
- "method": "POST",
- "pattern": "/api/webhook/*"
- }
- }
- ]
- }
- ```
-
- ## Condition Matchers
-
- The `condition` field adds additional constraints beyond path matching. An omitted condition defaults to match-all (`all`). All condition types:
-
- ### authenticated — user must be logged in
- ```json
- "condition": { "authenticated": {} }
- ```
-
- ### inRole — user must have the specified role
- ```json
- "condition": { "inRole": { "value": "admin" } }
- ```
-
- ### inTenant — request must be from the specified tenant type
- ```json
- "condition": { "inTenant": { "value": "platform" } }
- ```
- `value` is a tenant **type** — `default`, `user`, or `platform` — not a tenant ID. Any other string makes the policy fail to load: the error is logged and the policy is **silently skipped** at startup, so its rules stop applying (watch for 403s).
-
- ### eq — exact value match
- ```json
- "condition": {
- "eq": {
- "part": "request.path.var.id",
- "value": "#{principal.id}"
+ "method": ["GET", "HEAD"],
+ "pattern": ["/api/users/*", "/api/teams/*"]
}
}
```
- The `part` field is a path expression that extracts a value from the request or security context:
- - `request.path` — full request path
- - `request.path.var.{name}` — path variable
- - `request.method` — HTTP method
- - `request.remoteIp` — client IP
- - `request.origin` — request origin; `request.origin.host` — origin host
- - `request.referer` — referer; `request.referer.host` — referer host
- - `request.appId` / `request.spaceId` / `request.deviceId` — request identifiers
- - `request.header.{name}` — request header (singular `header`)
- - `request.attributes.{key}` — request attributes (e.g., `request.attributes.ipRegion`)
- - `context.tenantId` — current tenant ID
- - `context.principal.id` — current user ID
- - `context.principal.attributes.{key}` — principal attributes
-
- The `value` field supports SpEL templates like `#{principal.id}`.
+ `"*"` or `{ "all": { "method": "GET" } }` matches all paths. `{ "composite": [...] }` ORs heterogeneous action matchers. Path patterns support variables such as `{id}` and SpEL templates such as `#{principal.id}`.
- ### contains — substring match
- ```json
- "condition": {
- "contains": {
- "part": "request.attributes.ipRegion",
- "value": "上海"
- }
- }
- ```
+ ## Condition reference
- ### startsWith / endsWith — prefix/suffix match
- ```json
- "condition": {
- "startsWith": {
- "part": "request.attributes.ipRegion",
- "value": "中国"
- }
- }
- ```
+ | Type | Required configuration | Purpose |
+ |---|---|---|
+ | `all` | none | Always match |
+ | `authenticated` | none | Require a non-anonymous principal |
+ | `inRole` | `value` | Match a principal role |
+ | `inTenant` | `value`: `default`, `user`, or `platform` | Match tenant type, not tenant ID |
+ | `eq`, `contains`, `startsWith`, `endsWith` | `part`, `value` | Compare an extracted string |
+ | `in` | `part`, array `value` | Match one of several values |
+ | `regular` | `part`, `pattern` | Regex match with a time budget |
+ | `path` | `part`, `pattern`, optional `options` | Spring path-pattern match on an extracted value |
+ | `bool` | optional sibling arrays `and`, `or` | Combine conditions |
+ | `spel` | `expression` | Evaluate against root properties `request` and `context` |
+ | `ognl` | `expression` | Sandboxed expression using `#request` and `#context` |
+ | `rateLimiter` | `permitsPerSecond` | One in-memory limit per matcher/JVM |
+ | `groupedRateLimiter` | `part`, `permitsPerSecond`, `expireAfterAccessSecond` | In-memory per-part limit |
+ | `redisRateLimiter` | `permitsPerSecond`; optional `windowSeconds`, `strictFailure` | Cluster-wide Redis limit |
+ | `redisGroupedRateLimiter` | `part`, `permitsPerSecond`; optional `windowSeconds`, `strictFailure` | Cluster-wide Redis per-part limit |
- ### in — value must be in a list
- ```json
- "condition": {
- "in": {
- "part": "context.principal.id",
- "value": ["adminId", "developerId"]
- }
- }
- ```
+ The Redis types exist only when cache support and `StringRedisTemplate` activate their Spring factories. They use an atomic sliding window, return 429 when exceeded, fail open on Redis errors by default, and fail closed when `strictFailure` is `true`. Because the starter registers these factories after local repository initialization, bootstrap local policy files cannot use them; store those policies in the repository or register the factories earlier. The in-memory limiters also return 429 when exceeded.
- ### regular — regex match
- ```json
- "condition": {
- "regular": {
- "negate": true,
- "part": "request.origin",
- "pattern": "^(http|https)://github.com"
- }
- }
- ```
- Set `negate: true` to invert the match (matches when regex does NOT match).
+ `negate` is handled only by matchers based on `AbstractConditionMatcher`; the ungrouped `rateLimiter` and `redisRateLimiter` do not support it. Avoid negating any limiter because an exceeded quota throws before boolean negation.
- ### path — path pattern match on arbitrary values
- ```json
- "condition": {
- "path": {
- "part": "request.remoteIp",
- "pattern": "192.168.0.*",
- "options": {
- "caseSensitive": false,
- "separator": ".",
- "decodeAndParseSegments": false
- }
- }
- }
- ```
+ ### Boolean example
- ### bool — boolean logic (AND/OR)
```json
- "condition": {
+ {
"bool": {
"and": [
{ "authenticated": {} }
],
"or": [
- { "in": { "part": "context.principal.id", "value": ["dev1"] } },
- { "path": { "part": "request.remoteIp", "pattern": "10.0.0.*" } }
+ { "inRole": { "value": "admin" } },
+ { "in": { "part": "context.principal.id", "value": ["support-1", "support-2"] } }
]
}
}
```
- All items in `and` must match. At least one item in `or` must match. Both are optional — you can use just `and`, just `or`, or both.
- ### spel / ognl — expression-based
- ```json
- "condition": {
- "spel": {
- "expression": "#principal.attributes['vip'] == 'true'"
- }
- }
- ```
-
- ### rateLimiter — rate limiting
- ```json
- "condition": {
- "rateLimiter": {
- "permitsPerSecond": 10
- }
- }
- ```
- When the limit is exceeded the request fails with TOO_MANY_REQUESTS (HTTP 429) rather than a normal deny. `rateLimiter` creates a single limiter shared by all matching requests; `groupedRateLimiter` creates one per group value. Both are in-memory and per-JVM-instance — `cosec-cocache` provides **no** rate limiting (it only caches policies/permissions/tokens), so a limit shared across instances requires a custom ConditionMatcher backed by Redis or another shared store.
+ All `and` entries must match. If `or` is present, at least one entry must match. `and` and `or` are sibling fields.
- ### groupedRateLimiter — per-group rate limiting
- ```json
- "condition": {
- "groupedRateLimiter": {
- "part": "context.principal.id",
- "permitsPerSecond": 100,
- "expireAfterAccessSecond": 60
- }
- }
- ```
- The group is selected by `part` (any valid part path, e.g. `context.principal.id` for per-user limits). Both `permitsPerSecond` and `expireAfterAccessSecond` (idle expiry of each group's limiter) are required. Exceeding the limit yields TOO_MANY_REQUESTS.
+ ## Valid part paths
- ## Common Patterns
+ - `request.path`, `request.method`, `request.remoteIp`
+ - `request.origin`, `request.origin.host`, `request.referer`, `request.referer.host`
+ - `request.appId`, `request.spaceId`, `request.deviceId`
+ - `request.header.{name}`, `request.attributes.{key}`, `request.path.var.{name}`
+ - `context.tenantId`, `context.principal.id`, `context.principal.attributes.{key}`
- ### Public endpoints (no auth required)
- ```json
- {
- "id": "public-api",
- "name": "Public API",
- "type": "global",
- "tenantId": "(platform)",
- "statements": [
- {
- "name": "PublicEndpoints",
- "action": ["/auth/login", "/auth/register", "/health"]
- }
- ]
- }
- ```
+ Use singular `request.header`, and use `request.path.var.id` only after the action matcher captures `{id}`. An unsupported part throws during evaluation.
- ### Admin-only endpoints
- ```json
- {
- "id": "admin-api",
- "name": "Admin API",
- "type": "global",
- "tenantId": "(platform)",
- "statements": [
- {
- "name": "AdminOnly",
- "action": "/admin/**",
- "condition": { "inRole": { "value": "admin" } }
- }
- ]
- }
- ```
+ ## Security review
- ### User can only access their own resources
- ```json
- {
- "name": "OwnResourcesOnly",
- "action": "/api/users/{id}/**",
- "condition": {
- "eq": {
- "part": "request.path.var.id",
- "value": "#{principal.id}"
- }
- }
- }
- ```
+ Check the final policy for:
- ### IP whitelist with deny
- ```json
- {
- "name": "BlockExternalIp",
- "effect": "deny",
- "action": "*",
- "condition": {
- "regular": {
- "negate": true,
- "part": "request.remoteIp",
- "pattern": "^(10\\.0\\.0\\.|192\\.168\\.)"
- }
- }
- }
- ```
+ - accidental broad global allows that short-circuit stricter principal or role tiers;
+ - deny conditions whose negation is reversed;
+ - missing HTTP method constraints on state-changing endpoints;
+ - trusted-proxy assumptions behind `request.remoteIp`;
+ - tenant type (`inTenant`) being confused with tenant ID (`context.tenantId`);
+ - unsafe or needlessly complex expressions when a built-in matcher suffices;
+ - local limiters used where a cluster-wide quota is required.
- ### CORS origin restriction
- ```json
- {
- "name": "RestrictOrigin",
- "effect": "deny",
- "action": "*",
- "condition": {
- "regular": {
- "negate": true,
- "part": "request.origin",
- "pattern": "^https://(app\\.example\\.com|admin\\.example\\.com)"
- }
- }
- }
- ```
+ ## Validation
- ### Rate-limited authenticated API
- ```json
- {
- "name": "RateLimitedApi",
- "action": "/api/**",
- "condition": {
- "bool": {
- "and": [
- { "authenticated": {} },
- { "rateLimiter": { "permitsPerSecond": 10 } }
- ]
- }
- }
- }
- ```
+ Validate with CoSec's runtime parser, not JSON syntax alone. In a CoSec-based test:
- ### Health probe (for Kubernetes)
- ```json
- {
- "id": "(health-probe)",
- "name": "Health Probe",
- "type": "global",
- "tenantId": "(platform)",
- "statements": [
- {
- "name": "actuator",
- "action": [
- "/actuator/health",
- "/actuator/health/readiness",
- "/actuator/health/liveness"
- ]
- }
- ]
- }
+ ```kotlin
+ val policies = LocalPolicyLoader(setOf("classpath:cosec-policy/orders-policy.json")).policies
+ require(policies.isNotEmpty())
+ policies.forEach(DefaultPolicyEvaluator::evaluate)
```
- ## Validation Checklist
-
- When reviewing a policy, check:
-
- 1. **DENY before ALLOW** — DENY in any same-tier policy overrides ALLOW elsewhere; writing DENY statements first improves readability
- 2. **SpEL templates** — `#{principal.id}` is valid; `{principal.id}` is NOT (it is parsed as a path variable or literal)
- 3. **Path variables** — use `{varName}` syntax, access via `request.path.var.varName` in conditions
- 4. **Wildcard with conditions** — `"action": "*"` alone allows everything; always pair with a condition
- 5. **Negate logic** — every condition matcher accepts `negate: true` (handled centrally in `AbstractConditionMatcher`); `rateLimiter` is the only exception
- 6. **Bool structure** — `and` and `or` are sibling fields, not nested
- 7. **Part paths** — must be valid (singular `request.header.{name}`, not `request.headers.{name}`); invalid parts throw `IllegalArgumentException` at evaluation time
- 8. **Policy type** — `global` policies apply to all requests; `custom` policies are attached to specific users/roles
- 9. **groupedRateLimiter** — requires `part`, `permitsPerSecond`, and `expireAfterAccessSecond`; there is no `groupKey` field
+ `LocalPolicyLoader` logs and skips malformed policies, so assert that the expected policy ID loaded. `DefaultPolicyEvaluator` catches expected rate-limit and regex-timeout signals but surfaces other matcher configuration errors. Add one authorization test for each meaningful allow/deny boundary.