Skip to content

refactor(accounts): bind and read the tenant and user under the library's setting names - #1294

Merged
antoinetoussaint-byte merged 2 commits into
mainfrom
issue-1293-accounts-bind-and-read-the-tenant-and-user
Nov 3, 2026
Merged

antoinetoussaint-byte merged 2 commits into
mainfrom
issue-1293-accounts-bind-and-read-the-tenant-and-user

Conversation

@antoinetoussaint-byte

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

Copy link
Copy Markdown
Contributor

Closes #1293.

The host bound the caller's organisation and user to setting names of its own —
app.current_org_id and app.current_user_id — while the platform's Postgres
library defaults a scoped transaction to codefly.current_tenant_id /
codefly.current_user_id (codefly-dev/service-postgres, libs/go/store.go),
which is what the other platform stores read. With the document store moving to
the library's names in its own repository, the host was the last store on
app.*. This moves it, and changes nothing else: transaction-local,
fail-closed, forced, NOBYPASSRLS roles, app_tenant as the request default,
app_control_plane on its own login.

Classification: a fix, at the place that owns the behaviour. No stopgap.

What moved

  • 65_library_scope_settings.up.sql / .down.sql — 75 policies over 70
    relations, 9 function bodies, and the 6 partitions of public.audit_events.
    Generated from pg_policy and pg_proc rather than written by hand: the
    catalog is the only place that knows the whole set, and each expression below
    is the catalog's own rendering under an empty search_path — which is how
    the baseline's text was produced, and what makes the deparser schema-qualify,
    which the ledger requires. Only the two setting names are substituted.
    • The audit partitions carry their own copies of the parent's policies, and
      their names depend on the month, so the migration ends by calling migration
      34's public.audit_events_secure_partition over pg_inherits. That is the
      ledger's own mechanism for exactly this, and the one the runtime partition
      creator already calls — reused, not re-implemented.
    • The nine function bodies are not all the installer's to replace. The
      baseline hands seven of them to an application role (enqueue_job_message
      to app_job_worker, six to app_control_plane), so each is replaced as
      its owner
      , holding CREATE on the schema for that one statement and
      giving it back — migration 64's idiom, generated here. See What the first
      CI run caught
      below.
  • WithScopeSettings is gone (pkg/infra/postgres.go). The store is built
    through the library, so the override was a second place to keep the two names
    in step; the library's defaults are now what the policies read.
  • One constant per setting, infra.TenantScopeSetting /
    infra.UserScopeSetting (pkg/infra/tenant_tx.go), and every hand-written SQL
    site in pkg/infra names them from there: bindRequestScopeSQL, the pool's
    sessionReset, postgres_effect_receipts.go, postgres_org.go (×3),
    postgres_team.go.
  • TestNoTenantRebindingOutsidePkgInfra is the scan that stops any package
    outside pkg/infra from binding a scope. Its pattern was app.current_,
    so without this change it would have gone blind on the very day the names
    moved: control_plane_call_sites_test.go now matches codefly.current_, and
    its synthetic fixture moved with it.
  • rls-migration-gate.mjs now accepts only the two library settings. This is
    the gate that ships to consumers and holds their own migrations to the host's
    convention, so narrowing it is the point: a consumer policy left on
    app.current_org_id reads something nothing binds.
  • Docs in the same PR: module/DATABASE_AUTHORITY.md (including the sentence
    that said accounts supplies the settings — it no longer does),
    AUTHZ.md, MODULE.md, module/JOBS.md,
    module/services/accounts/AUTHZ.md, a new rule in
    module/services/accounts/AGENTS.md, two new sections in
    module/services/store/migrations/README.md (the two settings a policy reads,
    and how to replace a function the baseline reassigned), and two rows in
    CLAIM_INVENTORY.md.
  • qualification/managed-database/run.py — the RLS assertions there follow the
    rename.

Why the quiet half needed a test of its own

A missed policy does not fail. Nothing binds the old name any more, so
current_setting(…, true) returns NULL, the predicate is false, and that one
relation reads as empty for every caller — or, with the comparison the other
way round, stops isolating. A suite that sets the new name and reads the renamed
policies stays green straight through it.

So TestMigration65RenamesEveryScopeSettingAndNothingElse
(module/services/store/code, in the replay gate's ^TestMigration scope)
stands a database at version 64, fingerprints every row-security decision in
the catalog
— each relation's ENABLE/FORCE state, each policy's kind, verb,
role list and both expressions, each routine body, and every relation's and
routine's owner and ACL — applies 65, and requires the result to equal the old
fingerprint with the two names substituted and no other difference. One
equality carries every claim: a moved USING/WITH CHECK shape, verb, role list or
FORCE state is a differing line, and so is a policy the migration missed. It then
proves the renamed policies isolate (bound to org A it sees A's row and only
A's), that binding the retired name admits nothing rather than everything,
and that down 1 restores version 64's catalog exactly.

Mutation-checked: deleting the teams_tenant pair from the migration makes it
fail with the exact line, naming app.current_org_id against the expected
codefly.current_tenant_id.

TestEveryScopedPolicyReadsTheLibrarySettings (pkg/infra) is the standing
version against the live database: no policy, routine, view, constraint, default,
index or rule may read app.current_*, with floor counts so an empty or
mis-written probe cannot pass vacuously.

What the first CI run caught

The first push went red on Authorization coverage: the managed-database
qualification refused migration 65 with pq: must be owner of function enqueue_job_message (42501).

That is a real defect in the migration, and the reason it reached CI is worth
stating. A developer database is installed by a superuser, which owns
everything and may replace anything, so all 75 policies, all 9 functions and
every local test applied cleanly. The principal a managed install actually gets
is LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS CREATEROLE owning the database —
and the moment the baseline runs ALTER FUNCTION ... OWNER TO app_job_worker,
that principal no longer owns the function. NOINHERIT is what makes the
ownership check fail even though the role is a member, and CREATE OR REPLACE
also wants CREATE on the schema, which an application role is deliberately
not left holding.

The fix is the ledger's own idiom, which migration 64 established for exactly
this, emitted by the generator for each of the seven functions the baseline
reassigned:

GRANT CREATE ON SCHEMA public TO app_job_worker;
SET LOCAL ROLE app_job_worker;
CREATE OR REPLACE FUNCTION public.enqueue_job_message(...) ...;
RESET ROLE;
REVOKE CREATE ON SCHEMA public FROM app_job_worker;

Nothing was relaxed in the qualification, and no policy statement changed. The
diff against the first push is the seven wrappers and the comments explaining
them. Replacing a function preserves its owner and its grants, so the catalog
the migration produces is unchanged — which is why the fingerprint test below
still holds without being touched. The schema ACL comes back byte-identical:
after 65, app_* roles hold USAGE on public and nothing more, exactly as
at 64.

Two things made the miss possible, and the second is now closed: the local
install is a superuser, and the migrations README said nothing about function
ownership. It now has a section on it, including that the qualification needs
only Docker and Go — which is also the correction below.

Verified

Run locally against real PostgreSQL unless noted.

  • node module/tools/migration-reference-gate.mjs origin/main --replay — green,
    including TestMigrationUpgrade (clean-install equivalence) and the new
    migration-65 test.

  • All six declared accounts targets: -tags=pure ./..., ./pkg/business,
    ./pkg/infra, ./pkg/auth/pg, ./pkg/billing/pg, ./pkg/redisintegration.
    The host-interface conformance suite's isolation cases are in pkg/adapters
    and pass unchanged in meaning — nothing there names a setting.

  • python3 scripts/qualify-scoped-pools.py — 6 declared tests ran and passed,
    0 skipped. This is the direct proof that dropping WithScopeSettings makes the
    production constructor bind the library's names: those fixtures' own policies
    read them.

  • node module/tools/rls-migration-gate.mjs check, node --test module/tools/*.test.mjs (239), module/tools Go suites,
    node module/tools/naming-gate.mjs check, scripts/check-boundaries.sh.

  • python3 qualification/managed-database/run.py — 141 tests, passed. On the
    first push I wrote that this needed a fixture this machine could not produce.
    That was wrong: it needs only Docker and Go, it is what caught the ownership
    defect, and it is now run. It installs the ledger twice — once as a superuser,
    once as the least-privileged principal a managed install gets — and requires
    one schema, one authority catalog and one seed catalog from both. Its rollback
    leg reversed all 35 migrations above the irreversible frontier, so migration
    65's down half is proved under that principal as well.

    • On an arm64 machine, ci.py cross-builds the migrate binary for
      GOARCH=amd64 while the pinned postgres image resolves to arm64. I built
      golang-migrate for the container's architecture from the same pinned
      service-postgres module and passed it to run.py --migrate; CI's own
      amd64 runners are unaffected, so this is a local-only step and not a change
      to the gate.

Two things left as they were, deliberately

  • Five documents keep the old names: RLS_PLAN.md (headed Historical),
    APPROVALS_DESIGN.md, module/docs/adr/0003,
    docs/authorization/2-proposals/0001 and the ltree-record-shares spike. Each
    records a design or decision as it was written, and each already names the
    long-gone app.bypass, so renaming half of each would make it read as current
    state. CLAIM_INVENTORY.md says so in a row of its own — the repo's rule is
    not to "fix" a historical document into the present tense.
  • The generator is not committed. Migration 65's content came from a live
    catalog at version 64, which a reviewer cannot re-run; the migration-65 test
    proves the property independently of how the bytes were produced, which is the
    stronger guarantee.

Upstream, for a follow-up

service-postgres does not export its two default setting names as constants, so
this repo names them once itself against the library's documented default; and
WithScopeSettings's doc comment says the defaults are app.current_tenant_id /
app.current_user_id, which contradicts the code two hundred lines below it. Say
the word and I will file both against that repo.

Overlap

Checked #1292 (host-interface conformance in CI): it touches pkg/adapters, CI,
module/tools/modulepackage and the module manifest. This touches migrations,
pkg/infra, the RLS gate and docs. No file in common.

Checked #1298 (central RBAC enforcement, denial counter, provider sign-out,
audit archive), opened after this one. It adds no migration, so migration 65
is uncontested. Five files are common: pkg/business/service.go and
pkg/business/store.go, where each PR adds to a struct and an interface, and
AUTHZ.md, CLAIM_INVENTORY.md and module/JOBS.md, where each adds its own
section. Those are textual conflicts at rebase, not semantic ones — neither PR
changes a line the other depends on. Whichever lands first, the second rebases;
nothing needs to be re-decided. #1298 is currently red on eleven checks and
merge-blocked, so there is nothing to rebase onto yet.

🤖 Generated with Claude Code

…ry's setting names

The host bound a request's organisation and user to names of its own,
app.current_org_id and app.current_user_id, while the platform's Postgres
library defaults a scoped transaction to codefly.current_tenant_id and
codefly.current_user_id — which is what the other platform stores read. With
the document store moving to the library's names in its own repository, the
host was the last store on app.*.

Store migration 65 renames 75 policies over 70 relations and 9 function bodies
onto the library's two settings, and brings the six audit_events partitions
back in step through migration 34's audit_events_secure_partition. It is
generated from pg_policy and pg_proc under an empty search_path, which is how
the baseline's own text is rendered and what makes the deparser
schema-qualify; only the two names are substituted.

pkg/infra passes no WithScopeSettings override any more — the library's
defaults are what the policies read — and names the two settings once, in
TenantScopeSetting and UserScopeSetting.

Semantics are unchanged: transaction-local, fail-closed, forced, NOBYPASSRLS
roles, app_tenant as the request default and app_control_plane on its own
login.

A missed policy would not have failed loudly. Nothing binds the old name any
more, so current_setting(..., true) returns NULL, the predicate is false, and
that relation reads as empty for every caller.
TestMigration65RenamesEveryScopeSettingAndNothingElse therefore fingerprints
every row-security decision in the catalog at version 64 — each relation's
ENABLE and FORCE state, each policy's kind, verb, role list and both
expressions, each routine body, and every owner and ACL — and requires the
migrated catalog to equal it with the two names substituted and no other
difference, which catches a moved shape and a missed policy alike. It then
proves the renamed policies isolate, that binding the retired name admits
nothing rather than everything, and that down 1 restores version 64 exactly.
TestEveryScopedPolicyReadsTheLibrarySettings is the standing version of that
check against the live database.

Two guards had the old name baked in and would have gone blind rather than
failed: rls-migration-gate.mjs now accepts only the two library settings, and
control_plane_call_sites_test.go's rebinding scan — the one that stops any
package outside pkg/infra binding a scope — matches codefly.current_ instead
of app.current_.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
antoinetoussaint-byte added a commit that referenced this pull request Nov 3, 2026
…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>
…grator

Migration 65 replaced nine function bodies with CREATE OR REPLACE as the
principal installing the ledger. Seven of them are not that principal's to
replace: the baseline hands enqueue_job_message to app_job_worker and six
others to app_control_plane, so the managed-database qualification refused the
migration with "must be owner of function enqueue_job_message (42501)".

Nothing caught this locally because a developer database is installed by a
superuser, which owns everything and may replace anything. The principal a
managed install gets is NOINHERIT NOSUPERUSER NOBYPASSRLS CREATEROLE owning
the database: NOINHERIT is what makes the ownership check fail even though the
role is a member, and CREATE OR REPLACE also wants CREATE on the schema, which
an application role is deliberately not left holding.

Each of the seven is now replaced as its owner, granted CREATE on the schema
for that one statement and revoked again -- the idiom migration 64 established
for exactly this, emitted by the same generator that produced the rest of the
file, in both the up and the down half. No policy statement changed and the
qualification was not relaxed. Replacing a function preserves its owner and
its grants, so the catalog the migration produces is unchanged, which is why
the fingerprint test still holds untouched; the schema ACL comes back
byte-identical, with app_* roles holding USAGE on public and nothing more.

The migrations README gains the section whose absence made the miss possible:
how to replace a function the baseline reassigned, that a local superuser
install cannot catch getting it wrong, and that the qualification which can
needs only Docker and Go.

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 ad81f71 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
…d, sign-out reaches the provider, the audit log archives daily (codefly-dev#1298)

* fix(accounts): central RBAC admission enforces unless an operator opts 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>

* feat(accounts): signing out reaches the identity provider, both ways

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>

* chore(frontend): saas-sdk 0.3.43 for the Logout response contract

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>

* feat(accounts): a scheduled, leased, idempotent daily audit archive with 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>

* fix(accounts): a day's archive window stops one instant before the next 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>

* docs(configurations): the audit-archive group, in the group reference

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>

* test(accounts): record the archive's and the back-channel's control-plane 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>

* test(accounts): the business suite's logout case reads the new response

`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>

* test(gateway): route the back-channel logout receiver, and derive the 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>

* chore(frontend): the workspace lock carries saas-sdk 0.3.43

`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>

* fix(accounts): the sign-out URL is an additive RPC, not a changed Logout 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>

* fix(accounts): regenerate the event catalog, move the pinned catalog counts, name the exemption's guards

The two checks failing at 9ac18dc, both from the new work and neither the
managed-database qualification codefly-dev#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>

---------

Co-authored-by: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <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

1 participant