Skip to content

Extending CoSec ​

For developers who need behavior CoSec does not ship. This page lists every extension point of the 8.0 pipeline and control plane, what each one must guarantee, and how the starter picks it up. The guarantees come from the architecture invariants: an extension that blocks a thread or hides a failure weakens every decision.

Extension points ​

With the starter you extend CoSec by declaring a Spring bean. Some extension points collect every bean of their type; the others are single beans that replace the starter's default (@ConditionalOnMissingBean). Without Spring, assemble a Pipeline from PipelineComponents, with the documents from SuspendingSnapshotSource.of(store), and pass your implementation to the class that uses it.

Extension pointModule, packageImplement it toThe starter takes
Authenticatorcosec-spi, spi.authnAccept another kind of credential, or replace token and API key authentication.one bean, replacing the built-in one
TokenStore, RevocationReadercosec-spi, spi.tokenKeep revoked tokens somewhere other than Redis.one bean of each
ApiKeyReadercosec-spi, spi.apikeyLook API keys up somewhere other than memory or Redis.one bean
MembershipProvidercosec-spi, spi.membershipSupply the roles and attached policies of principals whose credential does not carry them: tokens with the provider marker, and every API key. Say whether a principal is known and active.one bean; required with API keys
MembershipLoadercosec-cocache, cacheLoad the memberships that CoCacheMembershipProvider caches in Redis for every instance.one bean, wrapped in CoCacheMembershipProvider when there is no MembershipProvider
AttributeProvidercosec-spi, spi.attributeGive conditions external attributes (ext.<provider>.<name>).every bean
AuditEventSinkcosec-spi, spi.auditReceive the flat audit record of each decision.every bean, as a BEST_EFFORT listener
RateLimitercosec-spi, spi.ratelimitCount rate limit permits somewhere other than Redis or memory.one bean, replacing the built-in one
SnapshotDocumentStorecosec-spi, spi.snapshotKeep policy documents somewhere other than Redis.one bean, without cosec-cocache
IdGeneratorcosec-spi, spi.idGive request, token and change ids your own format. cosec-cosid implements it over CosId 5.one bean, replacing RandomIdGenerator
Authenticationcosec-login, loginCheck another kind of login credential and say what its token holds. Throw to refuse.every bean, by the credential type it supports
TokenConvertercosec-login, login.tokenIssue tokens in another format.one bean, replacing JwtTokenConverter
SocialUserPrincipalConvertercosec-social, socialTurn a provider's user into your principal.one bean, replacing DirectSocialUserPrincipalConverter
BlacklistSourcecosec-runtime, runtime.attributeSay which principals, addresses and devices are blocked.one bean, wrapped in BlacklistAttributeProvider
DecisionListenercosec-runtime, runtime.eventObserve every decision: audit, alerting, analytics.every bean
BridgeContextContributorcosec-runtime, runtime.eventCarry a thread-local context (logging, tracing) into the pipeline.every bean
ChangeListenercosec-control, control.auditObserve or gate control-plane changes.every bean, with cosec-control
ControlStorecosec-control, control.storeKeep the control plane's records somewhere other than Redis.one bean, with cosec-control
Route mapscosec-runtime, runtime.routeGive requests service:verb actions on typed resources.cosec.authorization.routes.location

Packages are under me.ahoo.cosec. The ports whose signatures need only the model and the contracts live in cosec-spi, with their value types and in-memory implementations (InMemorySnapshotDocumentStore, InMemoryMembershipProvider, InMemoryApiKeyStore); it depends on cosec-model, cosec-api, cosec-annotations and kotlinx-coroutines only, so an implementation of one of them needs nothing else from CoSec. A port whose signature carries a layer's own types stays with that layer: DecisionListener takes the runtime's DecisionEvent, the shadow window ports (CandidateStore, ShadowReportSink, ShadowReportReader) the runtime's candidates and reports, and ControlStore and ChangeListener the control plane's records and events.

Each of these interfaces carries @CoSecSpi: changing one, rather than implementing it, breaks every application that implements it (Stability levels).

The adapters themselves call a RequestGate (cosec-runtime): the Pipeline, or, on a service behind a CoSec gateway (cosec.pipeline.mode=gateway), IdentityGate. It is not an extension point: both build every outcome through StatusMapping, and a gate of your own would be a second decision path. The compiler holds this: implementing RequestGate requires the @InternalCoSecApi opt-in (@SubclassOptInRequired), which applications never give.

Stability levels ​

Every public declaration of a published module is in one of four levels. The markers are in cosec-annotations (me.ahoo.cosec.annotations):

MarkerWhat it promisesWhat you do
noneStable. From 8.0 GA on, removing or changing it is a breaking change, made only in a major release.Use it.
@CoSecSpiAn interface applications implement, not only call: the extension points above, and the capabilities a principal declares (ExpiresAtCapable, IssuedAtCapable, MembershipMarkerCapable). Stable like the rest, and a member added in a minor release comes with a default body, so an implementation keeps compiling.Implement it.
@ExperimentalCoSecApiPublic, but it may change or go away in a minor release; the release notes say so. Today: the public API of cosec-testkit, that is PolicyTestKit, PipelineFixture, EngineOutcome and their exceptions.Opt in with @OptIn(ExperimentalCoSecApi::class), or the compiler warns.
@InternalCoSecApiPublic only so that CoSec's own modules can call each other. No promise: it may change in any release.Never use it. Opting in is required at error level, and only CoSec's own sources do.

The build keeps these promises. Every published library module stores its public ABI in a dump file, api/<project>.api in its directory (core/spi/api/spi.api for cosec-spi), and check runs the Kotlin Gradle plugin's checkKotlinAbi, which fails when the compiled module no longer matches its dump. cosec-migrate keeps no dump: it is a command-line tool, its contract is the command line, and its classes are not a library API. @InternalCoSecApi declarations are left out of the dumps; experimental ones are in them, so their changes show in review too. A dump lists signatures, not annotations, so which interfaces are SPIs is read from the sources, the API docs and this page, and SpiMarkerTest (test/architecture) fails when a public interface of cosec-spi, or a type the table above names, lacks @CoSecSpi.

To change a public signature on purpose, regenerate the module's dump and commit it with the change:

bash
./gradlew :spi:updateKotlinAbi

Review the dump's diff like code: an added line is a new API; a removed or changed line breaks the callers or the implementations of that declaration. From 8.0 GA on, a pull request that removes or changes a line of a dump is a breaking change and its title carries ! (CONTRIBUTING.md). The one exception is a line of an @ExperimentalCoSecApi declaration: removing or changing it is not a breaking change and needs no !, and the pull request description says which experimental declarations it changes.

Sets that grow ​

Exhaustive matching over a closed set stops compiling when a minor release adds a member, and an already compiled binary throws NoWhenBranchMatchedException on the new value. So every extension treats these as open: a when over one has an else, and a listener or store never relies on the set being complete.

SetGrows by
AuthnResult.RejectReasona new reason, such as a new token check
Attribute.Kinda new catalog attribute
ProblemTypea new problem, such as 413 or 415
Obligationa new kind of obligation
ChangeEvent.Kinda new kind of change event

A set not listed here does not gain a member after 8.0 GA. A public data class is only ever a value that cannot grow, a record with a fixed shape (PublicApiStabilityTest reads the reviewed list); a record that can grow is a plain class, so a new field is a new constructor.

Rules every extension follows ​

The pipeline calls every SPI on the request path, often on a Netty event loop. The login contracts run on your login endpoint, on the same threads. Every SPI here follows the same contract:

  • Never block the calling thread. The SPIs are suspend functions (the snapshot store returns CompletableFutures). A suspending client is fine. An implementation over a blocking client (JDBC, a blocking HTTP client) moves the call to its own bounded executor with runInterruptible(myDispatcher) { ... }, sized for that dependency: Dispatchers.IO.limitedParallelism(n) or an executor of your own, as JustAuthProvider does. A bare Dispatchers.IO is shared with everything else in the process, and RuntimeBoundaryTest forbids it in cosec-runtime.
  • Stop when cancelled. Timeouts are cooperative: at an SPI's timeout, or at cosec.pipeline.timeout, the pipeline cancels the call and waits for it to return before it answers 503. withContext(myDispatcher) cannot stop a blocking call, so a JDBC query that hangs for 30 seconds holds the request for 30 seconds, not for its 300 ms timeout, and on WebMvc it holds a request thread too. runInterruptible interrupts the thread on cancellation; give the client its own timeouts as well (a JDBC query timeout, an HTTP read timeout) for calls that ignore interruption.
  • Report failure by throwing. Never answer "nothing" for a lookup that failed: an empty membership list, a missing attribute or "not revoked" can each turn a deny into a permit. The pipeline turns a thrown exception or a timeout into Indeterminate, which answers 503 unless a DENY matches.
  • Answer ids that are text. An id an SPI gives (a principal, a tenant, a credential id, a role, a space, a policy id) must not hold an unpaired UTF-16 surrogate: a Redis key would hold ? in its place and share another id's key (IDN-10). AuthenticatedPrincipal and ApiKey refuse one when made, so an authenticator or an API key store that gives one throws, and a MembershipProvider answer with one fails: each is a 503. Identifiers.isWellFormed (cosec-model) is the test.
  • Do not catch cancellation. A client that goes away cancels the request's coroutine. Rethrow CancellationException, and do not use runCatching around a suspending call.
  • Keep it cheap. Each call has a timeout (see Configuration); the whole request has cosec.pipeline.timeout. A local answer that does not suspend costs no thread switch and no timer.

Authenticators ​

Authenticator turns a request's credentials (Credentials.token, Credentials.apiKey) into an AuthnResult:

ResultWhenWhat happens
Authenticated(principal, method)The credential is valid.The request is evaluated as that principal; method goes to audit.
AnonymousNo credential.The request is evaluated as the anonymous principal.
Rejected(reason)The credential is invalid, expired or revoked.The request is evaluated as anonymous; a deny answers 401 with that reason.
Unavailable(cause)A dependency failed.503. A thrown exception is treated the same way.

An AuthenticatedPrincipal names its id, tenant, app (the app its credential declares, Apps; left out, none), membership source (the token's claims, or the membership provider), attributes and credential. Conditions read the attributes as subject.attributes.<name>. credential (Identity.Credential: method JWT or API_KEY, the credential's id and its expiry, never its value) is what a gateway forwards with the identity: on a gateway, a principal without one is permitted but forwarded to no backend as authenticated, and one whose credential has no expiry fails the request with 500.

Whether break-glass bindings apply is decided by provenance: only a token CoSec's own JwtTokenVerifier verified gets them, through TokenAuthenticator. A principal your authenticator returns, with a credential of its own method (such as Identity.Method("ldap")) or none, never gets break-glass, and nor does a token a TokenVerifier of your own accepts, even through TokenAuthenticator (its credential is then named after the authenticator's method, token by default). The method JWT is reserved for CoSec's token: an AuthenticatedPrincipal you construct with it is refused (IllegalArgumentException, a 503), so no authenticator can pass for CoSec's token by its name (BRK-8).

The control plane reads two more facts of the principal for approvals (Testing policies). apiKey: CoSec's API key authenticator sets it, and a credential of the method API_KEY counts as one whoever made it; an API key never approves and is never the trust root. A principal of yours is a person's to CoSec, so give an authenticator of workloads neither cosec:approve nor cosec:platform-admin. The credential's id: an approval records it, and one without an id does not count. A principal you construct has no issuedAt, so any revocation of its tokens (subjectRevokedBefore) takes its approvals back.

An authenticator sees only what the pipeline reads as credentials: the token (the Authorization header, else the query parameter when cosec.pipeline.allow-query-token is on, else the cookie when cosec.pipeline.allow-cookie-token is on) and the API key header (cosec.pipeline.api-key-header).

The starter's default is a CompositeAuthenticator: a request with an API key is judged by the API key authenticator alone (when cosec.api-key.enabled), any other request by the JWT TokenAuthenticator. Your Authenticator bean replaces all of it. To accept another token format and keep the built-in ones, build them yourself and delegate:

kotlin
@Bean
fun cosecAuthenticator(algorithm: Algorithm, revocations: RevocationReader, keys: ApiKeyReader): Authenticator {
    val builtIn = CompositeAuthenticator(
        TokenAuthenticator(JwtTokenVerifier(algorithm), revocations),
        ApiKeyAuthenticator(keys),
    )
    val sessions = SessionTokenAuthenticator() // your own Authenticator for "Session ..." tokens
    return Authenticator { credentials ->
        if (credentials.token?.startsWith("Session ") == true) {
            sessions.authenticate(credentials)
        } else {
            builtIn.authenticate(credentials)
        }
    }
}

A TokenVerifier of your own returns a VerifiedToken (me.ahoo.cosec.spi.token), an immutable value built from the claims it verified: id, principal, tenant, app (the token's aud), roles, policies, attributes, its times and membership marker. Its constructor throws TokenVerificationException for claims that no token may carry (an id beginning with (, an empty tenant, an unpaired surrogate, an impossible app), so verify refuses them whichever verifier read them.

Give TokenAuthenticator the plain verifier (JwtTokenVerifier), never a RevocableTokenVerifier or another wrapper: revocation is checked through the suspending RevocationReader, and only the plain verifier's tokens may use break-glass. A token with an act claim must never become a principal (IDN-4): a TokenVerifier of your own throws DelegationUnsupportedException for it, which TokenAuthenticator turns into a token-invalid refusal, and an Authenticator of your own that reads tokens itself returns AuthnResult.Rejected(DELEGATION_UNSUPPORTED) instead of a principal for its sub. How tokens, claims and memberships work for users is in Authentication.

Credentials and revocation ​

Extension pointContract
TokenStoreThe write side of revocation, used by logout: revoke(tokenId, ttl), revokeSubject(tenant, principal, issuedBefore, ttl). An entry must outlive the refresh token it revokes. A store that cannot revoke every token of a principal throws from revokeSubject, so an emergency revocation never silently does nothing.
RevocationReaderThe read side, called on every authenticated request: isRevoked(tokenId), subjectRevokedBefore(tenant, principal). Both suspend and must not block.
ApiKeyReaderfindByHash(hash): ApiKey?, suspending. Return null for an unknown key; throw when the lookup fails (the request answers 503).

With cosec-cocache, the starter wires CoCacheTokenStore and its reader. Without it, JWT fails startup unless you register a TokenStore and a RevocationReader (or set cosec.jwt.enabled=false). Registering RevocationReader.NONE is an explicit choice to revoke nothing.

The revocation read is one of the two documented exceptions to failing closed: a store whose degraded read cannot answer reports "not revoked" itself. Anything a RevocationReader throws still answers 503. The exceptions are listed in the security model.

Memberships ​

A token carries a principal's roles and attached policies as claims, or, when they change often or do not fit, the provider marker instead. An API key never carries them (RFC 0009 §4). For those principals the pipeline asks the MembershipProvider:

kotlin
fun interface MembershipProvider {
    suspend fun memberships(tenant: TenantId, principal: PrincipalId): MembershipAnswer
}
  • Answer MembershipAnswer.Known(Memberships(Memberships.Source.PROVIDER, roles, attachments)): roles by space (SpaceId.DEFAULT for the default space) and attached policy ids, in order. Any other source is rejected.
  • Answer MembershipAnswer.Unknown for a principal you do not know, and memberships with active = false for one that is deactivated: the credential is then refused (token-invalid), as if its authenticator had rejected it (IDN-3). Do not answer empty memberships for an unknown principal: that is a known principal with no roles, and its API keys would keep working. A sealed answer, so that a provider has to say which it means.
  • Throw when the lookup fails. A failure, a timeout (cosec.authorization.membership.timeout) or a missing provider makes the request 503, never "unknown" and never "no roles": no roles would also mean that no DENY bound to a role applies.
  • An API key's cap (MembershipSource.Provider.cap) is applied by the pipeline's MembershipResolver, after your answer: answer the principal's memberships, never a key's.

InMemoryMembershipProvider (cosec-spi) is for tests; it answers Unknown for a principal it was not given. CoCacheMembershipProvider (cosec-cocache) caches what your MembershipLoader answers in Redis, shared by every instance; MembershipLoader.load follows the same contract, Unknown is not cached, and a load that takes longer than its fill timeout (cosec.authorization.membership.timeout in the starter) fails, so that the next request retries. Call its invalidate when a principal's memberships change. The starter registers CoCacheMembershipProvider when you declare a MembershipLoader bean and no MembershipProvider; otherwise declare the one you want as a bean. With API keys on, one is required (IDN-6).

External attributes ​

Conditions read the request, the principal and the scope directly. Anything else (a department from a directory, a risk score, the client's region) comes from an AttributeProvider:

kotlin
@Component
class DirectoryAttributeProvider(private val directory: DirectoryClient) : AttributeProvider {
    override val attributes = listOf(AttributeDeclaration("ext.directory.department", AttributeType.STRING))

    override val timeout: Duration = 200.milliseconds // kotlin.time; null uses cosec.authorization.attributes.timeout

    override suspend fun fetch(request: AttributeRequest, names: Set<String>): Map<String, Any> {
        if (!request.subject.authenticated) {
            return emptyMap() // absent: a comparison with it is false
        }
        val department = directory.departmentOf(request.tenant.value, request.subject.id) // suspends, throws on failure
        return if (department == null) emptyMap() else mapOf("ext.directory.department" to department)
    }
}
json
{ "eq": ["ext.directory.department", "finance"] }
  • Declare every attribute as ext.<provider>.<name> with its type: STRING, BOOL, STRING_SET or IP. Each path is declared once across all providers. The declarations form the attribute catalog that documents compile against: a policy that reads an ext. path nobody declares does not compile. A platform document may also declare the catalog, so the control plane and every instance agree.
  • Register the same providers on every host. The control plane checks a change against the platform's declarations and its own host's providers, and every instance compiles against its own. A provider missing on one instance makes a change it reads fail there: the instance reports CANDIDATE_ERROR and blocks the shadow window (Deploying the gateway server).
  • Values. Return values by path, of the declared type. A path left out is absent. A value of the wrong type, a thrown exception or a timeout makes the attributes asked for unavailable, and each statement that reads them follows its onMissing setting (Policy language).
  • Stages. A stage-B attribute (the default) is fetched only when the engine's plan says this request could read it, after the scope is resolved. A stage-A attribute is fetched on every request, before the scope, together with the memberships. Use stage A only for attributes that decide what is loaded, as the blacklist does. An AuthorizationChecker check asks for what its own action and resource can read, in both stages: a stage-A provider is asked again then, still without the scope.
  • What a provider sees. AttributeRequest holds the tenant, the subject, the resolved scope (null in stage A), the action, the resource, the client address, the request id and the device id, never another attribute's value.

The blacklist is a stage-A provider: BlacklistAttributeProvider(source) declares ext.blacklist.principal, ext.blacklist.ip and ext.blacklist.device, and a request one of them blocks meets the built-in DENY cosec:blacklist. Implement BlacklistSource.contains(kind, value) over your store (principals are <tenant>:<principal id>), or use InMemoryBlacklistSource, and declare the source as a bean: the starter wraps it in a BlacklistAttributeProvider. Without one, startup logs No BlacklistSource bean: requests are not checked against a blacklist (ext.blacklist.*). at INFO. A source that fails never means "not blocked": the request is 503 unless a DENY matches.

With cosec-ip2region, the starter registers Ip2RegionAttributeProvider (ext.ip2region.country, .province, .city, .isp).

Decision listeners ​

A DecisionListener observes every DecisionEvent: the outcome and status, the decision and the statement and binding that decided it, the document revisions, the subject, scope, action and resource, the status (never the value) of each external attribute, and the trace. It cannot change the decision.

kotlin
@Component
class BreakGlassAlerts(private val pager: PagerClient) : DecisionListener {
    override val name = "break-glass-alerts" // unique among the listeners

    override suspend fun onDecision(event: DecisionEvent) {
        if (event.breakGlass) {
            pager.page("break-glass used by ${event.subject.tenant}/${event.subject.id}")
        }
    }
}
deliveryDeliveredA failure
BEST_EFFORT (default)Queued without suspending into the listener's own bounded queue, handled off the request path. A full queue drops the event.Logged and counted; the response is unchanged.
REQUIREDBefore the response, together with the other REQUIRED listeners, within cosec.observability.listener.required-timeout.Turns a permit into 503; any other outcome keeps its status.
  • A permit through a break-glass binding needs at least one REQUIRED listener, or it is a 503.
  • A REQUIRED listener should be idempotent by event.id: a write that timed out may still have succeeded.
  • Every DecisionListener bean is registered, in @Order order, and so is every AuditEventSink bean that is not also a listener, as a BEST_EFFORT listener named after the bean. The starter adds the audit log (LoggingAuditListener, unless cosec.audit.enabled=false), and, with their features, MetricsListener, TracingListener, KafkaAuditListener and the shadow listener. Events, metrics and spans are described in Observability.
  • event.evaluationTrace holds the engine's trace at cosec.pipeline.trace-level; it is null at off, the default.
  • On shutdown, queued events are still delivered for up to cosec.observability.listener.drain-timeout.

Rate limiters ​

RateLimiter counts the permits of a permit's rate limit obligations:

kotlin
fun interface RateLimiter {
    suspend fun tryAcquire(ruleId: String, bucket: String, limit: RateLimitSpec): RateLimitResult
}

Spend one permit of limit from bucket of rule ruleId and report what is left; RateLimiter.window(limit) gives the sliding window both built-in limiters use, and its resultOf(...) builds the result. Throw when the backend fails: the rule's strictFailure then decides between 503 and skipping the rule. With cosec-cocache and a ReactiveStringRedisTemplate, the starter registers ReactiveRedisRateLimiter, which counts across instances; otherwise the per-instance LocalRateLimiter. Your bean replaces both. Rules, buckets and headers are in Rate limiting.

Snapshot document stores ​

SnapshotDocumentStore is where SnapshotSource reads the platform, app and tenant documents:

  • read(key) may answer from a local copy (report its maximum age as localCopyMaxAge); readIfChanged(key, known) must read the store of record and answer Unchanged when the digest matches; subscribe(listener) reports changes after they are readable.
  • Every method returns without blocking and never throws: a failure completes the future exceptionally. A stored value that cannot be decoded carries a SnapshotFormatException in its cause chain.
  • Never delete a document. A key that once had a document and then reads as absent fails closed.
  • A digest is a SnapshotDigest: 32 bytes of SHA-256, named by its algorithm, and nothing else (a SHA-1, a version number or an ETag is refused when made, so every read fails closed). Hash the stored bytes, or whatever changes when the document does; only your store interprets it. Its text form, in the control API and in events, is SHA-256:<64 hex>.

With cosec-cocache, the starter uses RedisSnapshotDocumentStore, and the control plane writes through the same Redis. Without it, the starter registers an InMemorySnapshotDocumentStore; put documents into it with put(key, document), or declare your own SnapshotDocumentStore bean instead. Requests fail closed until a platform document is there.

Ids ​

IdGenerator (generate(): String) makes the ids of what CoSec creates: request ids (CoSec-Request-Id, and the rid a gateway forwards in CoSec-Identity), decision event ids, token ids (jti) and control-plane change ids. Without one of yours, CoSec uses RandomIdGenerator: random version 4 UUIDs drawn from ThreadLocalRandom, which never blocks, rather than from SecureRandom, which can.

  • An id is unique across every instance that shares the stores, and is 1 to 64 characters of A-Z a-z 0-9 . _ -: it goes into response headers, URLs and Redis keys.
  • generate runs on every request, on the request's thread, an event loop included, before there is an outcome: it never blocks and never throws.
  • An id is not a secret and need not be unpredictable: a token is signed, and a request id only correlates logs and audit events. The OAuth state of social login and the secret of an API key, which must be unpredictable, never come from it: CoSec draws both from SecureRandom itself.

CoSec's core depends on no id library. For CosId 5, add cosec-cosid. When the application has CosId's CosIdRuntime and no IdGenerator of its own, the starter gives CoSec the ids of the generator cosec.cosid.generator names, CosId's default unless set, as that generator's codec writes them (CosIdIdGenerator). A generator whose ids break the first rule above fails startup.

kotlin
implementation("me.ahoo.cosec:cosec-cosid")
implementation("me.ahoo.cosid:cosid-spring-boot-starter") // 5.x, with the cosid.* configuration of your generators

Any other source of ids is one bean:

kotlin
@Bean
fun cosecIdGenerator(ids: MyIds): IdGenerator = IdGenerator { ids.next() }

Pick a generator that never waits for its store, such as a Snowflake one, since generate runs on the event loop. Without Spring, pass it as PipelineComponents(idGenerator = …), IdentityGate(idGenerator = …) and the first argument of JwtTokenConverter.

Bridge context ​

The adapters start each request's coroutine with a context captured on the thread that received it. A BridgeContextContributor adds to that context, so a thread-local your code relies on is restored whenever the pipeline resumes on another thread:

kotlin
fun interface BridgeContextContributor {
    fun capture(): CoroutineContext
}

The starter registers MdcContextContributor (the SLF4J MDC) and, with cosec-opentelemetry, the OpenTelemetry context. Every BridgeContextContributor bean is used, in @Order order. capture runs once per request: keep it cheap.

Change listeners ​

With cosec-control, a ChangeListener receives a ChangeEvent for every step of a change: proposed, checked, shadowed, approved, publishing, published, break-glass requested or approved, and so on. An event never carries document content.

  • A REQUIRED listener gates publishing and every step of creating break-glass: the step happens only after every REQUIRED listener recorded it within cosec.control.audit.required-timeout (5 s), otherwise it fails with 503 and writes nothing. Creating break-glass needs at least one REQUIRED listener.
  • Other events, and every event to a BEST_EFFORT listener, are delivered off the caller; a failure is logged.
  • Listeners add up: every ChangeListener bean is registered next to the starter's LoggingChangeListener, which backs off only for a LoggingChangeListener bean of yours.

With cosec-kafka and a KafkaOperations bean, the starter registers KafkaChangeListener under cosec.control.audit.kafka.* (Observability).

Control store ​

With cosec-control, the control plane keeps revisions, change requests, staging slots, candidates and leases in a ControlStore; the starter's is CoCacheControlStore on Redis. Besides reading and writing those records by key, a store answers what the maintenance passes visit:

  • documents(): every document it has indexed revisions for. It may name more than exist, never fewer.
  • openChanges(): the change requests that are not terminal, as the store holds them now, each read once (#1219): maintenance goes by this read and reads a change again only to expire it. Every written change that is not terminal is among them; one listed and not written yet (a first write in flight) is not. A listed change whose record does not decode fails openChanges() as it fails change(id): it cannot be known to be terminal, so maintenance stops until an operator removes its record.
  • acquireLease(name, holder, duration) and releaseLease(name, holder): one instance at a time per task. A release removes the lease only while holder still has it, checked and removed in one atomic step (CoCache's deleteIfMatch): a lease that expired and another instance took meanwhile stays theirs.
  • withdrawCandidate(key, window): removes a document's candidate only while it is still the one of window, in one atomic step (CTL-26). A change withdraws its candidate once it is terminal, when another may already have staged its own.
  • stagedCandidate(key) and putCandidate(key, expected, candidate): the candidate with the digest a write must name, and a write that lands only over the candidate that was read (null: only if there is none), false when it changed. A stage reads, checks it still holds the document's staging slot, and writes over what it read; a change preempted meanwhile finds its late write refused, and so never replaces the candidate of the change that took the slot (CTL-6). A write whose outcome is unknown is resolved by reading back, never by writing again.
  • referrers and putReferrers: each referring document with the revision of it that last indexed the reference, so that a revision's late removal never takes back what a later revision indexed again (CTL-25).
  • RevisionIndex.reclaiming: the records reclamation took out of the index and has not yet seen deleted; a store keeps it with the index, as it keeps the rest of it.

Like the data plane's stores, a read that fails throws: it never answers "absent".

Route maps ​

By default a request's action is http:<METHOD> and its resource http:path:<path>. A route map gives a service's routes their own actions and typed resources (order:read on order/{orderId}), so policies are written in the service's terms. Mapped requests get the route's action; every other request keeps the http namespace.

json
{
  "service": "order",
  "routes": [
    { "method": "GET", "path": "/orders/{orderId}", "action": "order:read", "resource": "order/{orderId}" },
    { "method": "POST", "path": "/orders/{orderId}/cancel", "action": "order:cancel", "resource": "order/{orderId}" }
  ]
}
  • The starter loads the route maps listed in cosec.authorization.routes.location (comma-separated) at startup. A missing or invalid map, a map with no routes, or two routes one request could match, fails startup. Unset, the service loads none, unless classpath:cosec-routes.json exists: startup then fails until the property names it or is set empty. A gateway host loads none.
  • With cosec-openapi, springdoc and Spring Boot Actuator, the cosecServiceGenerator endpoint generates both documents from the OpenAPI document springdoc scans: routes is the route map to ship as cosec-routes.json, app is the app document (catalog and permission policies) to publish through the control plane.

The file is read strictly: an unknown or missing field fails it (RouteMapCodec.kt). App documents and catalogs are in Role permissions.

Next: how to propose and land a change, in CONTRIBUTING.md, and the build rules and invariants for changing CoSec, in AGENTS.md.

Licensed under the Apache License, Version 2.0.