cosec-custom-matcher · git:20261020.1bd0c8b · 2026-10-20 · sha256 c456a83a6e6722c1

cosec-custom-matcher git:20261020.1bd0c8bA

Immutable. This exact content never changes and is served at /api/v1/blob/c456a83a6e6722c1.

---
name: cosec-custom-matcher
description: Extend CoSec with custom ActionMatcher or ConditionMatcher implementations and register their factories through ServiceLoader or Spring. Do not use for policies that built-in matchers already express.
---

# CoSec Custom Matchers

Add a matcher only after confirming that the built-ins cannot express the rule. Prefer composing `path`, `bool`, and part matchers over new code. For quotas, write a rate limit rule (`cosec.limiter.locations`) instead of a stateful matcher, or implement the `RateLimiter` / `RateLimitRuleProvider` SPIs in `me.ahoo.cosec.limiter` to change where counters or rules live.

## Extension contract

- `ActionMatcher` and `ConditionMatcher` live in `cosec-api` and extend `RequestMatcher`.
- Their factory interfaces and providers live in `cosec-core` under `me.ahoo.cosec.policy.action` and `me.ahoo.cosec.policy.condition`.
- A factory's `type` is the object key used in policy JSON. It must not collide with another factory; later registration replaces the existing entry.
- Do not change the SPI interfaces to add a matcher. Implement them and register the factory.
- Keep matching synchronous and side-effect free. Authorization is a pure decision: matchers run for denied requests too, so a stateful control such as a quota belongs in `RateLimitingAuthorization`, not in a matcher.
- Path variables are scoped to their statement. A matcher that reads them in code (`securityContext.getPathVariables()`) is not caught by the load-time `PathVariableScope` check, which only scans configuration strings; at run time it sees only the variables captured by its own statement's `path` action, and an empty map in policy/app permission conditions, under any other action, or inside an action matcher.
- If a custom matcher has an unavoidable external side effect, skip it when `securityContext.isPolicyEvaluation` (`me.ahoo.cosec.policy`) is `true`: saving a policy validates it by running each condition once against a mock request.

Before editing, inspect the target CoSec version's interfaces and the nearest built-in matcher. In a CoSec checkout, the authoritative files are:

- `cosec-api/src/main/kotlin/me/ahoo/cosec/api/principal/RequestMatcher.kt`
- `cosec-core/src/main/kotlin/me/ahoo/cosec/policy/action/`
- `cosec-core/src/main/kotlin/me/ahoo/cosec/policy/condition/`

## Minimal condition matcher

Use `AbstractConditionMatcher` when `negate` support is desirable. Parse and validate configuration once during construction, not on every request.

```kotlin
class PremiumUserConditionMatcher(configuration: Configuration) :
    AbstractConditionMatcher(PremiumUserConditionMatcherFactory.TYPE, configuration) {

    private val requiredTier = configuration.getRequired("tier").asString()

    override fun internalMatch(request: Request, securityContext: SecurityContext): Boolean {
        val tier = securityContext.principal.attributes["tier"]?.toString()
        return tier == requiredTier
    }
}

class PremiumUserConditionMatcherFactory : ConditionMatcherFactory {
    companion object {
        const val TYPE = "premiumUser"
    }

    override val type: String = TYPE

    override fun create(configuration: Configuration): ConditionMatcher =
        PremiumUserConditionMatcher(configuration)
}
```

Register it for non-Spring and Spring environments with:

```text
# src/main/resources/META-INF/services/me.ahoo.cosec.policy.condition.ConditionMatcherFactory
com.example.cosec.PremiumUserConditionMatcherFactory
```

```json
{
  "action": "/api/premium/**",
  "condition": {
    "premiumUser": { "tier": "gold" }
  }
}
```

For an action matcher, use the same pattern with `AbstractActionMatcher`, `ActionMatcherFactory`, and:

```text
src/main/resources/META-INF/services/me.ahoo.cosec.policy.action.ActionMatcherFactory
```

## Spring registration

When a factory needs Spring-managed dependencies, expose it as a bean instead of adding a service file:

```kotlin
@Bean
fun premiumUserConditionMatcherFactory(): ConditionMatcherFactory =
    PremiumUserConditionMatcherFactory()
```

`MatcherFactoryRegister` registers all action and condition factory beans at startup. Do not register the same type through both mechanisms unless replacement is intentional.

## Useful API surface

Read configuration through `get`/`getRequired` followed by `asString`, `asBoolean`, `asInt`, `asLong`, `asDouble`, `asList`, or `asMap`.

Request matching commonly uses `path`, `method`, `remoteIp`, `origin`, `referer`, `appId`, `spaceId`, `deviceId`, `requestId`, headers, queries, cookies, and request attributes. Context matching commonly uses principal ID/authentication/roles/policies/attributes, tenant ID, and context attributes. Inspect the target interfaces rather than assuming fields from another CoSec version.

## Existing type names

Do not replace these unless the user explicitly wants an override:

- Actions: `all`, `path`, `composite`
- Conditions: `all`, `authenticated`, `inRole`, `inTenant`, `eq`, `contains`, `startsWith`, `endsWith`, `in`, `regular`, `path`, `bool`, `spel`, `ognl`, `time`

## Verification

Add one focused test that creates the matcher through its factory and checks a match and non-match. If registration changed, also deserialize one policy using the custom type so a missing service entry or Spring bean fails the test. Run the narrow module test and the repository's static analysis command.