Repository navigation
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 intoNov 3, 2026
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1296, in the order the issue gives.
1. Central RBAC admission enforces unless an operator opts out
configuredCentralEnforcementread an unsetRBAC_CENTRAL_ENFORCEMENTas theclassify-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.
shadowstays reachable as an explicit opt-out thatthe 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
centralEnforcementvariable is initialized to theenforcing 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
shadowan "explicit, datedopt-out". I implemented explicit and boot-warned, not dated: requiring an expiry
timestamp beside
shadow(the shapeCODEFLY_GATEWAY_TOKEN_PREVIOUS_EXPIRES_AThas) would refuse the boot of any deployment that already sets
shadowwithoutone, 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_MEMBERagainst, and the floor refused everysuch call with "verified authentication context required" before the adapter
could authenticate the credential it was handed and resolve the organization from
it.
business.CentralTenantEnforcementnow declines a shape-authenticatedmethod, and
ClassifyCentralCoverageclassifies itgaprather thanok— sothe 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 bymethodand by thegate that refused:
admission,impersonation_restricted,api_key_scope,central_admission,handler. Emitted beside the existing coverage attributesin
grpc_auth_interceptor.go, on both transports.Three decisions worth reviewing:
reasonis a closed set of constants, never the error message. The messagecarries 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.
PermissionDeniedandUnauthenticatedare counted. The interceptorsees every handler error pass through
recordPolicyDenial; counting anInternalor anInvalidArgumentwould quietly turn an authorization metricinto a general error rate, and an alert on it into noise.
Unauthenticatedis counted, because the central floor returns it for a callwith no verified database scope — leaving it out would take that whole branch
dark.
produces a gRPC status on one transport and a
connect.Erroron 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, notthe dotted instrument name. Accounts exports over OTLP, so a PromQL rule sees
the collector's normalised spelling: dots to underscores,
_totalfor amonotonic counter — the same transformation
saas.jobs.completed→saas_jobs_completed_totalin this repo's own alert pack already relies on.Labels are
methodandreason. The companion coverage counter issaas_accounts_rpc_policy_coverage_total(method,coverage). A rule on thedotted 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 ordinarytraffic — 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", andthe comparison needs a
sum()or it is per-methodseries. That is now on theinfrastructure side's rule PR as a comment, since its rule ships disabled
against a placeholder name.
Documented by name in
module/MEASUREMENT_RUNBOOKS.mdunder a newauthorization-denialsrunbook — the file the alert pack's stable runbookidentifiers 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 saysthe 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) answersend_session_url— the provider'send_session_endpointwith the deployment's registeredpost_logout_redirect_uri, and with the client'sid_token_hintwhen itsupplied one or the host's
client_idwhen it did not, the substitutionRP-Initiated Logout 1.0 §2 provides for exactly that case.
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_urlstill carriesid_token_hintwhenever a client has one.pre-registered, and taking it from the caller would make this host's own
sign-out an open redirect.
and for the development fixture. The contract says a client reads it as
"nothing further to do", never as a failure.
IDENTITY_END_SESSION_URLpin for a provider configured from a preset ratherthan 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-logoutverifies an OpenIDConnect 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
eventsclaim carrying the back-channel logoutevent, a
subor asid, nononce(how §2.6 says to detect an id_tokensubstituted for a logout token), and an
iatwithin two minutes so a token theprovider gave no
expis not replayable for ever. It then revokes every hostsession of the named subject, access half included.
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.
200 — the provider is entitled to send one, and whether this host knows a
person must not be learnable from an unauthenticated response.
sidanswers 501. Nothing here recordsprovider 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
sidpersession, 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.
jti: revoking an already-revokedsession changes nothing, so the operation is idempotent by construction and the
iatbound limits the window. The endpoint has no table behind it.PostgresStore.RevokeAllUserSessionsnow returns the ids it revoked, asRevokeSessionalready did. Without them a whole-user sign-out was quietlyweaker 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_URIset, the host's daily sweep offers everycomplete UTC day in a seven-day catch-up window on a new
audit_archivequeue,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.
long as the slowest object store takes, and an object-storage outage would
stall the retention pass that shares that tick.
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.
saas.audit.archive_exportedin the audit log itself: theday, 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.
of an object means "not archived" and nothing else.
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
ExportAuditLogto run by hand.AUDIT_ARCHIVE_URInames a key prefix, never a bucket. The composedobject-storage service's
Putcarries no bucket, so the bucket, its credentialsand 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.mdand as a
CLAIM_INVENTORY.mdrow. The object cannot be silently replaced by thishost, 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:
business.CentralTenantEnforcement/ClassifyCentralCoverage, the two halves that must agreeend_session_endpointon the discovery documentsaas.accounts.v1's own compatibility rule, rather than the gate that enforces itRevokeAllUserSessionsreturning its ids--forceoncheck-generated-libraries.shOne labelled stopgap: a
sid-only back-channel logout token answers 501rather 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.shnow passes--forcetogenerate client. That gate exists to reproduce the checked-in tree, and thecommand 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
--forceto overwrite" instead ofwith a diff, so the one thing it exists to detect became unreachable. Its own
git diffis what keeps overwriting safe.Build or reuse
pkg/auth/oidc(JWKS cache, key resolution, alg allowlist, issuer/audience bindings),pkg/auth/headerjwtValidateLogoutTokenis a method on the existing*oidc.Validator; only the five logout-token-specific checks are new. No second JWKS client, no second key cache.pkg/datasource/objectstore(Dial,serviceToken,storeError, the per-callBinding),pkg/keyservicePutRequestcarries noBinding, which is exactly why the archive lands in the host's bucket and not a tenant's. Deliberately notkeyservice.EncryptSecret: it is sized for short credentials, and sealing a multi-megabyte export through transit is the wrong shape.work.go:1093), the datasource reconcile sweep,pkg/jobsjobs.NewWorker, the queue/topic/source/idempotency-key envelope contract, the daily retention goroutine's tick. No new scheduler, no advisory lock, no new table.PayloadContainsscan of the audit logaudit_archive_receiptstable,codefly_effect_receiptsStore.FindUserByIdentitysaas.accounts.rpc_policy.coverageand its lazy-binding patternsecurity,application,github-appaudit-archivegroup, three edits asgithub-appdid it: the group env file, the service manifest'sworkspace-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 callIssuedeclaresTENANT_REQUIREMENT_ORG_MEMBERand is outside the centralfloor, 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:
organization" — an installation credential for the installed organisation,
naming a foreign one, is
PermissionDeniedwith reasonorganization;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: exactlyIssueand
GetDelegation, so a third method cannot quietly join the set and leave adeclared org tenant enforced by nothing.
AUTHZ.mdnames all three.Overlap with #1293
Checked before opening, as the issue asks. #1293's PR #1294 takes migration 65
and rewrites the
pkg/infraset_configsites. This PR adds no migration atall and touches
pkg/infrain exactly one place —postgres_sessions.go'sRevokeAllUserSessionssignature — which #1294 does not touch. The overlappingfiles are
pkg/business/service.goandpkg/business/store.go, where both PRsadd 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 stillblocked on a released dependency.
Validation
Regeneration, with CI's pinned CLI 0.1.221 and proto companion 0.0.18,
never a local build:
main:codefly generate proto --proto ./proto --output .. --template accounts/proto/buf.gen.yamlleftgit statusclean, so the toolchain matches CI before anything moved.go generate ./pkg/business ./pkg/adapters ./pkg/cataloggen,codefly generate contracts saas-starter,codefly generate runnables saas-starter, andscripts/ci/check-generated-libraries.sh. Every generated byte in this PR isthat pipeline's output. Nothing was hand-edited and nothing was hand-picked
out of a regeneration.
@codefly-dev/saas-sdk0.3.42 → 0.3.43: the new messages reach thegenerated TypeScript, so the tarball would differ from the bytes the registry
already serves under 0.3.42.
scripts/ci/kit-version.mjs checkis green, andthe frontend
package-lock.jsonworkspace entry was regenerated withnpm install --package-lock-only— a staleversionthere is the whole ofwhat "Base manifest integrity" reds on, a job name that reads like a manifest
hash and sends you to the wrong tool.
buf breakingagainstorigin/main, run asci.ymlruns it (filtered imageson 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/pgwith real store/vault — PASSscripts/ci/host-interface-conformance-main.sh— PASS, declaration gateincluded (all five declared interface versions match what the pin publishes)
module/tools(the repository gates in it) — PASSnode module/tools/naming-gate.mjs checkandmessageover every commit —PASS
TestScopePinPinsRequestOrgToVerifiedOrg— the invariant the central floor'sequivalence rests on — still passes; it is in
./pkg/adaptersabove.New tests: an unset
RBAC_CENTRAL_ENFORCEMENTenforces (and a typo fails theboot, 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,
Unauthenticatedincluded, andnon-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_idfallback, preserved configured query and five unusable endpoints; thefixture 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
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 realbucket write are unverified.
is unverified by construction — see the "sealed" section.
the regenerated
authentication_pb.tsand the SDK version bump.Deliberately not in this PR
GetProviderSignOutyet. Thefrontend's
logout()reads onlyresponse.okfrom a rawfetch, so nothinghere 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 amechanical consequence of the server change. The issue scopes section 3 to the
server returning the URL; a consuming client (and this frontend) completes it.
jtireplay store and no provider-sidcolumn, for the reasons above.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 underits 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