All features

Feature breakdown · built with CraftHQ

PlatformSecurity

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

The questions are answered.
The acceptance criteria are testable.
The size is estimated.
The scope is checked for splitting.
The product owner has signed off.

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

Every acceptance criterion is checked; where a scenario has a dedicated test it is cited, and where it was verified by review that is stated.
The three checks are green.
A person approved the merge.

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

Client (untrusted)
Tenant A userreads records
Tenant B userreads records
Your app
App serverpins the tenant per transaction
Row-level securityforced on every table
Databasetenant key on every table
  • 1request
  • 2open a transaction, pin the tenant
  • 3run the query (scoped by row-level security)
Simplified.

Sequence

Browsera signed-in user
App serverpins the tenant per transaction
Databaseforced row-level security
  • 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)
Simplified.

Infrastructure

Client (untrusted)
Web appruns in the browser
Your app
App serverauthenticates, pins the tenant per transaction
Databaseper-tenant isolation, forced row-level security
  • 1request (authenticate + tenant check)
  • 2scoped query per tenant
Simplified.

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 @security
Feature: Multi-tenant data isolation
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
Two 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 tenants
And a signed-in user belongs to one tenant
Rule: A read only ever returns the current tenant's rows
Scenario: A query is scoped to the current tenant
Given the active tenant is pinned for the request
When the user reads a list of records
Then only that tenant's rows are returned
Scenario: A cross-tenant read returns nothing
Given a record belongs to another tenant
When the user tries to read it
Then no row is returned, even on a raw query
Rule: Every write is pinned to the current tenant
Scenario: A write is stamped with the tenant
Given the active tenant is pinned for the request
When the user creates a record
Then it is stored pinned to that user's tenant
Scenario: A write aimed at another tenant is refused
Given the active tenant is pinned for the request
When a write tries to stamp a row with another tenant
Then the write is refused
Rule: Isolation does not depend on the application getting it right
Scenario: A forgotten filter cannot leak across tenants
Given a query is missing its tenant filter in the application
When it runs inside a transaction with the tenant pinned
Then row-level security still limits it to the current tenant
Scenario: No tenant pinned fails closed
Given no tenant has been pinned for the transaction
When a query runs
Then the query is refused and fails closed, rather than returning any rows
Rule: Background jobs are scoped like any request
Scenario: A background job pins its tenant before it reads or writes
Given a background job runs outside any user request
When it opens a transaction
Then it pins its tenant before it reads or writes
And it is limited to that tenant's rows
Rule: Within a tenant, customers are separated by the application
Scenario: A staff member sees only their assigned customers
Given a staff member is mapped to some of the tenant's customers
When they read a list
Then they see only the customers assigned to them, enforced in application code
Scenario: A customer user sees only their own records
Given a customer user signs in
When they read their workspace
Then they see only their own records
@edge-cases
Scenario Outline: Access across the tenant boundary
Given 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.

AIn the database: force row-level security on every table, so a forgotten filter cannot leak across tenants. · recommended
BIn the application: scope every query in code.
Decided: In the database for the tenant boundary, with forced row-level security on every table and the tenant pinned per transaction (option A). Customer-level separation inside a tenant is enforced in the application.

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 tenant
    An 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 nothing
    An 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 refused
    A test over composite tenant keys confirms a write stamped for another tenant is refused.
  • An admin of another tenant aggregates nothing
    A 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 tenant
    An integration test confirms audit rows are isolated per tenant too.
  • Customer-level separation within a tenant
    An 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.

Measured from your own git history
Every pull request tested before review
A person approves every merge