Skip to content

Checks in Code ​

For developers whose rules depend on more than the route: showing or hiding UI actions, checking a loaded object, or diagnosing a decision. This page covers AuthorizationChecker, which asks the engine directly, and the control plane's explain, which says why a decision came out as it did.

When to check in code ​

The web adapter already authorizes every request before your handler runs. Check in code only when you need an answer the adapter cannot give:

  • What may the user do next? A page decides which buttons to show, without sending those requests.
  • Is this object theirs? The answer depends on data you load in the handler, such as the order's owner (Object-level rules).

Do not repeat the request's own check in the handler: the adapter has decided it already, with auditing and rate limiting, and a second check can only disagree with it. A check is advice for the next step, not enforcement of the current one.

Checking a permission ​

The starter registers an AuthorizationChecker bean (me.ahoo.cosec.runtime). It decides for the principal and scope of a request the pipeline permitted, so it needs that request's SecurityContext (Accessing the security context):

kotlin
suspend fun check(security: SecurityContext, action: String, resource: String): Boolean
  • action is service:verb, as the route map names it (order:cancel), or http:<METHOD> for an unmapped route. A string without : is false.
  • resource is a typed resource, type/id (order/42), or a path starting with / (/orders/42), which is an http resource.

A service behind the gateway in cosec.pipeline.mode=gateway has no checker: it holds no policies to decide with (Services behind the gateway). Check there through the gateway's decision, or run the service in enforce mode.

Two adapter modules add non-suspending forms:

ModuleFunctionUse it
cosec-webfluxAuthorizationChecker.checkMono(security, action, resource): Mono<Boolean>In WebFlux and gateway code. It starts on the caller's thread and continues off any client library's I/O thread.
cosec-webmvcAuthorizationChecker.checkBlocking(security, action, resource): BooleanOn a servlet request thread. It throws on a non-blocking thread, such as a Netty event loop: use checkMono there.
kotlin
@RestController
class OrderActionsController(private val checker: AuthorizationChecker) {
    // WebFlux: which actions may the caller take on this order?
    @GetMapping("/orders/{id}/actions")
    fun actions(@PathVariable id: String, exchange: ServerWebExchange): Mono<Map<String, Boolean>> {
        val security = exchange.getSecurityContext()!!
        return Mono.zip(
            checker.checkMono(security, "order:cancel", "order/$id"),
            checker.checkMono(security, "order:delete", "order/$id"),
        ).map { mapOf("cancel" to it.t1, "delete" to it.t2) }
    }
}

The endpoint itself is a route like any other: your documents must permit it.

The decision behind a check ​

decide takes the same arguments and answers the Decision (me.ahoo.cosec.model.decision) that check turns into a Boolean. check is decide(...) is Decision.Permit, so the two always agree:

kotlin
suspend fun decide(security: SecurityContext, action: String, resource: String): Decision
DecisionWhencheck
Permit(by)An ALLOW matched and no DENY did. by is the statement that permitted: policy, index, name, and via, how the policy applied. BindingRef.Bound(id, document) is a binding, in a PLATFORM, APP or TENANT document; BindingRef.Attached(policy) is a policy the principal's memberships attach.true
Deny(by)A DENY matched (by is that statement), the tenant's platform boundary refused the permit (StatementRef.Boundary), or nothing matched (null). A SecurityContext the pipeline did not build, or an action without :, is Deny(null) too.false
Indeterminate(cause)No decision could be made: a document could not be read, a DENY failed, the check itself failed, or only break-glass would permit. A failure of the check is logged as a warning and reported as SourceUnavailable(PLATFORM, error), whatever failed: read error, not the document kind.false

Use it to record what allowed an action your code takes, such as "permitted by role binding order-admins of the app document". A Decision stays in your process: never write it or its cause into a response. The web adapters never see one either, which is how every status code comes from one place (Responses and status codes). For the same reason the adapter modules add no Mono or blocking form of decide: call it from a coroutine. The obligations a Permit carries, its rate limits, did not run.

How a check relates to the request pipeline ​

A check evaluates with the same engine, the same documents and the same rules as a request, in the evaluation order. It reuses the request's principal, memberships, scope (tenant, app, space and roles) and environment (client IP, device), and differs only in what surrounds the decision:

Request through the adapterAuthorizationChecker
Action and resourcefrom the route mapthe ones you pass
Snapshot documentsread for the requestread again for the check
External attributesstage A for every request, stage B as the plan namesfetched again for the check's action and resource: each one it can read, from the provider that declares it (a stage-A provider without the scope, as stage A asks)
Decision listeners (audit, metrics, traces)yesnone
Rate limitsspends permits, can answer 429never spends, never limited
A Permit only break-glass givesproceeds, after a REQUIRED auditfalse: a check writes no audit
Deny, Indeterminate, an errora status codefalse, with a warning in the log for an error
Resultan HTTP statustrue only on a Permit; decide answers the decision

A check fails closed: a SecurityContext the pipeline did not build (one you made yourself, or none) is false, whatever the documents say. There is therefore no check outside a request: a message consumer that acts for a user has no permitted request to check as, and must carry its own authorization.

Object-level rules ​

Whether a user may change this order depends on the order: its owner, tenant or status. The adapter runs before your handler has loaded anything, so an object-level rule is decided in two steps:

  1. In the adapter, the route is authorized on what the request carries, such as order:cancel on order/42.
  2. In your code, after loading the object, a check names a resource whose id carries the facts the rule needs.

A check passes no resource attributes, only a type and an id. So write the facts into the id, and let a statement capture them. For example, check ownership as the resource order-of/<owner>/<orderId>, with this policy bound to anyone ("subject": "*"):

json
{
  "id": "own-orders-only",
  "statements": [
    { "name": "not-the-owner", "effect": "deny", "action": "order:cancel", "resource": "order-of/{owner}/*",
      "condition": { "not": { "eq": ["resource.var.owner", { "attr": "subject.id" }] } } }
  ]
}
kotlin
val order = orderRepository.findById(id)
val allowed = checker.checkBlocking(security, "order:cancel", "order-of/${order.ownerId}/$id")
if (!allowed) throw AccessDeniedException()

The DENY applies only to order-of/... resources, which no route produces, so the route itself is still decided by your other policies. The check is a separate decision: it is permitted only when an ALLOW also covers order:cancel on order-of/..., and it is denied for anyone but the owner. A captured value holds one segment and is compared exactly; ids are split on / and never decoded, so an owner id must not contain /. Resource patterns and conditions are described in Policy language.

The check must run in your code; a handler that skips it is not protected by the rule.

Explaining a decision ​

To see why a principal gets a decision for a request, without sending the real request, ask the control plane's explain. It needs cosec-control and cosec-cocache, and is served on the host as POST <base-path>/explain (default /cosec/control/v1/explain; see Control-plane routes).

It decides with the same scope resolution, document compiler and engine as enforcement, at full trace, on the published documents: their heads, or the revisions you name. Nothing is spent or changed.

bash
curl -X POST localhost:8080/cosec/control/v1/explain \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{
  "request": {
    "tenant": "acme",
    "principal": "alice",
    "roles": ["clerk", "lead@team-a"],
    "policies": ["orders-readonly"],
    "app": "order",
    "space": "team-a",
    "action": "order:cancel",
    "resource": "order/42"
  }
}'

Input. request describes the request; nothing is read from a credential:

FieldMeaning
tenantRequired. The principal's tenant.
principalThe principal id; leave it out for the anonymous principal.
roles, policiesThe membership claims, written as a token carries them (role, role@space; Roles and spaces).
app, spaceThe app the simulated credential declares, on a host of that app, and the space it selects (CoSec-Space-Id).
actionRequired. service:verb.
resourceRequired. type/id, or a path starting with /.
remoteIpThe client IP.
timeThe request time, an ISO-8601 instant; now by default.

An optional at ({"platform": 12, "app": 3, "tenant": 7}) explains on those retained revisions instead of the heads; a revision no longer kept is 410. Unknown fields are 400.

Output.

json
{
  "version": { "platform": 12, "app": 3, "tenant": 7 },
  "decision": {
    "decision": "Deny",
    "by": { "policy": "no-cancel", "statement": 0, "name": "no-cancel", "via": "no-cancel", "document": "platform" },
    "trace": {
      "redacted": 1,
      "candidates": [
        { "effect": "DENY", "outcome": "MATCHED",
          "statement": { "policy": "no-cancel", "statement": 0, "name": "no-cancel", "via": "no-cancel", "document": "platform" } }
      ]
    }
  }
}
FieldMeaning
versionThe revisions of the documents decided on.
decision.decisionPermit, Deny, Indeterminate, or Rejected when the scope is rejected (then cause is APP_MISMATCH or SPACE_NOT_MEMBER, and there is no trace).
decision.byThe statement that decided: policy, statement index and name, the binding it applied through (via: a binding id, or attached:<policy>) and its document. Absent for the implicit deny and for Indeterminate.
decision.causeFor Indeterminate, the type of the cause (such as MissingPolicies, MatcherFailed, SourceUnavailable); never its message.
decision.redactedtrue when a statement decided that you may not see.
trace.candidatesEach statement evaluated: its effect, its statement, and its outcome: MATCHED, ACTION_MISS, RESOURCE_MISS, CONDITION_MISS, GUARD_MISS (its policy's condition did not hold), GUARD_ERROR, ERROR or NOT_EVALUATED.
trace.redactedHow many candidates were left out because you may not see them.

Who may explain, and what they see. explain is authorized like every control-plane call, as the principal of the request, in the app cosec.control.app-id:

  • the caller needs cosec:explain on the simulated tenant's document (doc/tenant/<tenant>). A tenant's principal can only explain requests of its own tenant: tenant isolation comes first and is not a policy. The built-in cosec:tenant-viewer and cosec:tenant-admin grant it on the caller's own tenant, cosec:platform-admin on every tenant (Built-in policies);
  • statements are shown only from documents the caller may read (cosec:read on the document). A statement reached through an attachment is shown only when the caller may read both the tenant and the platform document. Everything else is left out of the trace and counted in redacted, and a decision made by a hidden statement says only "redacted": true;
  • without the permission the answer is 403, and when it cannot be decided 503, with no detail.

What it does not see. An explanation reads the published documents, not what an instance has installed (an instance may still serve an older revision for a while; see Storage). It fetches no external attributes, so a statement that reads one counts as missing them (Missing external attributes), and it has no token attributes, blacklist or rate limits.

Simulating a change. POST <base-path>/simulate takes the same request, and optionally a staged change ("candidate": "<change id>"): it answers the decision on the current documents (enforced), on the documents with the change applied (candidate), and whether they differ. It needs cosec:simulate on the tenant's document and on the change's. Staging changes, shadow windows and access analysis (/analyze) are in Testing policies.

Next: every property and its default, in Configuration; or, to write the rules themselves, Policy language.

Licensed under the Apache License, Version 2.0.