Repository navigation
refactor(accounts): bind and read the tenant and user under the library's setting names - #1294
Merged
antoinetoussaint-byte merged 2 commits intoNov 3, 2026
Conversation
…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>
This was referenced Nov 4, 2026
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>
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 #1293.
The host bound the caller's organisation and user to setting names of its own —
app.current_org_idandapp.current_user_id— while the platform's Postgreslibrary 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,
NOBYPASSRLSroles,app_tenantas the request default,app_control_planeon 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 70relations, 9 function bodies, and the 6 partitions of
public.audit_events.Generated from
pg_policyandpg_procrather than written by hand: thecatalog 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.
their names depend on the month, so the migration ends by calling migration
34's
public.audit_events_secure_partitionoverpg_inherits. That is theledger's own mechanism for exactly this, and the one the runtime partition
creator already calls — reused, not re-implemented.
baseline hands seven of them to an application role (
enqueue_job_messageto
app_job_worker, six toapp_control_plane), so each is replaced asits owner, holding
CREATEon the schema for that one statement andgiving it back — migration 64's idiom, generated here. See What the first
CI run caught below.
WithScopeSettingsis gone (pkg/infra/postgres.go). The store is builtthrough 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.
infra.TenantScopeSetting/infra.UserScopeSetting(pkg/infra/tenant_tx.go), and every hand-written SQLsite in
pkg/infranames them from there:bindRequestScopeSQL, the pool'ssessionReset,postgres_effect_receipts.go,postgres_org.go(×3),postgres_team.go.TestNoTenantRebindingOutsidePkgInfrais the scan that stops any packageoutside
pkg/infrafrom binding a scope. Its pattern wasapp.current_,so without this change it would have gone blind on the very day the names
moved:
control_plane_call_sites_test.gonow matchescodefly.current_, andits synthetic fixture moved with it.
rls-migration-gate.mjsnow accepts only the two library settings. This isthe 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_idreads something nothing binds.module/DATABASE_AUTHORITY.md(including the sentencethat said accounts supplies the settings — it no longer does),
AUTHZ.md,MODULE.md,module/JOBS.md,module/services/accounts/AUTHZ.md, a new rule inmodule/services/accounts/AGENTS.md, two new sections inmodule/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 therename.
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 onerelation 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^TestMigrationscope)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 1restores version 64's catalog exactly.Mutation-checked: deleting the
teams_tenantpair from the migration makes itfail with the exact line, naming
app.current_org_idagainst the expectedcodefly.current_tenant_id.TestEveryScopedPolicyReadsTheLibrarySettings(pkg/infra) is the standingversion against the live database: no policy, routine, view, constraint, default,
index or rule may read
app.current_*, with floor counts so an empty ormis-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 CREATEROLEowning the database —and the moment the baseline runs
ALTER FUNCTION ... OWNER TO app_job_worker,that principal no longer owns the function.
NOINHERITis what makes theownership check fail even though the role is a member, and
CREATE OR REPLACEalso wants
CREATEon the schema, which an application role is deliberatelynot 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:
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 holdUSAGEonpublicand nothing more, exactly asat 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 newmigration-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/adaptersand 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
WithScopeSettingsmakes theproduction 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/toolsGo suites,node module/tools/naming-gate.mjs check,scripts/check-boundaries.sh.python3 qualification/managed-database/run.py— 141 tests, passed. On thefirst 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.
ci.pycross-builds the migrate binary forGOARCH=amd64while the pinned postgres image resolves to arm64. I builtgolang-migrate for the container's architecture from the same pinned
service-postgresmodule and passed it torun.py --migrate; CI's ownamd64 runners are unaffected, so this is a local-only step and not a change
to the gate.
Two things left as they were, deliberately
RLS_PLAN.md(headed Historical),APPROVALS_DESIGN.md,module/docs/adr/0003,docs/authorization/2-proposals/0001and theltree-record-sharesspike. Eachrecords 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 currentstate.
CLAIM_INVENTORY.mdsays so in a row of its own — the repo's rule isnot to "fix" a historical document into the present tense.
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-postgresdoes not export its two default setting names as constants, sothis repo names them once itself against the library's documented default; and
WithScopeSettings's doc comment says the defaults areapp.current_tenant_id/app.current_user_id, which contradicts the code two hundred lines below it. Saythe word and I will file both against that repo.
Overlap
Checked #1292 (host-interface conformance in CI): it touches
pkg/adapters, CI,module/tools/modulepackageand 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.goandpkg/business/store.go, where each PR adds to a struct and an interface, andAUTHZ.md,CLAIM_INVENTORY.mdandmodule/JOBS.md, where each adds its ownsection. 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