Feature breakdown · built with CraftHQ
Multi-tenant data isolation
Each tenant's data is isolated in the database by row-level security, forced on every table and checked in the build, so a forgotten filter in the application cannot leak across tenants. Within a tenant, which customers a staff member sees is decided by the application from their role and assignments.
Workflow
How it moved through the workflow
Select a stage. The build shipped, so every stage is complete; a person approved the merge.
Request
A shared platform must keep one tenant's data out of another's reach, enforced below the application so a forgotten filter cannot leak across tenants.
Questions
One design decision was recorded before building.
Approved
The product owner signs off the breakdown by moving the story into the approved state, and the build runs against that reference.
Definition of ready
Building
CraftHQ's build agents write the code and the tests together, run as a loop; a failing check halts the work until it passes.
Checks
Three checks run on every pull request: one on the product owner's machine as CraftHQ builds, one on the engineer's machine before review, and a code-quality scan in CI. A build check also fails if any table is missing its row-level-security policy. Merging is gated by the CI check passing and a person's approval, enforced by branch protection.
Review
An engineer reviews the pull request and merges it.
Merged
The pull request is merged to main after a person approves it. Nothing merges without that approval.
Definition of done
Breakdown
The feature breakdown
Business first, then the engineering view. Tab through it.
Overview
The gap it closes
On a shared platform, a single missed check in the application could expose one tenant's data to another. That risk is closed below the application, in the database.
Who uses it
Nobody uses it directly. The database holds the tenant boundary for every user; within a tenant, the application decides which customers a staff member sees.
User story
As a tenant, I want my data isolated from every other tenant below the application, so that a query written wrong still cannot cross tenants.
Examples
Example 1
A staff member opens a list and sees only the customers assigned to them; a customer user sees only their own records. The tenant boundary is held by the database; the per-customer view is applied by the application.
Example 2
A query in the application is written without its tenant filter. The database still returns only the current tenant's rows, so a forgotten filter cannot leak across tenants. The tenant pin is the trusted step, and it is tested.
Dependencies & risks
Depends on
A database that supports row-level security, a tenant key on every table, and a tenant setting pinned per transaction.
Risk & mitigation
A tenant pin set wrong, or a pooled connection carrying a stale pin, is the thing to defend against. Reduced by pinning per transaction, forcing row-level security on every tenant-scoped table (checked in the build), and testing the boundary. Customer-level separation inside a tenant is application logic, so it carries its own tests.
Tenancy model
Two levels. The tenant is the top-level boundary, and the database isolates each tenant with row-level security forced on every table (a policy on every tenant-scoped table, verified by a check in the build). A query carries a tenant setting pinned per database transaction, not per connection, so a pooled connection never carries another tenant's context, and with no tenant set a query is refused and fails closed. The application role runs under the policies and does not own the tables, and background jobs pin their tenant the same way before they read or write. Within a tenant, separation between customers is enforced by the application: a staff member sees only the customers assigned to them, and a customer user sees only their own records. That inner separation is enforced in application code and is tested.
How it works
Simplified diagrams of the parts involved, the steps of one request, and where it runs.
Architecture
- 1request
- 2open a transaction, pin the tenant
- 3run the query (scoped by row-level security)
Sequence
- 1read a list of records
- 2open a transaction and pin the active tenant
- 3run the query in that transaction
- 4only the current tenant's rows
- 5the scoped list (narrowed to the user's customers in the app)
Infrastructure
- 1request (authenticate + tenant check)
- 2scoped query per tenant
Acceptance criteria
Each scenario is written as a Gherkin spec. The Engineering tab shows the test that covers each one, or notes where it was verified by review.
@platform @securityFeature: Multi-tenant data isolationAs a tenantI want my data isolated from every other tenant below the applicationSo that a query written wrong still cannot cross tenantsTwo levels. The tenant is the top-level boundary: its rows are isolated in the database by row-level security forced on every table (checked in the build), with the tenant setting pinned per transaction. Within a tenant, which customers a staff member sees is decided by the application from their role and assignments, not by the database.Background:Given the platform serves many tenantsAnd a signed-in user belongs to one tenantRule: A read only ever returns the current tenant's rowsScenario: A query is scoped to the current tenantGiven the active tenant is pinned for the requestWhen the user reads a list of recordsThen only that tenant's rows are returnedScenario: A cross-tenant read returns nothingGiven a record belongs to another tenantWhen the user tries to read itThen no row is returned, even on a raw queryRule: Every write is pinned to the current tenantScenario: A write is stamped with the tenantGiven the active tenant is pinned for the requestWhen the user creates a recordThen it is stored pinned to that user's tenantScenario: A write aimed at another tenant is refusedGiven the active tenant is pinned for the requestWhen a write tries to stamp a row with another tenantThen the write is refusedRule: Isolation does not depend on the application getting it rightScenario: A forgotten filter cannot leak across tenantsGiven a query is missing its tenant filter in the applicationWhen it runs inside a transaction with the tenant pinnedThen row-level security still limits it to the current tenantScenario: No tenant pinned fails closedGiven no tenant has been pinned for the transactionWhen a query runsThen the query is refused and fails closed, rather than returning any rowsRule: Background jobs are scoped like any requestScenario: A background job pins its tenant before it reads or writesGiven a background job runs outside any user requestWhen it opens a transactionThen it pins its tenant before it reads or writesAnd it is limited to that tenant's rowsRule: Within a tenant, customers are separated by the applicationScenario: A staff member sees only their assigned customersGiven a staff member is mapped to some of the tenant's customersWhen they read a listThen they see only the customers assigned to them, enforced in application codeScenario: A customer user sees only their own recordsGiven a customer user signs inWhen they read their workspaceThen they see only their own records@edge-casesScenario Outline: Access across the tenant boundaryGiven a user from tenant "<viewer>"When they request a record owned by tenant "<owner>"Then the result is "<result>"Examples:| viewer | owner | result || A | A | visible || A | B | not found || B | A | not found |
Questions
Questions raised in planning, each with the decision taken.
Should the tenant boundary be enforced in the application or in the database?
Enforcing it in the application relies on every query being written correctly. Enforcing it in the database closes the gap even when a query is wrong.
Components touched
Row-level security
Forced on every tenant-scoped table (verified by a check in the build), so each query is limited to the current tenant at the database. The application role runs under the policies and does not own the tables.
Tenant pin
A tenant setting applied per database transaction (not per connection), so a pooled connection never carries another tenant's context. With no tenant pinned, a query is refused and fails closed.
Composite tenant keys
The tenant is part of each table's key, so a record cannot be mis-joined across tenants.
Background jobs
A reminder, expiry or packaging job opens a transaction and pins its tenant the same way before it reads or writes, so a job is scoped like any request.
Application customer scoping
Within a tenant, a staff member's reach is the customers assigned to them (from their role and mappings), and a customer user sees only their own records. This separation is enforced in application code and is tested.
Data touched
All tablesread
Every table carries a tenant key and a forced row-level-security policy.
Tenant settingwrite
Set per transaction to the active tenant; the database uses it to scope every query. With no tenant pinned, a query is refused and fails closed.
Security decisions
- The tenant boundary is enforced at the database with row-level security forced on every tenant-scoped table (checked in the build); the application role runs under it.
- The tenant is pinned per transaction, not per connection, so connection pooling cannot carry a stale tenant.
- With no tenant pinned, a query is refused and fails closed.
- The tenant is part of each table's key, so records cannot be mis-joined across tenants.
- A forgotten tenant filter in the application still cannot leak data, because the database scopes the query; the boundary is tested.
- Within a tenant, which customers a staff member sees is decided by the application from their role and assignments; this customer-level separation is enforced in application code and tested.
Subtasks
- Force row-level security on every table, with a build check that fails on a missing policy.
- Pin the active tenant per transaction, failing closed when unset.
- Add composite tenant keys across the schema.
- Scope background jobs the same way; scope customers within a tenant in the application.
- Write boundary probes that attempt cross-tenant reads and writes.
How each scenario is verified
- A query is scoped to the current tenantAn integration test reads a list as one tenant and confirms only that tenant's rows come back, including a case where the application filter is dropped.
- A cross-tenant read returns nothingAn integration test probes a read across the tenant boundary and confirms no row is returned, even on a raw query.
- A write into another tenant is refusedA test over composite tenant keys confirms a write stamped for another tenant is refused.
- An admin of another tenant aggregates nothingA cross-tenant dashboard test confirms an administrator of another tenant aggregates zero rows, so the boundary holds even for an aggregate read.
- The audit trail is isolated per tenantAn integration test confirms audit rows are isolated per tenant too.
- Customer-level separation within a tenantAn access test confirms a staff member sees only their assigned customers and a customer user only their own records.
Risk tier
Core A person approves every merge. This is a Core-tier change, so it also carries the fullest set of automated tests; a regression here affects the whole product, not one page.
Start here
Want this on your backlog?
Get a free baseline report on one repository, or scope a 3-month pilot measured against what your team’s work costs today.