Skip to content

feat(accounts): central RBAC enforces by default, refusals are counted, sign-out reaches the provider, the audit log archives daily - #1298

Merged
antoinetoussaint-byte merged 12 commits into
mainfrom
issue-1296-accounts-nyl-requirements-central-rbac-enforces-by-default
Nov 3, 2026
Merged

antoinetoussaint-byte merged 12 commits into
mainfrom
issue-1296-accounts-nyl-requirements-central-rbac-enforces-by-default

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1296, in the order the issue gives.

1. Central RBAC admission enforces unless an operator opts out

configuredCentralEnforcement read an unset RBAC_CENTRAL_ENFORCEMENT as the
classify-only reading, so every deployment that configured nothing ran the
central org-tenant floor in shadow
: it recorded coverage and denied nothing. An
unset value now enforces. shadow stays reachable as an explicit opt-out that
the boot warns about by name, on every start, and any other value still fails
the boot. The reader returns a two-valued mode rather than a bool, so "nobody
configured this" and "an operator asked for classify-only" are different states
rather than the same empty string.

The interceptors' own centralEnforcement variable is initialized to the
enforcing value for the same reason: Go's zero value meaning shadow is what made
every test in that package — and every caller reaching an interceptor without
passing through startup — exercise the weaker admission while the configuration
claimed the stricter one. Flipping that is what made the suites below mean
anything.

One reading I did not take. The issue calls shadow an "explicit, dated
opt-out". I implemented explicit and boot-warned, not dated: requiring an expiry
timestamp beside shadow (the shape CODEFLY_GATEWAY_TOKEN_PREVIOUS_EXPIRES_AT
has) would refuse the boot of any deployment that already sets shadow without
one, and that is a second decision rather than a mechanical consequence of
flipping the default. Say the word and it is a small follow-up; the warning names
the key on every boot in the meantime, which is what makes a stale opt-out
visible.

One exemption was required, and it is declared where the issue asks. With
enforcement on, the host-interface conformance suite refused 30 cases, all on
host.authority.v1.AuthorityService/Issue. That method is shape-authenticated
(business.ShapeAuthenticated): its caller's credential is in the request body,
not in a bearer. Admission never reads the body, so for a workload, an
installation or a child hop there is no verified organization to resolve the
declared TENANT_REQUIREMENT_ORG_MEMBER against, and the floor refused every
such call with "verified authentication context required" before the adapter
could authenticate the credential it was handed and resolve the organization from
it. business.CentralTenantEnforcement now declines a shape-authenticated
method, and ClassifyCentralCoverage classifies it gap rather than ok — so
the enforcing half and the measuring half keep one answer per method instead of
disagreeing. That is the only change to what is enforced; nothing else in the
suites needed one.

2. Refusals are counted and alertable

A new counter saas.accounts.rpc_policy.denied, labelled by method and by the
gate that refused: admission, impersonation_restricted, api_key_scope,
central_admission, handler. Emitted beside the existing coverage attributes
in grpc_auth_interceptor.go, on both transports.

Three decisions worth reviewing:

  • reason is a closed set of constants, never the error message. The message
    carries the organization and the permission, which would make the label set
    unbounded and put tenant identifiers in a metrics backend. What a denial was
    is the reason; who it was about stays in the audit log and the request log.
  • Only PermissionDenied and Unauthenticated are counted. The interceptor
    sees every handler error pass through recordPolicyDenial; counting an
    Internal or an InvalidArgument would quietly turn an authorization metric
    into a general error rate, and an alert on it into noise.
    Unauthenticated is counted, because the central floor returns it for a call
    with no verified database scope — leaving it out would take that whole branch
    dark.
  • The refusal classifier reads both error representations. The same gate
    produces a gRPC status on one transport and a connect.Error on the other;
    checking one would count a refusal on one transport and silently drop the
    identical refusal on the other.

The name a rule must target is saas_accounts_rpc_policy_denied_total, not
the dotted instrument name. Accounts exports over OTLP, so a PromQL rule sees
the collector's normalised spelling: dots to underscores, _total for a
monotonic counter — the same transformation saas.jobs.completed →
saas_jobs_completed_total in this repo's own alert pack already relies on.
Labels are method and reason. The companion coverage counter is
saas_accounts_rpc_policy_coverage_total (method, coverage). A rule on the
dotted form matches nothing and fires never, which looks exactly like "no
denials" — the one failure mode an authorization alert cannot afford, so both
spellings and that warning are in the runbook.

Worth flagging for whoever writes the rule: reason="handler" is ordinary
traffic
— a person asking for something they may not have — and is by far the
commonest value, so an unlabelled threshold pages on normal product use. The
near-zero-baseline subset is reason=~"central_admission|api_key_scope", and
the comparison needs a sum() or it is per-method series. That is now on the
infrastructure side's rule PR as a comment, since its rule ships disabled
against a placeholder name.

Documented by name in module/MEASUREMENT_RUNBOOKS.md under a new
authorization-denials runbook — the file the alert pack's stable runbook
identifiers come from — with the label sets, what is worth alerting on and what
is ordinary traffic. I did not add a rule to slo_pack.json: the issue says
the alerting rule belongs to the infrastructure side, and an untested threshold
on a metric that legitimately moves with user behaviour is worse than none.

3. Sign-out reaches the identity provider

Both halves, wired independently because providers offer them independently.

RP-initiated logout. A new public RPC GetProviderSignOut
(POST /v1/auth/provider-sign-out) answers end_session_url — the provider's
end_session_endpoint with the deployment's registered
post_logout_redirect_uri, and with the client's id_token_hint when it
supplied one or the host's client_id when it did not, the substitution
RP-Initiated Logout 1.0 §2 provides for exactly that case.

This started as a field on the Logout response, and CI was right to refuse
it.
Logout has always returned google.protobuf.Empty, and changing an
RPC's response type is not additive: module/CONTRACT_VERSIONING.md says a
published v1 package never changes an RPC's response type and that a
non-additive change needs a new package major. Every client generated against
Logout would have needed rebuilding. The gate caught it
(Immutable module package, buf breaking); AGENTS.md says a CI gate is part
of the definition of done and is never allowlisted past, so the design moved
rather than the gate
. Logout is back to Empty and LogoutRequest lost
the field it briefly gained.

The separate call is better, not merely permitted: a client can hold the URL
before it signs out, so it has somewhere to send the person the moment the
session ends — and it is not presenting a bearer that the very call it is
making has just revoked. Public for the same reason GetJWKS is: the client
id and the registered post-logout redirect it echoes are already public, and
a caller asking after a sign-out has no session left to authenticate with.
buf breaking against origin/main now passes locally, run exactly as
ci.yml runs it.

  • The hint is client-supplied and no provider id_token is stored. The host
    has no other use for one, and retaining a provider credential to hand back
    later is a liability rather than a feature. This is a deliberate departure from
    the most literal reading of the issue; end_session_url still carries
    id_token_hint whenever a client has one.
  • The redirect is never read from the request. The provider requires it to be
    pre-registered, and taking it from the caller would make this host's own
    sign-out an open redirect.
  • An empty URL is the whole answer for a provider that publishes no endpoint,
    and for the development fixture. The contract says a client reads it as
    "nothing further to do", never as a failure.
  • The endpoint comes from discovery, or from an explicit
    IDENTITY_END_SESSION_URL pin for a provider configured from a preset rather
    than an issuer (auth0, google — Google publishes none at all). A discovery
    failure here is a boot warning, not a refusal: the login path already
    discovered the same document successfully by then, so refusing to start a host
    whose sign-in works because its sign-out could not be enriched trades a working
    deployment for a cosmetic one. The boot says which source answered, and says
    so when none did.

Back-channel logout. POST /v1/auth/backchannel-logout verifies an OpenID
Connect logout token against the provider's JWKS, reusing the sign-in
validator's key cache, signing-algorithm allowlist, issuer and audience
binding
, and adds the checks that separate a logout token from every other
token the same issuer signs: an events claim carrying the back-channel logout
event, a sub or a sid, no nonce (how §2.6 says to detect an id_token
substituted for a logout token), and an iat within two minutes so a token the
provider gave no exp is not replayable for ever. It then revokes every host
session of the named subject, access half included.

  • The route is unauthenticated at the perimeter and that is correct: the caller
    is the provider's back channel, which holds no session, bearer or API key here,
    and the signed token is the credential. It is mounted only when the
    configured provider can verify such a token
    , so a deployment without one
    exposes no such surface rather than one whose only honest answer is a refusal.
  • A token for an identity this host has never seen revokes nothing and answers
    200 — the provider is entitled to send one, and whether this host knows a
    person must not be learnable from an unauthenticated response.
  • A token naming only the provider's sid answers 501. Nothing here records
    provider session ids, so no host session can be matched to it, and 200 would
    tell the provider the sessions are gone when they are not. This is the honest
    partial, not an oversight: closing it means storing a provider sid per
    session, which is a migration, and accounts · bind and read the tenant and user under the library's setting names (codefly.current_tenant_id / codefly.current_user_id), not app.current_org_id / app.current_user_id #1293 owns migrations right now.
  • Replay is deliberately not tracked by jti: revoking an already-revoked
    session changes nothing, so the operation is idempotent by construction and the
    iat bound limits the window. The endpoint has no table behind it.

PostgresStore.RevokeAllUserSessions now returns the ids it revoked, as
RevokeSession already did. Without them a whole-user sign-out was quietly
weaker than a single-family one: the rows stopped refreshing while every
outstanding access token stayed valid to its natural TTL, with no per-session
revocation marker written. It had no callers, so nothing else moves.

4. A scheduled, leased, idempotent daily audit archive

With audit-archive/AUDIT_ARCHIVE_URI set, the host's daily sweep offers every
complete UTC day in a seven-day catch-up window on a new audit_archive queue,
and a leased worker exports that day — the whole platform's audit log, every
organisation plus the control plane — as one object, then writes a receipt.

  • The sweep only enqueues. Exporting inline would hold the daily tick for as
    long as the slowest object store takes, and an object-storage outage would
    stall the retention pass that shares that tick.
  • Idempotent per day, twice over and independently. The job's idempotency key
    is the day, so the sweep may offer the same day for ever without producing a
    second job; and the write is create-if-absent, so even a job that outlived its
    own record cannot replace an archived day's bytes. A key already present is a
    success with no second receipt — the first archiving wrote one, and a
    receipt whose digest described bytes this run did not write would be a lie.
  • The receipt is saas.audit.archive_exported in the audit log itself: the
    day, the object key, the SHA-256 digest of exactly the bytes written, and the
    byte and event counts. That is what makes "was yesterday archived, and is the
    object in the bucket the object we wrote" answerable from the trail rather than
    from the bucket alone — which is the thing the archive exists to stop being the
    single copy.
  • A day with no events is still archived, as an empty array, so the absence
    of an object means "not archived" and nothing else.
  • The catch-up window is what makes a host that was down over a weekend
    archive the days it missed instead of losing them silently, and it costs
    nothing because every day in it is already deduplicated. A day older than the
    window is an operator's ExportAuditLog to run by hand.

AUDIT_ARCHIVE_URI names a key prefix, never a bucket. The composed
object-storage service's Put carries no bucket, so the bucket, its credentials
and its retention policy stay with the composition — and a value naming a bucket,
a host or a scheme fails the boot rather than writing somewhere the operator did
not choose. A configured destination with no object-storage service composed also
fails the boot: the operator asked for an archive and would otherwise get
silence.

"Sealed" means create-if-absent plus a digest receipt, not WORM

Stated in those words in AUTHZ.md, module/AUDIT_METRICS.md, module/JOBS.md
and as a CLAIM_INVENTORY.md row. The object cannot be silently replaced by this
host, and a replacement by anything else is detectable against the receipt's
digest. It is not immutable: that needs an object-lock or retention policy on
the bucket, which belongs to the infrastructure that provisions the bucket and
does not exist yet (the companion issue filed alongside #1296 on the
infrastructure side). Until it does, the archive is tamper-evident and not
tamper-proof, and nothing in this PR claims otherwise.

Fix or hack

Every change is a fix at the owner:

Change Owner it lands at
the enforce-by-default flip and the boot warning the configuration reader that holds the default
the shape-authenticated exemption business.CentralTenantEnforcement / ClassifyCentralCoverage, the two halves that must agree
the denial counter the interceptor every refusal already passes through
end_session_endpoint on the discovery document the OIDC metadata type
the sign-out URL as its own additive RPC saas.accounts.v1's own compatibility rule, rather than the gate that enforces it
logout-token verification the OIDC validator that already owns the JWKS and the bindings
RevokeAllUserSessions returning its ids the store method whose shape made whole-user sign-out weaker than per-family
--force on check-generated-libraries.sh the gate whose own job it blocked

One labelled stopgap: a sid-only back-channel logout token answers 501
rather than revoking a session, because the host records no provider session id.
Closing it needs a migration, which #1293 owns right now.

scripts/ci/check-generated-libraries.sh now passes --force to
generate client. That gate exists to reproduce the checked-in tree, and the
command refuses by default to re-point a library at a contract whose digest
moved — exactly the case a reproduction check must handle. After any proto change
reaching that endpoint it failed with "use --force to overwrite" instead of
with a diff, so the one thing it exists to detect became unreachable. Its own
git diff is what keeps overwriting safe.

Build or reuse

Component Searched Decision
logout-token verification pkg/auth/oidc (JWKS cache, key resolution, alg allowlist, issuer/audience bindings), pkg/auth/headerjwt Reused. ValidateLogoutToken is a method on the existing *oidc.Validator; only the five logout-token-specific checks are new. No second JWKS client, no second key cache.
the archive's object writer pkg/datasource/objectstore (Dial, serviceToken, storeError, the per-call Binding), pkg/keyservice Reused the client and the error mapping, added an unbound writer beside the bound reader. PutRequest carries no Binding, which is exactly why the archive lands in the host's bucket and not a tenant's. Deliberately not keyservice.EncryptSecret: it is sized for short credentials, and sealing a multi-megabyte export through transit is the wrong shape.
the archive's scheduling and leasing the privacy worker (work.go:1093), the datasource reconcile sweep, pkg/jobs Reused wholesale — jobs.NewWorker, the queue/topic/source/idempotency-key envelope contract, the daily retention goroutine's tick. No new scheduler, no advisory lock, no new table.
per-day idempotency a receipt-table design, a PayloadContains scan of the audit log Neither. The job store's idempotency key already dedupes by day, and the store's own create-if-absent already refuses a second write. Two durable guards, no migration — which also keeps this PR off #1293's ground.
the receipt a new audit_archive_receipts table, codefly_effect_receipts Reused the audit log. The receipt belongs in the trail an auditor already reads, and it needs no migration.
provider-subject → user lookup Store.FindUserByIdentity Reused. No new store method, no new query.
the denial metric saas.accounts.rpc_policy.coverage and its lazy-binding pattern Reused the pattern (lazy bind after startup installs the real meter provider, nil-safe). A second instrument because coverage counts admitted calls and this counts refused ones.
the config group security, application, github-app New audit-archive group, three edits as github-app did it: the group env file, the service manifest's workspace-configuration-dependencies, and the repo-root symlink. No .secret.env — the credentials are the object-storage service's, so "unset means off" needs no placeholder.

The exempted Issue, and what refuses a cross-organisation call

Issue declares TENANT_REQUIREMENT_ORG_MEMBER and is outside the central
floor, so the floor is not what stops a body credential for organisation A
acting for organisation B. The adapter is, and that is proven against a live
store in two harness cases — both of which went red when enforcement was turned
on and green again once the exemption was declared, so they exercise the
exempted path rather than a neighbouring one:

  • the shared suite's "an installation issue cannot name a different
    organization"
    — an installation credential for the installed organisation,
    naming a foreign one, is PermissionDenied with reason organization;
  • this repository's own "a module workload is issued only where it is
    installed, within its pin, until uninstalled"

    (adapters/host_interface_conformance_test.go) — issue(globexOrganization)
    is refused while the module is installed only at Acme, and refused again by
    the module envelope's pin after Globex installs it.

The exemption's extent is pinned separately by
TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods: exactly Issue
and GetDelegation, so a third method cannot quietly join the set and leave a
declared org tenant enforced by nothing. AUTHZ.md names all three.

Overlap with #1293

Checked before opening, as the issue asks. #1293's PR #1294 takes migration 65
and rewrites the pkg/infra set_config sites. This PR adds no migration at
all
and touches pkg/infra in exactly one place — postgres_sessions.go's
RevokeAllUserSessions signature — which #1294 does not touch. The overlapping
files are pkg/business/service.go and pkg/business/store.go, where both PRs
add to a struct and an interface; that is a textual conflict at rebase, not a
semantic one.

PR #1106 (audit storage interface and journal delivery) is the other open PR in
this area. It is a streaming tee to a warehouse; this is a daily file archive
with a retention statement. They share no file and no configuration key, and
neither depends on the other. I did not build on it: it is rebase-stale (its
migrations 61–63 are now taken by main) and its warehouse half is still
blocked on a released dependency.

Validation

Regeneration, with CI's pinned CLI 0.1.221 and proto companion 0.0.18,
never a local build:

  • The no-change control first, on clean main: codefly generate proto --proto ./proto --output .. --template accounts/proto/buf.gen.yaml left
    git status clean, so the toolchain matches CI before anything moved.
  • Then, after the proto change: the same proto command, go generate ./pkg/business ./pkg/adapters ./pkg/cataloggen, codefly generate contracts saas-starter, codefly generate runnables saas-starter, and
    scripts/ci/check-generated-libraries.sh. Every generated byte in this PR is
    that pipeline's output. Nothing was hand-edited and nothing was hand-picked
    out of a regeneration.
  • @codefly-dev/saas-sdk 0.3.42 → 0.3.43: the new messages reach the
    generated TypeScript, so the tarball would differ from the bytes the registry
    already serves under 0.3.42. scripts/ci/kit-version.mjs check is green, and
    the frontend package-lock.json workspace entry was regenerated with
    npm install --package-lock-only — a stale version there is the whole of
    what "Base manifest integrity" reds on, a job name that reads like a manifest
    hash and sends you to the wrong tool.
  • buf breaking against origin/main, run as ci.yml runs it (filtered images
    on both sides, the service's own buf.yaml, --exclude-imports) — clean.

Suites, all with enforcement on (the point of §1):

  • go test -tags=pure ./... (accounts) — PASS
  • ./pkg/adapters, ./pkg/business, ./pkg/infra, ./pkg/auth/pg,
    ./pkg/billing/pg with real store/vault — PASS
  • scripts/ci/host-interface-conformance-main.sh — PASS, declaration gate
    included (all five declared interface versions match what the pin publishes)
  • module/tools (the repository gates in it) — PASS
  • node module/tools/naming-gate.mjs check and message over every commit —
    PASS

TestScopePinPinsRequestOrgToVerifiedOrg — the invariant the central floor's
equivalence rests on — still passes; it is in ./pkg/adapters above.

New tests: an unset RBAC_CENTRAL_ENFORCEMENT enforces (and a typo fails the
boot, and the enforcing mode is what an ignored error leaves behind); admission
enforces by default in the interceptor package itself; refusals are counted by
method and reason in both error representations, Unauthenticated included, and
non-refusals are not; the logout token's ten refusals, each mutated one at a time
from a valid token so a case cannot pass because an earlier check fired, plus a
wrong-key signature and a client_id-claim binding; the end-session URL's hint,
client_id fallback, preserved configured query and five unusable endpoints; the
fixture provider offering no provider sign-out at all; the back-channel
endpoint's 200/200-for-unknown/idempotent/400/501 answers, its request shape, its
refusal naming no cause, and its refusal to serve anything below its own path;
the archive's URI parsing (ten cases, five refused), key shape, job keying,
envelope/day disagreement, retry schedule, both idempotency guards, digest
receipt and three failure classifications.

Not verified

  • No deployed run. Nothing here was exercised against a real identity
    provider or a real object store: the OIDC halves are tested against a
    locally-signed JWKS and the archive against a scripted store. A real
    provider's end_session_endpoint, a real back-channel delivery and a real
    bucket write are unverified.
  • The bucket's retention lock does not exist, so the archive's immutability
    is unverified by construction — see the "sealed" section.
  • Marketing/frontend suites not run: nothing in this PR reaches them beyond
    the regenerated authentication_pb.ts and the SDK version bump.

Deliberately not in this PR

  • The product's own sign-out does not call GetProviderSignOut yet. The
    frontend's logout() reads only response.ok from a raw fetch, so nothing
    here breaks — but nothing here consumes the URL either, which means on this
    repository's own frontend the RP-initiated half is served and unused. Doing it
    means deciding where the person lands, what happens when the provider is slow,
    and how that composes with the existing "incomplete sign-out" state and
    SIGN_OUT_RETRY_DESTINATION — a journey decision with its own tests, not a
    mechanical consequence of the server change. The issue scopes section 3 to the
    server returning the URL; a consuming client (and this frontend) completes it.
  • No jti replay store and no provider-sid column, for the reasons above.
  • An alerting rule for the denial counter: the issue places it on the
    infrastructure side, and the metric is documented by name for it.

Size

~1,270 hand-written lines of source, ~960 of tests, ~230 of docs, plus
regenerated output. That is over AGENTS.md's ~500-line guidance and well under
its 2,000-line stop-and-ask ceiling; the four parts are one self-contained PR
because the issue asks for one, and three of them (the enforcement flip, the
counter, and the exemption it forced) are not separable.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 12 commits November 3, 2026 14:09
…s out, and refusals are counted

`configuredCentralEnforcement` read an unset `RBAC_CENTRAL_ENFORCEMENT` as the
classify-only reading, so every deployment that configured nothing ran the
central org-tenant floor in shadow: it recorded coverage and denied nothing. An
unset value now enforces. `shadow` remains reachable, as an explicit opt-out
that the boot warns about by name on every start, and any other value still
fails the boot. The reader returns a two-valued mode rather than a bool so
"nobody configured this" and "an operator asked for classify-only" are different
states rather than the same empty string.

The interceptors' own `centralEnforcement` variable is initialized to the
enforcing value for the same reason: Go's zero value meaning shadow is what made
every test in that package, and every caller reaching an interceptor without
passing through startup, exercise the weaker admission while the configuration
claimed the stricter one.

Turning it on made the host-interface conformance suite refuse 30 cases, all on
`host.authority.v1.AuthorityService/Issue`: a shape-authenticated method, whose
caller's credential is in the request body rather than in a bearer, so admission
has no verified organization to resolve the declared tenant against and refused
every workload, installation and child hop with "verified authentication context
required" before the adapter could authenticate the credential it was handed.
`business.CentralTenantEnforcement` now declines a shape-authenticated method,
and `ClassifyCentralCoverage` calls it a gap, so the two halves keep one answer
per method.

Refusals are counted on a new `saas.accounts.rpc_policy.denied`, labelled by
`method` and by the gate that refused — `admission`, `impersonation_restricted`,
`api_key_scope`, `central_admission`, `handler` — on both transports. The reason
is a closed set of constants, never the error message, which carries the
organization and the permission. Only PermissionDenied and Unauthenticated are
counted: the interceptor sees every handler error pass through, and counting an
Internal would make an authorization metric a general error rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ending the host session left the provider's session untouched, so the next
sign-in through it was silent: a person pressed sign out, pressed sign in, and
was back in without being asked anything. Both halves of OpenID Connect
sign-out now exist, wired independently because providers offer them
independently.

RP-initiated logout: `Logout` answers `LogoutResponse.end_session_url` — the
provider's `end_session_endpoint` with the deployment's registered
`post_logout_redirect_uri`, and with the client's `id_token_hint` when it
supplied one or the host's `client_id` when it did not, the substitution
RP-Initiated Logout 1.0 §2 provides for exactly that case. The hint is
client-supplied and no provider id_token is stored: the host has no other use
for one, and retaining a provider credential to hand back later is a liability.
The redirect is never read from the request, which would make this host's own
sign-out an open redirect. An empty URL is the whole answer for a provider that
publishes no endpoint, and for the development fixture; the endpoint comes from
discovery or from an IDENTITY_END_SESSION_URL pin, and the boot says which, and
says so when there is none.

Back-channel logout: `POST /v1/auth/backchannel-logout` verifies a logout token
against the provider's JWKS, reusing the sign-in validator's key cache, issuer
and audience binding, and adds the checks that separate a logout token from
every other token the same issuer signs — an events claim carrying the
back-channel logout event, a sub or sid, no `nonce` (how §2.6 says to detect an
id_token substituted for a logout token), and an iat within two minutes so a
token given no `exp` is not replayable for ever. It then revokes every host
session of the named subject, access half included.

The route is mounted only when the configured provider can verify such a token,
so a deployment without one exposes no such surface rather than one whose only
honest answer is a refusal. A token for an unknown identity revokes nothing and
answers 200, because whether this host knows a person must not be learnable from
an unauthenticated response. A token naming only the provider's `sid` answers
501: nothing here records provider session ids, and 200 would claim sessions are
gone when they are not. Replay needs no `jti` marker — revoking an already
revoked session changes nothing, and the iat bound limits the window — so the
endpoint has no table behind it.

`PostgresStore.RevokeAllUserSessions` now returns the ids it revoked, as
`RevokeSession` already did. Without them a whole-user sign-out was quietly
weaker than a single-family one: the rows stopped refreshing while every
outstanding access token stayed valid to its natural TTL, with no per-session
revocation marker written.

scripts/ci/check-generated-libraries.sh passes --force to `generate client`.
That gate's job is to reproduce the checked-in tree, and the command refuses by
default to re-point a library at a contract whose digest moved — exactly the
case a reproduction check must handle — so after any proto change reaching that
endpoint it failed with "use --force to overwrite" instead of with a diff, and
the one thing it exists to detect became unreachable. Its git diff is what keeps
overwriting safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated TypeScript now carries LogoutResponse, so the published tarball
would differ from the bytes 0.3.42 already serves and the registry would refuse
it. Additive, so a patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ith a receipt

`ExportAuditLog` has always let an administrator download their own
organisation's audit log on request. Nothing ran it on a schedule into a store
outside the database, so the only copy of the audit trail was the Postgres table
it was written to — which the same operator who can change a record can also
change.

With `audit-archive/AUDIT_ARCHIVE_URI` set, the host's daily sweep offers every
complete UTC day in a seven-day catch-up window on a new `audit_archive` queue,
and a leased worker exports that day: the whole platform's audit log, every
organisation plus the control plane, as one object. The sweep only enqueues —
exporting inline would hold the daily tick for as long as the slowest object
store takes, and an object-storage outage would stall the retention pass that
shares the tick. The window rather than only yesterday is what makes a host that
was down over a weekend archive the days it missed instead of losing them
silently, and it costs nothing because every day in it is deduplicated.

Idempotent per day, twice over and independently: the job's idempotency key is
the day, so the sweep may offer the same day for ever without producing a second
job, and the write is create-if-absent, so even a job that outlived its own
record cannot replace an archived day's bytes. A key already present is a
success with no second receipt — the first archiving wrote one, and a receipt
whose digest described bytes this run did not write would be a lie.

The receipt is `saas.audit.archive_exported` in the audit log itself: the day,
the object key, the SHA-256 digest of exactly the bytes written, and the byte and
event counts. That is what makes "was yesterday archived, and is the object in
the bucket the object we wrote" answerable from the trail rather than from the
bucket alone, which is the thing the archive exists to stop being the single
copy.

The destination is the composed object-storage service's own bucket, reached
through a new unbound writer beside the per-call-bound reader that package
already has: that service's Put carries no bucket, so the bucket, its
credentials and its retention policy stay with the composition.
`AUDIT_ARCHIVE_URI` is therefore a key PREFIX, and a value naming a bucket, a
host or a scheme fails the boot rather than writing somewhere the operator did
not choose. A configured destination with no object-storage service composed
also fails the boot: the operator asked for an archive and would otherwise get
silence.

**"Sealed" means create-if-absent plus a digest receipt, not WORM.** The object
cannot be silently replaced by this host, and a replacement by anything else is
detectable against the receipt. It is not immutable: that needs an object-lock
or retention policy on the bucket, which belongs to the infrastructure that
provisions it and does not exist yet. AUTHZ.md, module/AUDIT_METRICS.md,
module/JOBS.md and CLAIM_INVENTORY.md all say tamper-evident rather than
tamper-proof, in those words.

`PostgresStore` keeps one behaviour change of its own from this: a day with no
events is still archived, as an empty array, so the absence of an object means
"not archived" and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xt midnight

The store's audit window is inclusive at both ends (`created_at >= From AND
created_at <= To`), so asking for the next midnight as the end put an event
written at exactly 00:00:00.000000 into two consecutive days' archives — a
duplicated row in a trail whose whole value is being exact. Postgres timestamps
are microsecond-resolution, so stepping back one microsecond is exact rather
than a fudge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other group a service declares has a section in
module/configurations/README.md; the new one had its semantics only in its own
env file, which is the copy an operator reads last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lane crossings

Both new call sites cross scopes deliberately and for the same reason: neither
path has a request principal to scope to. The archive exports every
organisation's audit log from a scheduled job, and a back-channel logout acts on
a provider subject that is nobody's caller, so RLS would otherwise scope
`sessions` and `user_identities` to an identity that does not exist on that
request. The inventory exists so each crossing is countable rather than merely
greppable; these two are now in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Service.Logout` answers a `LogoutResponse` now, so its one caller outside the
pure build tag had to move. The fixture configures no identity-provider
sign-out, so the case also asserts the empty end-session URL — the whole answer
a client is meant to read as "nothing further to do".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… extension count

The explicit REST extension inventory gains POST /v1/auth/backchannel-logout:
routed, unprotected (the provider's signed logout token is the credential, and
it holds no session at this perimeter), and asserted not to shadow the
transcoded /v1/auth/logout beside it — RegisterHTTPRoute matches by prefix, so
that adjacency is the thing worth a test.

The count assertion now derives from the inventory map instead of naming 15.
The map is already exhaustive — the loop consumes each entry and the emptiness
check refuses an unnamed one — so the literal added nothing except the one
failure mode a literal has: two branches each adding a route and each bumping
the same figure merge into a count that is silently wrong with every test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`module-verify`'s workspace-sync check compares each `packages/<name>` entry in
the frontend `package-lock.json` against that workspace's own `package.json`, so
bumping the SDK's version without regenerating the lock leaves the one field
stale and reds "Base manifest integrity" — a job name that reads like a manifest
hash and sends you to the wrong tool. Regenerated with
`npm install --package-lock-only`; the lock is generated and never hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…out response

CI's proto breaking-change gate refused the previous shape, and it was right:

    RPC "Logout" on service "AuthService" changed response type from
    "google.protobuf.Empty" to "saas.accounts.v1.LogoutResponse"

module/CONTRACT_VERSIONING.md is explicit — a published v1 package is additive
and an RPC's response type is never changed; a change that cannot be represented
additively needs a new package major. Every client generated against `Logout`
returning Empty would have had to be rebuilt. AGENTS.md says a CI gate is part
of the definition of done and is never allowlisted past, so the design moved
rather than the gate.

`Logout` goes back to `google.protobuf.Empty` and `LogoutRequest` loses the
field it briefly gained. The URL now has its own public RPC,
`GetProviderSignOut` (`POST /v1/auth/provider-sign-out`), carrying the optional
client-supplied `id_token_hint` and answering `end_session_url`.

The separate call is better, not merely permitted. A client can hold the URL
*before* it signs out, so it has somewhere to send the person the moment the
session ends — and it is not presenting a bearer that the very call it is making
has just revoked. Public for the same reason GetJWKS is: the client id and the
registered post-logout redirect it echoes are already public, and asking for it
after a sign-out means there is no session left to authenticate with.

`buf breaking` against origin/main now passes locally, run the way ci.yml runs
it. The gateway's public-route count moves 18 → 19; it stays a literal because
there is no exhaustive inventory to derive it from, but the one route that moved
it is now asserted by name immediately after, so the figure is anchored to a
claim instead of floating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…counts, name the exemption's guards

The two checks failing at 9ac18dc, both from the new work and neither the
managed-database qualification #1294 also hits:

- `Immutable module package` — its first step (buf breaking) is already fixed;
  what failed next was `module-package run-generators` drift: the new
  `saas.audit.archive_exported` event belongs in the generated event catalog
  (`deployment/generated/asyncapi.json`, `event-catalog.json`,
  `communication.md`, `pkg/eventcatalog/catalog_gen.go`). Regenerated by the
  generator, not by hand. Running it locally needs the public
  `@bufbuild/protoc-gen-es` symlinked into the frontend prefix the template
  names — without it buf kills the sibling plugins and the log reads as an OOM;
  the shim is removed again before hashing the tree, which `base-integrity`
  checks.
- `Codefly quality (test)` — `TestAuthorizationCatalogCompilationAndPolicyProjection`,
  one of three pinned catalog counts the new public RPC moves:
  fail-closed 28 → 29, gateway public 63 → 66 (REST plus both Connect
  spellings), REST public 18 → 19. Each delta is narrated beside the figure, as
  every other delta in those files is, and the REST case now also asserts the
  route by procedure and exposure so the number is anchored to a claim.

The metric's exported name is now stated where a rule is written against it.
Accounts exports over OTLP, so an alerting rule targets the collector's
normalised spelling — `saas_accounts_rpc_policy_denied_total`, not the dotted
instrument name — with `method` and `reason` as labels. MEASUREMENT_RUNBOOKS.md
gives both spellings, cites this repository's own precedent
(`saas.jobs.completed` → `saas_jobs_completed_total`, and the gauge
`saas.retention.failing` keeping no suffix), and says plainly that a rule on the
dotted form matches nothing and fires never — which looks exactly like "no
denials".

And the central floor's one exemption now names its backstop.
`Issue` declares an org tenant and is outside the floor, so what refuses a
cross-organisation call is the adapter: proven against a live store by the
shared suite's "an installation issue cannot name a different organization" and
by this repository's "a module workload is issued only where it is installed,
within its pin, until uninstalled", both of which went red under enforcement and
green again once the exemption was declared.
`TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods` pins the
exemption's extent, so a third method cannot quietly join it and leave a
declared tenant enforced by nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit ecd6a59 Nov 3, 2026
25 of 26 checks passed
crisfiorini-oss pushed a commit to crisfiorini-oss/module-saas-starter that referenced this pull request Nov 4, 2026
…nd store tests (codefly-dev#1301)

The Go lint job added in codefly-dev#1297 was clean at its own head, but codefly-dev#1298 (accounts)
and codefly-dev#1295's base (store) landed test code written before that job existed.
golangci-lint v2.14.0 reports three errcheck findings on main:

- accounts rpc_policy_denied_test.go:95 dropped recordPolicyDenial's return.
  The function passes the refusal through, and the sibling tests in the same
  file assert exactly that, so this one now asserts it too.
- store migration_65_scope_settings_test.go:93 and :164 deferred runner.Close
  and rows.Close bare; both now use the idiom every other migration test in
  the package already uses.

No exclusion, no nolint. Both modules report 0 issues under the CI command.

Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

accounts · NYL requirements: central RBAC enforces by default, denials are counted, sign-out reaches the IdP, audit exports to an archive

1 participant