Repository navigation
feat: add the domain tables and enforce tenancy in PostgreSQL - #3
Merged
Merged
Conversation
quality-runtime
Bot
force-pushed
the
feat/domain-schema
branch
3 times, most recently
from
September 18, 2026 20:04
b426e08 to
3984580
Compare
Adds every tenant-owned table behind the requirement -> control -> evidence loop: control, standard, requirement, control_requirement, evidence, file and audit_event, with the shared column builders and prefixed-identifier helpers they use. The point of the package is what PostgreSQL enforces rather than what code remembers. Every domain table has row-level security enabled and forced, so the policies bind the table owner too. Foreign keys are composite on (id, organization_id), so a row referencing another tenant's row cannot be formed at all. Where the rule depends on the row, policies name each command: audit_event is append-only, evidence can be amended or discarded only until it is attested, a file is never detached and a requirement mapping never rewritten, and only a draft control may be deleted. Two migrations. 0000 is generated from the schema and replaces the one already in history, which no database has applied; 0001 is hand-written and holds what drizzle-kit cannot express together: FORCE, the policies, and two triggers. The triggers exist where a policy cannot do the job. A policy is a test, not a lock: under read committed it reads a snapshot, so file_evidence_open locks the evidence FOR NO KEY UPDATE on every attachment and refuses one to attested evidence, rather than relying on each writer to take that lock. A policy also cannot see a row's past, so control_lifecycle_enforce owns activated_at and keeps a retired control retired; with a CHECK tying a draft to a null activated_at, the lifecycle is draft -> active -> retired, one way, whatever writes the row. Both pin search_path and qualify their tables, so a temporary table cannot stand in for evidence. Append-only and signed rows also get shape checks they can never be corrected past: user attribution must be a user identifier, the system carries none, and audit before/after must be JSON objects. withOrganization opens the transaction the policies read, and refuses to nest because a savepoint does not scope SET LOCAL. assertTenantIsolation now refuses to start on a connection that bypasses row security, runs with row_security = off, or finds a domain table whose row security is not enabled and forced. db:migrate connects only as MIGRATION_DATABASE_URL, so a migration never runs as the server's role. Verified in a clean checkout of main with only this change applied: bun run check, bun run test (148 tests, including the existing server tests against this package) and reuse lint pass. packages/db/enforcement.test.ts exercises each rule as a non-superuser role. Comments cite ADRs by path; those describe decisions spanning the schema and the API, and land with the documentation later in this series. Signed-off-by: quality-runtime[bot] <330432719+quality-runtime[bot]@users.noreply.github.com>
quality-runtime
Bot
force-pushed
the
feat/domain-schema
branch
from
September 18, 2026 20:10
3984580 to
989b8d7
Compare
quality-runtime Bot
added a commit
that referenced
this pull request
Sep 18, 2026
Serve the first part of the requirement -> control -> evidence loop over HTTP: controls and their audit history, on the database enforcement from #3. Every tenant-owned route sits under `/api/v1/organizations/{organizationId}`, behind middleware that resolves the caller's membership and binds `withOrganization` to that organization; a non-member gets 404. Errors share one envelope, requests are validated with Zod, and collections page newest first by an opaque, unsigned cursor scoped to its ordering. Controls are created, read, listed, changed along the one-way `draft -> active -> retired` lifecycle, and discarded only while they never took effect and carry no evidence. Amending and discarding accept optional `If-Match` against the row's `xmin`; a change that alters nothing writes nothing. Every mutation writes an audit event in the same transaction, attributing impersonated changes to the administrator by id and name. `GET /history` serves the organization's audit trail, optionally narrowed to one record with `?resource=`, with no per-record lookup because history outlives records. `/api/v1/openapi.json` is built from the route schemas and held to the registered routes by a test; `/api/v1/reference` renders it. Races are tested against a real PostgreSQL 18, which CI provides. Standards and mapping, evidence and attestation, and files are not yet served.
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.
Adds the tenant-owned tables behind the requirement → control → evidence loop —
control,standard,requirement,control_requirement,evidence,file,audit_event— and makes PostgreSQL, not application code, enforce tenancy and finality. First of a series; builds on #1 and #2, and the server routes and docs follow.What the database enforces
(id, organization_id), so a cross-tenant reference cannot be written at all.audit_eventis append-only; evidence is amendable and discardable only until attested; a file is never detached and a mapping never rewritten; only a draft control may be deleted.file_evidence_openlocks the evidenceFOR NO KEY UPDATEon every attachment and refuses attested evidence. A policy is a test, not a lock: underread committedit would miss an attestation committing alongside.control_lifecycle_enforceownsactivated_atand keeps a retired control retired. With a CHECK tyingdraftto a nullactivated_at, the lifecycle isdraft → active → retired, one way, whatever writes the row.search_pathand qualify their tables, so a temporary table namedevidencecannot stand in for the real one.usr_…identifier, thesystemactor carries none, and auditbefore/aftermust be JSON objects.Migrations
0000_schema.sqlis generated from the schema and replaces the0000already onmain, which no database has applied.0001_tenancy_and_finality.sqlis hand-written and keeps the whole enforcement boundary in one place: FORCE, the policies and the triggers.Runtime API
withOrganizationsets the tenant for one transaction and refuses to nest, because a savepoint does not scopeSET LOCAL.assertTenantIsolationrefuses to start when the role bypasses row security, whenrow_security = off(the migrator's setting), or when any domain table's row security is not both enabled and forced.db:migratenow requiresMIGRATION_DATABASE_URLand never falls back toDATABASE_URL..env.exampleanddocs/development.mdare updated for that and nothing else.Comments cite ADRs by path. Those documents span the schema and the API, so they land with the docs PR later in this series.
Verification
In a clean checkout of
mainwith only this change applied:bun run check,bun run test(148 tests, including the existing server tests against this package) andreuse lintpass.packages/db/enforcement.test.tsexercises each rule as a non-superuser role. The race guarantees are proven against real PostgreSQL 18 by the concurrency suite, which lands with the server routes.