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 point | Module, package | Implement it to | The starter takes |
|---|---|---|---|
Authenticator | cosec-spi, spi.authn | Accept another kind of credential, or replace token and API key authentication. | one bean, replacing the built-in one |
TokenStore, RevocationReader | cosec-spi, spi.token | Keep revoked tokens somewhere other than Redis. | one bean of each |
ApiKeyReader | cosec-spi, spi.apikey | Look API keys up somewhere other than memory or Redis. | one bean |
MembershipProvider | cosec-spi, spi.membership | Supply 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 |
MembershipLoader | cosec-cocache, cache | Load the memberships that CoCacheMembershipProvider caches in Redis for every instance. | one bean, wrapped in CoCacheMembershipProvider when there is no MembershipProvider |
AttributeProvider | cosec-spi, spi.attribute | Give conditions external attributes (ext.<provider>.<name>). | every bean |
AuditEventSink | cosec-spi, spi.audit | Receive the flat audit record of each decision. | every bean, as a BEST_EFFORT listener |
RateLimiter | cosec-spi, spi.ratelimit | Count rate limit permits somewhere other than Redis or memory. | one bean, replacing the built-in one |
SnapshotDocumentStore | cosec-spi, spi.snapshot | Keep policy documents somewhere other than Redis. | one bean, without cosec-cocache |
IdGenerator | cosec-spi, spi.id | Give request, token and change ids your own format. cosec-cosid implements it over CosId 5. | one bean, replacing RandomIdGenerator |
Authentication | cosec-login, login | Check another kind of login credential and say what its token holds. Throw to refuse. | every bean, by the credential type it supports |
TokenConverter | cosec-login, login.token | Issue tokens in another format. | one bean, replacing JwtTokenConverter |
SocialUserPrincipalConverter | cosec-social, social | Turn a provider's user into your principal. | one bean, replacing DirectSocialUserPrincipalConverter |
BlacklistSource | cosec-runtime, runtime.attribute | Say which principals, addresses and devices are blocked. | one bean, wrapped in BlacklistAttributeProvider |
DecisionListener | cosec-runtime, runtime.event | Observe every decision: audit, alerting, analytics. | every bean |
BridgeContextContributor | cosec-runtime, runtime.event | Carry a thread-local context (logging, tracing) into the pipeline. | every bean |
ChangeListener | cosec-control, control.audit | Observe or gate control-plane changes. | every bean, with cosec-control |
ControlStore | cosec-control, control.store | Keep the control plane's records somewhere other than Redis. | one bean, with cosec-control |
| Route maps | cosec-runtime, runtime.route | Give 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):
| Marker | What it promises | What you do |
|---|---|---|
| none | Stable. From 8.0 GA on, removing or changing it is a breaking change, made only in a major release. | Use it. |
@CoSecSpi | An 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. |
@ExperimentalCoSecApi | Public, 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. |
@InternalCoSecApi | Public 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:
./gradlew :spi:updateKotlinAbiReview 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.
| Set | Grows by |
|---|---|
AuthnResult.RejectReason | a new reason, such as a new token check |
Attribute.Kind | a new catalog attribute |
ProblemType | a new problem, such as 413 or 415 |
Obligation | a new kind of obligation |
ChangeEvent.Kind | a 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
suspendfunctions (the snapshot store returnsCompletableFutures). A suspending client is fine. An implementation over a blocking client (JDBC, a blocking HTTP client) moves the call to its own bounded executor withrunInterruptible(myDispatcher) { ... }, sized for that dependency:Dispatchers.IO.limitedParallelism(n)or an executor of your own, asJustAuthProviderdoes. A bareDispatchers.IOis shared with everything else in the process, andRuntimeBoundaryTestforbids it incosec-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 answers503.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.runInterruptibleinterrupts 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 answers503unless 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).AuthenticatedPrincipalandApiKeyrefuse one when made, so an authenticator or an API key store that gives one throws, and aMembershipProvideranswer with one fails: each is a503.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 userunCatchingaround 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:
| Result | When | What happens |
|---|---|---|
Authenticated(principal, method) | The credential is valid. | The request is evaluated as that principal; method goes to audit. |
Anonymous | No 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:
@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 point | Contract |
|---|---|
TokenStore | The 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. |
RevocationReader | The read side, called on every authenticated request: isRevoked(tokenId), subjectRevokedBefore(tenant, principal). Both suspend and must not block. |
ApiKeyReader | findByHash(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:
fun interface MembershipProvider {
suspend fun memberships(tenant: TenantId, principal: PrincipalId): MembershipAnswer
}- Answer
MembershipAnswer.Known(Memberships(Memberships.Source.PROVIDER, roles, attachments)): roles by space (SpaceId.DEFAULTfor the default space) and attached policy ids, in order. Any other source is rejected. - Answer
MembershipAnswer.Unknownfor a principal you do not know, and memberships withactive = falsefor 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 request503, 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'sMembershipResolver, 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:
@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)
}
}{ "eq": ["ext.directory.department", "finance"] }- Declare every attribute as
ext.<provider>.<name>with its type:STRING,BOOL,STRING_SETorIP. Each path is declared once across all providers. The declarations form the attribute catalog that documents compile against: a policy that reads anext.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_ERRORand 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
onMissingsetting (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
AuthorizationCheckercheck 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.
AttributeRequestholds 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.
@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}")
}
}
}delivery | Delivered | A 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. |
REQUIRED | Before 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
DecisionListenerbean is registered, in@Orderorder, and so is everyAuditEventSinkbean that is not also a listener, as aBEST_EFFORTlistener named after the bean. The starter adds the audit log (LoggingAuditListener, unlesscosec.audit.enabled=false), and, with their features,MetricsListener,TracingListener,KafkaAuditListenerand the shadow listener. Events, metrics and spans are described in Observability. event.evaluationTraceholds the engine's trace atcosec.pipeline.trace-level; it is null atoff, 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:
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 aslocalCopyMaxAge);readIfChanged(key, known)must read the store of record and answerUnchangedwhen 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
SnapshotFormatExceptionin 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, isSHA-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. generateruns 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
stateof social login and the secret of an API key, which must be unpredictable, never come from it: CoSec draws both fromSecureRandomitself.
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.
implementation("me.ahoo.cosec:cosec-cosid")
implementation("me.ahoo.cosid:cosid-spring-boot-starter") // 5.x, with the cosid.* configuration of your generatorsAny other source of ids is one bean:
@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:
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
REQUIREDlistener gates publishing and every step of creating break-glass: the step happens only after every REQUIRED listener recorded it withincosec.control.audit.required-timeout(5 s), otherwise it fails with503and writes nothing. Creating break-glass needs at least one REQUIRED listener. - Other events, and every event to a
BEST_EFFORTlistener, are delivered off the caller; a failure is logged. - Listeners add up: every
ChangeListenerbean is registered next to the starter'sLoggingChangeListener, which backs off only for aLoggingChangeListenerbean 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 failsopenChanges()as it failschange(id): it cannot be known to be terminal, so maintenance stops until an operator removes its record.acquireLease(name, holder, duration)andreleaseLease(name, holder): one instance at a time per task. A release removes the lease only whileholderstill has it, checked and removed in one atomic step (CoCache'sdeleteIfMatch): 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 ofwindow, 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)andputCandidate(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),falsewhen 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.referrersandputReferrers: 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.
{
"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, unlessclasspath:cosec-routes.jsonexists: 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, thecosecServiceGeneratorendpoint generates both documents from the OpenAPI document springdoc scans:routesis the route map to ship ascosec-routes.json,appis 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.