The multi-tenant data isolation decision record: why the isolation model you chose determines your cross-tenant data exposure surface and your query parameter trust boundary failure

Multi-tenant isolation model, tenant identity derivation source, and database-layer enforcement are established early, against a product surface small enough that the isolation mechanism is applied consistently through shared developer context and the set of endpoints that touch tenant data is small enough to audit by memory. Three failure patterns develop as the product surface, the engineering team, and the customer base grow past the conditions the founding isolation model was designed for: the bulk export endpoint added by a developer who correctly implemented authentication but omitted the authorization check that verifies the requesting session belongs to the tenant being exported — discovered eight months later when a security researcher iterated 4,700 tenant IDs and downloaded each one's complete dataset; the background job that processed data correctly in a request context for months before someone wrote a job with no request context and the ORM plugin's tenant scope never activated, producing six weeks of incorrect per-customer numbers from a nightly report aggregating all tenants' data; and the Row Level Security implementation that was bypassed from its first deployment because the connection pool used a PostgreSQL superuser account, discovered eleven days after a library upgrade introduced connection pool state leakage that the database-layer enforcement should have caught.

A 28-person B2B SaaS that built workflow automation tools for operations teams had chosen the standard shared database isolation model during the founding technical sessions: a single PostgreSQL database, tenant_id on every table, application-layer WHERE tenant_id = ? on every query, enforced through the backend framework's ORM. The choice was deliberate — the founding engineer had worked at a company that had tried schema-per-tenant at forty tenants and spent eight weeks migrating away from it when the schema management overhead became unmanageable; the shared database model was simple, well-understood, and had served the founding team well through the first year and a half of growth. By the time the company reached twenty-eight engineers, the product had grown into a platform with eleven separate service areas, three internal APIs, and a library of about sixty distinct API endpoints accessible to customers.

The tenant identity derivation model — how the application determined which tenant's data a given request was entitled to see — was documented in the backend's README in a single paragraph: all API requests must include a valid session token; the session token is validated by the auth middleware, which sets a thread-local current_tenant_id from the session record before the request handler executes; all database queries use the ORM's built-in .forTenant(current_tenant_id) method which appends WHERE tenant_id = ? automatically. The model was correct and had been correctly implemented across the original forty-seven endpoints that existed when the documentation was written. What the documentation had not specified was where current_tenant_id came from — which data source was the authoritative source of tenant identity and which data sources were explicitly not authoritative.

Fifteen months after the README was written, a product manager requested a bulk export endpoint for enterprise customers who wanted to pull their complete workflow history into their own data warehouse. The backend engineer assigned to the feature built it quickly — authentication via the existing session token middleware, rate limiting via the existing rate limit middleware, output formatting via the existing serialization layer. The endpoint accepted the target tenant ID as a URL path parameter: GET /api/tenants/{tenantId}/exports/workflows. The engineer built the endpoint correctly in every respect he was thinking about: the session token was required and validated, the output was correctly paginated, the rate limit was applied. What he did not add was the authorization check that verified the session's tenant matched the tenantId in the URL path parameter. The .forTenant() call in the endpoint used the path parameter directly, not the session-derived current_tenant_id.

The endpoint shipped in a Tuesday deployment. It was not covered by the application's security test suite — the suite tested authentication but not authorization, checking that requests without valid tokens were rejected but not that requests with valid tokens for tenant A could not access tenant B's data. In the code review, no one flagged the missing authorization check because the endpoint followed the correct structural pattern: authenticate, rate limit, query, serialize. The authorization check — verifying that the authenticated tenant was the same as the requested tenant — was an additional step that the pattern did not make visible as missing.

Eight months after the endpoint shipped, a security researcher submitted a responsible disclosure report. The report documented a complete enumeration of the company's tenant dataset: by authenticating as a valid account with a free trial, iterating the tenantId path parameter from 1 through 4,700, and calling the bulk export endpoint for each value, the researcher had downloaded the complete workflow history for all 4,700 tenant organizations active in the database. The endpoint had returned HTTP 200 with each tenant's data — there were no errors, no rate limit triggers that would have distinguished normal traffic from an enumeration scan, and no monitoring alert for cross-tenant data access because the application logs recorded the authenticated session's tenant ID and the queried tenant ID in separate log fields that were never compared. The researcher had done the enumeration over eleven days to avoid triggering rate limits and had submitted the disclosure with a proof-of-concept demonstrating the full dataset was accessible.

The founding technical session had documented the isolation model — shared database, tenant_id on all tables, ORM-based filtering — without specifying that the tenant_id used in the ORM filter must be derived exclusively from the authenticated session's verified claims, not from caller-supplied parameters. It had not specified that any endpoint accepting a tenant identifier as a parameter required an authorization check verifying the authenticated session was entitled to access that tenant's data. It had not specified that the security test suite must include a cross-tenant access test for every endpoint that accepts a tenant identifier as a parameter. These omissions were invisible when the founding team was building the original endpoint set, where the tenant_id always came from session context and the pattern was applied consistently through shared developer knowledge rather than documented constraint. At twenty-eight engineers with sixty endpoints, shared knowledge was no longer a sufficient enforcement mechanism.

A 37-person product SaaS that built a project management tool for design and engineering teams had chosen the same shared database isolation model and had implemented it via an ORM plugin — a library that automatically scoped all database queries by the current request's tenant context. The plugin was configured in the application's request lifecycle: at the start of every request, the authentication middleware extracted the tenant_id from the verified session token and stored it in the plugin's request-scoped context store; every subsequent database query in that request automatically appended WHERE tenant_id = ? using the stored value. The plugin had worked reliably through the first two years of growth. The founding engineers trusted it; engineers joining the team learned through code review that the ORM plugin handled tenant scoping automatically and that they did not need to add tenant_id filters to their individual queries.

The plugin's documentation, which none of the non-founding engineers had read in full, contained a section titled "Non-request contexts." The section explained that the plugin's context store was backed by AsyncLocalStorage, which preserved context across async operations within a single Node.js async context tree rooted in the request handler — and that code executing outside of a request handler, such as background jobs, cron tasks, and scripts, was not within any request's async context tree and would have an empty context store. Queries executed in a non-request context would receive no WHERE tenant_id = ? filter. The plugin's default behavior in the absence of a tenant context was documented as returning all rows — the assumption being that scripts and administrative tools legitimately needed cross-tenant access — rather than raising an error.

In the product's second year, the engineering team built a nightly report job that ran at 2:00 AM and generated a per-customer usage summary for the previous day: total tasks completed, active projects, team member activity, storage used. The job was a Node.js script executed by a cron runner, not by the application server. It iterated a list of customer accounts, queried each account's data, and wrote the summary records to the usage_summaries table. The developer who wrote the job followed the pattern she had learned from the request-handling code: query the relevant tables, aggregate the results, write the summary. She did not initialize the tenant context because the request lifecycle — where the auth middleware set the context — was not part of the job's execution path, and the plugin's documentation note about non-request contexts was not surfaced in code review or in the internal onboarding materials for new backend engineers.

The job ran successfully every night for six weeks. Each run produced usage summary records for all active customer accounts. The summaries were correct in their structure — the right columns, the right data types, the right date ranges. What they were not was scoped to the correct tenant. Because the ORM plugin's WHERE tenant_id = ? filter was not applied, the "total tasks completed yesterday" number for each customer was the total number of tasks completed across all customers combined. The "active projects" count was the total active project count across the entire product. The "storage used" calculation was the total storage consumed by all tenants. Each customer's dashboard — which read from the usage_summaries table — had been displaying numbers that were wrong by a factor of roughly the number of active customers for six weeks. The numbers were wrong in the direction of being too large, which most customers interpreted as a dashboard bug rather than as a data exposure, and which delayed the discovery.

The issue was found when a customer contacted support to report that their usage dashboard showed 184,000 tasks completed the previous day on a team of twelve people. The support engineer who reviewed the ticket queried the usage_summaries table directly and immediately saw that every customer's summary contained the same aggregate numbers. The investigation took ninety minutes from the support ticket to the root cause identification. No individual customer records had been leaked — the bug was in the aggregation, not in a record-level query — but every customer had been shown every other customer's aggregate usage statistics for six weeks, which was a cross-tenant data disclosure in the regulatory sense even though no record content was exposed.

The founding technical session had documented the ORM plugin choice and the automatic tenant scoping it provided without specifying that the plugin's scope only activated in request-response contexts. It had not specified that background jobs, cron tasks, administrative scripts, and migration utilities required explicit tenant context initialization before executing any database query. It had not specified that a missing tenant context in a non-request execution environment was an error condition that should fail loudly rather than silently returning cross-tenant data. These omissions were invisible at founding when the only code paths were request handlers where the plugin worked correctly, and they became structural risk points as the product grew complex enough to require background processing that operated outside the request lifecycle.

A 44-person analytics SaaS that built business intelligence tooling for mid-market companies had made the same shared database model choice and had added a database-layer enforcement mechanism that the first two companies had not: PostgreSQL Row Level Security. The founding engineers had read the post-mortems of several high-profile SaaS cross-tenant data exposures and had deliberately chosen to add a database-layer enforcement mechanism that would catch application-layer isolation failures as a defense-in-depth backstop. The choice was architecturally sound. The implementation had a flaw that the founding engineers were not aware of.

RLS in PostgreSQL works by attaching policies to tables that evaluate a condition for each row — the policy for the company's tenant tables was: USING (tenant_id = current_setting('app.tenant_id')::uuid). Before each database query, the application set the session-local configuration value with SET LOCAL app.tenant_id = ?. The policy evaluated the condition against that value and returned only the rows where the tenant_id column matched. The founding engineers had tested the policy during implementation: they had connected to the database as the application service account, set the configuration value, and verified that queries returned only the expected tenant's rows. The tests passed. The RLS policy was working correctly under the testing conditions.

What the founding engineers had not verified was the service account's privilege level. The application's PostgreSQL service account — created in the initial database provisioning script, two years before the RLS implementation — had been created as a superuser to simplify the initial setup and avoid debugging permission errors during the early-stage product sprint. No one had revisited the service account's privilege level after the initial sprint. PostgreSQL superusers bypass all Row Level Security policies by default, regardless of how the policies are configured. The RLS policy the founding engineers had implemented and tested was not active for any query executed by the superuser service account. The testing that confirmed the policy worked had been done by the engineers using their own non-superuser database accounts to verify the policy logic, then switching to the application's superuser account to test the full application stack — and the application stack tested correctly because the application's WHERE tenant_id = ? application-layer filter was working correctly at test time.

The RLS layer was present but inactive. The application-layer filtering was providing the only actual isolation. This was not discovered during implementation because the application-layer filter was working correctly throughout. The RLS layer's inactivity only mattered if the application-layer filter failed — which was exactly the scenario it was designed to catch.

In the third year of operation, the engineering team upgraded a connection pool management library to resolve a memory leak in the existing version. The new library version had a behavioral change in how connections were returned to the pool after transaction completion: in specific error-path scenarios — a query that threw a JavaScript exception after the database query had completed but before the transaction commit was sent — the library returned the connection to the pool without executing the cleanup callbacks that the application relied on to reset the session-local configuration. SET LOCAL is scoped to the current transaction in PostgreSQL and resets when the transaction ends with COMMIT or ROLLBACK. But the library's error-path connection return was bypassing the COMMIT/ROLLBACK, leaving the connection in a mid-transaction state with the previous request's app.tenant_id value still set. The next request that acquired that connection from the pool began executing with a stale tenant context before it set its own context.

For eleven days, approximately four percent of requests — those that acquired connections that had been improperly returned — executed their first database queries against a mismatched tenant context before the SET LOCAL in that request's setup code was reached. The mismatched queries returned data from the prior request's tenant. The application's response for those requests contained a mix of the current session's tenant data and the prior session's tenant data in the fields populated by the first queries. The mixing was partial — later queries in the same request executed after the SET LOCAL ran correctly — which meant the affected responses were inconsistent rather than entirely wrong, making the failure mode difficult for customers to identify and report.

The RLS layer that should have caught this at the database level — returning only the rows matching the current session's tenant context, regardless of what the application layer set — was not active because the service account was a superuser. The eleven days of cross-tenant data leakage occurred in a window where a defense-in-depth mechanism that had been designed and implemented specifically to catch application-layer isolation failures was bypassed by a privilege configuration that had never been reviewed after the initial database provisioning two years earlier.

The founding technical session had documented the RLS implementation — the policy syntax, the application's SET LOCAL pattern, the table coverage — without specifying the service account privilege level required for the RLS policy to be active. It had not specified that the service account must not be a superuser and must not have the BYPASSRLS attribute. It had not specified a verification procedure for confirming that RLS was enforcing isolation for queries executed under the application service account rather than only under the non-superuser accounts used to verify policy logic during implementation. These omissions were invisible at founding because the application-layer filtering was sufficient isolation in the absence of connection pool state leakage, and the defense-in-depth value of the RLS layer was never tested against an application-layer isolation failure.

Structural properties set by the multi-tenant data isolation decision

Three structural properties are determined when a founding team chooses its isolation model, its tenant identity derivation source, and its database-layer enforcement approach. None are labeled explicitly in the founding technical session — they are security properties that emerge from the assumptions the founding decisions embed about how the product will grow, which execution contexts will run database queries, and what the relationship is between the application service account's privilege level and the database-layer enforcement mechanism.

Property 1: The tenant identity derivation model and the query parameter trust boundary. The trust boundary violation — accepting a tenant identifier from a caller-supplied parameter and using it to scope a database query without verifying the authenticated session is entitled to access that tenant's data — is a structural property of any endpoint that routes through URL path parameters, query strings, or request bodies rather than deriving tenant identity exclusively from the authenticated session token. The failure mode is that a developer adding a new endpoint correctly implements authentication and other cross-cutting concerns but omits the authorization check because the omission is not surfaced by the type system, the ORM plugin, or the authentication middleware. Authentication middleware verifies that a session is valid; it does not verify that the authenticated session is entitled to access the specific tenant resource being requested. These are distinct concerns, and the distinction is invisible in any codebase where the authoritative source of tenant identity has never been explicitly documented as the session token — not the URL path parameter, not the request body's tenant field, not an environment variable, not a configuration value. The structural fix is to specify, in the isolation model decision record, that the tenant_id used in every database query must be derived from the authenticated session's verified claims, and that any endpoint accepting a tenant identifier as a parameter must include an explicit authorization check verifying that the authenticated session's tenant matches the parameter value — or, preferably, that the parameter is removed and the tenant identity is always derived from the session without a parameter at all. The security test suite must include a cross-tenant access test for every endpoint that accepts a tenant identifier as a parameter: authenticate as tenant A, supply tenant B's identifier, verify that tenant B's records are not returned. The API security decision record connects here — the missing authorization check in the bulk export endpoint is structurally identical to the webhook receiver endpoint that lacked application-layer authentication: both are caused by adding endpoints outside the enforcement mechanism's scope, where the type system and the framework provide no signal that a required check is missing.

Property 2: The application-layer isolation mechanism and the background job enforcement gap. Application-layer isolation mechanisms — ORM default scopes, middleware-applied tenant filters, request-scoped context providers backed by AsyncLocalStorage or thread-locals — only activate in request-response contexts where the activation mechanism is wired into the application's request lifecycle. Background jobs, scheduled tasks, data pipeline processors, administrative tools, and database migration scripts run in non-request contexts and are outside the activation scope of request-lifecycle mechanisms. The failure mode produced by this gap is silent operation without a tenant context — returning all tenant records, aggregating across all tenants, or processing records without a scope boundary — when a developer writes a non-request-context code path and follows the pattern that works in request contexts without being aware that the pattern requires an explicit initialization step in non-request contexts. The structural fix has three components. First, fail-on-missing-context: the tenant context provider must raise an error when a tenant-scoped query is attempted without any tenant context initialized, rather than silently returning all tenant records. This is a default-safe configuration that requires non-request code to explicitly opt into the cross-tenant access mode rather than accidentally receiving it through omission. Second, explicit context initialization requirement: all non-request code paths that require database access must initialize the tenant context before executing any query — either with a specific tenant identifier for single-tenant jobs, or with an explicit super-tenant or cross-tenant scope for jobs that legitimately need cross-tenant data. The distinction between "cross-tenant access intentionally requested" and "cross-tenant access because no scope was set" must be visible in the code and in the application log. Third, integration test coverage: every background job must have an integration test that runs the job against a multi-tenant fixture and verifies that only the intended tenant's records are read or modified. A test against a single-tenant fixture cannot verify tenant isolation. The data access layer decision record is the upstream context: the repository pattern determines where tenant isolation logic lives and how it is applied; if the isolation is in the ORM default scope rather than in the repository method interface, the enforcement gap in non-request contexts is invisible from the repository layer's API surface.

Property 3: The database-layer isolation mechanism and the connection pool trust surface. Database-layer isolation via PostgreSQL Row Level Security requires two conditions to be active simultaneously: the RLS policy must be correctly defined on the tables containing tenant-scoped data, and the database service account used by the application connection pool must not be a superuser and must not have the BYPASSRLS attribute. If either condition is not met, the RLS policy is not enforced for that account's queries. The service account privilege level is a deployment-time configuration property set when the database is provisioned; it is not revisited in the course of normal feature development, and it is not visible to developers implementing RLS policies who test the policy logic using their own non-superuser development accounts. The disconnect between where the RLS policy is implemented (by developers, in application code) and where the service account privilege level is configured (by infrastructure, in deployment scripts) means the implementation can be correct and the enforcement inactive simultaneously, with no observable difference in application behavior as long as the application-layer filtering is working. The connection pool is the second trust surface: SET LOCAL is scoped to the current transaction and resets at transaction end; connection pool implementations that return connections to the pool in error paths without executing COMMIT or ROLLBACK may leave the previous transaction's SET LOCAL values active in the connection state, which the next request that acquires that connection will inherit before its own SET LOCAL executes. The defensive mechanism for connection pool context leakage is to set the tenant context fresh at connection checkout — execute SET LOCAL app.tenant_id = ? immediately after acquiring a connection from the pool, before executing any other statement — rather than relying on the prior transaction's cleanup. An additional defensive check is to assert that the current connection's tenant context matches the expected context immediately before executing tenant-scoped queries: SELECT current_setting('app.tenant_id', true) must equal the session's expected tenant_id; if it does not, raise an application error and return the connection to the pool rather than executing the query with the wrong tenant context. The database backup verification decision record connects at the exposure recovery layer: a cross-tenant data exposure discovered months after the fact requires the ability to reconstruct what data was accessible during the exposure window; PITR availability determines whether the data state at the time of exposure can be recovered for regulatory assessment, or whether only the audit log is available.

What the founding session records and what it omits

The founding technical session on multi-tenant isolation — typically a short conversation between the founding engineers during the initial database design sprint — records the isolation model choice: shared database, schema-per-tenant, or database-per-tenant. It records the implementation approach: an ORM plugin, a middleware filter, or manual WHERE clauses on every query. If the team is particularly security-conscious, it records the decision to add Row Level Security as a defense-in-depth layer. What it does not record is the authoritative source of tenant identity — which data source is definitively authoritative and which sources are explicitly not authoritative. It does not record the non-request context isolation requirement or the fail-on-missing-context behavior that prevents silent cross-tenant access in background jobs. It does not record the service account privilege level required for the database-layer enforcement to be active.

These omissions are identical in structure to the rest of the founding decisions in this series: they are benign at founding, when the omissions are covered by shared developer context. The tenant identity derivation model is not a risk at three engineers when all API endpoints are built by the same people who built the authentication middleware and who understand intuitively that tenant identity comes from the session, not from the URL. The background job enforcement gap is not a risk when the only background jobs are written by the founding engineers who know the ORM plugin's non-request context behavior from having read its documentation. The service account privilege level is not a risk when the application-layer filtering is working correctly and the defense-in-depth layer has never needed to catch an application-layer failure.

The failure modes develop at different rates. The query parameter trust boundary failure develops as soon as the product surface has grown large enough that new endpoints are being written by developers who did not build the authentication middleware and who are not aware of the undocumented constraint that tenant identity must be derived from the session. This typically occurs within twelve to eighteen months of reaching ten or more engineers contributing to the backend. The background job enforcement gap develops as soon as the product requires background processing in non-request contexts — which typically occurs within the first year for any product with notifications, reports, scheduled data operations, or integrations that must push data on a schedule rather than in response to user requests. The connection pool trust surface failure develops only when an application-layer isolation failure occurs — which may never happen, or may happen at any point when a library upgrade, a configuration change, or a developer error produces application-layer state leakage. The RLS superuser bypass makes the defense-in-depth layer permanently inactive from its first deployment, which means the window of risk is as long as the service account has been a superuser.

The multi-tenant isolation ADR closes these gaps by documenting the authoritative source of tenant identity, the non-request context isolation requirement, the fail-on-missing-context behavior, the service account privilege level, and the connection pool context management protocol at the time the isolation model is established — not after each failure mode has produced a cross-tenant data exposure, a regulatory notification requirement, or an eleven-day data leakage window. The decisions never written down in the isolation domain are not the isolation model itself — every multi-tenant product has a documented isolation model. They are the authoritative identity source (which source is authoritative and which are not), the non-request enforcement requirement (what behavior is required when no tenant context is set), and the service account privilege constraint (what privilege level makes the database-layer enforcement active). The new CTO onboarding problem is specific in the isolation context: the incoming technical leader finds the isolation model documented in the backend README and the ORM plugin configured correctly in the request lifecycle, but cannot determine whether the authoritative identity source has been formally specified, whether background jobs have been audited for explicit tenant context initialization, or whether the database service account is a superuser whose queries bypass the RLS policies that are documented as the defense-in-depth layer. The isolation ADR makes those decisions explicit and auditable. The WhyChose extractor finds the multi-tenant isolation discussions in your AI session history — the conversation where the founding engineer chose between shared database and schema-per-tenant, debated whether to add Row Level Security, configured the ORM plugin's tenant scope, or decided what the application should do when no tenant context is available — and surfaces those parameters so you can assess which assumptions still hold and which have been superseded by the product surface and team growth that has happened since the founding isolation session.

The multi-tenant data isolation ADR: five sections

Section 1: Isolation model and tenant identity derivation. Specify the chosen isolation model — shared database with tenant_id on all tables, schema-per-tenant, or database-per-tenant — and document the founding rationale, including the product surface and team size context in which the choice was made and the recalibration trigger for when the isolation model should be reconsidered (typically: customer count above which schema migration overhead becomes manageable relative to the security boundary strength of schema-per-tenant, or contractual isolation requirements from enterprise customers requiring dedicated infrastructure). Specify the authoritative source of tenant identity: the verified session token claim is the single authoritative source of tenant_id in all database queries; URL path parameters, query string values, request body fields, and environment variables are explicitly not authoritative sources of tenant identity. Specify the authorization check requirement: any endpoint that accepts a tenant identifier as a parameter — even for legitimate features like an admin tool that allows impersonating tenants for debugging — must include an explicit authorization check verifying the authenticated session's entitlement to access that tenant's data, and this check must be implemented separately from the authentication middleware that verifies the session is valid. Document the security test requirement: the application's security test suite must include a cross-tenant access test for every endpoint that accepts a tenant identifier as a parameter, verifying that a session authenticated as tenant A cannot retrieve tenant B's data through that endpoint — the test must assert on the data content of the response, not only on the HTTP status code. Connect the identity derivation specification to the API security decision record's enforcement boundary principle: the authorization check for tenant identity is an application-layer enforcement that must be present in every handler, independently of the authentication middleware, for the same reason that application-layer authentication is a required defense-in-depth layer beneath the API gateway's JWT validation.

Section 2: Non-request context isolation requirement. Specify the isolation behavior required in non-request execution contexts — background jobs, cron tasks, data pipeline processors, administrative scripts, database migration utilities — and the enforcement mechanism that prevents silent cross-tenant access when no tenant context is initialized. The fail-on-missing-context requirement: when a tenant-scoped query is attempted without any tenant context initialized, the tenant context provider must raise an error and abort the query rather than returning all tenant records. This is the default-safe behavior that makes the omission of tenant context initialization a loud failure rather than a silent cross-tenant data access. The explicit context initialization requirement: all non-request code paths must initialize the tenant context before executing any database query, using one of two modes — single-tenant mode, initialized with a verified tenant identifier from the job input payload, for jobs that process a specific tenant's data; or explicit cross-tenant mode, initialized with a documented super-tenant scope identifier, for jobs that legitimately need cross-tenant access. The distinction between the two modes must be visible in the job's initialization code and in the application log for each job execution. The integration test requirement: every background job, scheduled task, and data pipeline processor must have an integration test that runs against a multi-tenant fixture with at least two tenants' data populated, verifying that the job reads and writes only the intended tenant's records. Document the onboarding requirement: the backend engineering onboarding materials must include a section explaining the tenant context behavior in non-request contexts, including the fail-on-missing-context behavior and the explicit initialization pattern for background jobs, before a new engineer's first background job code review. The incident response playbook should include a cross-tenant data disclosure response procedure that covers the non-request context failure mode specifically, because the exposure pattern — incorrect aggregate numbers rather than explicit record leakage — may be discovered through customer reports of wrong data rather than through security monitoring, and the investigation and notification requirements differ from explicit record access violations.

Section 3: Database-layer enforcement specification. Specify whether database-layer enforcement is required as a defense-in-depth mechanism beneath the application-layer isolation, and if so, the configuration requirements that make the enforcement active. For PostgreSQL Row Level Security: document the service account requirement — the application connection pool must use a database account that is not a superuser (rolsuper = false) and does not have the BYPASSRLS attribute (rolbypassrls = false); verify this by running SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'application_service_account' and confirming both values are false; this verification must be performed in the production environment against the account actually used by the connection pool, not against the development account used to verify policy logic. Document the policy coverage requirement: RLS must be enabled and at least one USING policy must exist for every table that contains tenant-scoped data; verify with SELECT tablename, rowsecurity, (SELECT count(*) FROM pg_policies WHERE tablename = t.tablename) AS policy_count FROM pg_tables t WHERE schemaname = 'public' — every table with rowsecurity = true must have policy_count > 0. Document the connection pool context management protocol: the tenant context must be set via SET LOCAL app.tenant_id = ? immediately after acquiring a connection from the pool (connection checkout), before executing any other statement; the verify-before-execute check — asserting that SELECT current_setting('app.tenant_id', true)::uuid = expected_tenant_id immediately before executing a tenant-scoped query — is required for any connection pool implementation that does not guarantee transaction-boundary cleanup in all error paths. Specify the service account privilege review cadence: the service account's privilege level must be verified quarterly against the production database using the same SELECT query above; a database provisioning or migration script that creates or modifies the service account must include a post-execution verification step that confirms the privilege level. The test data management decision record connects here: integration tests verifying tenant isolation must run against a multi-tenant fixture that includes at least two tenants' data, and the test must execute using the same service account (or an account with the same privilege level) that the application uses in production — a test using a superuser test account that bypasses RLS will not detect whether RLS is active for the production service account.

Section 4: Exposure detection and audit logging. Specify the monitoring and logging configuration that enables cross-tenant data access detection and post-hoc exposure window reconstruction. Application log requirement: every database query must produce a structured log event that includes both the authenticated session's tenant_id and the tenant_id used to scope the query in the same log event, not in separate log streams that require correlation. When the two values differ — which should not occur under normal operation — the log event must produce a high-severity alert and the query must be aborted before returning results. The detection coverage this provides: any cross-tenant access event that occurs with both values populated in the application's session context produces an immediate alert; the failure mode it does not cover is cross-tenant access that occurs when no tenant context is set, which is why the fail-on-missing-context behavior in Section 2 is the primary enforcement mechanism for that scenario. Database audit log requirement: if database-layer enforcement via RLS is configured, enable PostgreSQL pgaudit for the tenant data tables and configure the audit log to capture both the statement text and the current value of app.tenant_id for each logged statement; the audit log provides the post-hoc reconstruction capability for exposure windows that are discovered after the application log retention period has expired. Specify the monitoring alert for cross-tenant access: a structured query log event where authenticated_tenant_id ≠ query_tenant_id must produce an immediate PagerDuty alert classified at least P2, investigated within thirty minutes. Specify the post-hoc reconstruction procedure: when a cross-tenant data exposure is discovered, the investigation must identify the exposure window start time, the exposure window end time, the set of affected query_tenant_id values (whose data was accessible to queries scoped to the wrong tenant), and the set of authenticated_tenant_id values (which tenants' sessions were executing the misscoped queries). These four values determine the regulatory notification requirement under GDPR Article 33 — whether the exposed data constitutes personal data, the seventy-two-hour notification clock start time, and the set of affected data subjects requiring notification. Connect the audit log retention period to the database backup verification decision record's retention policy: the audit log must be retained for the maximum of the operational recovery window and the longest regulatory retention requirement applicable to the company's customer base.

Section 5: Isolation model review cadence and enterprise customer requirements. Specify the mechanism for reviewing the isolation model as the product surface, team size, and customer base grow, and the process for meeting enterprise customers' isolation requirements that may exceed the founding isolation model's security boundary. Isolation model review cadence: annually, review the isolation model against the current product surface, team size, and customer base to assess whether the founding model still meets the security boundary requirements of the customer base. The review should address: whether any customer contracts specify isolation requirements (dedicated schema, dedicated database, dedicated infrastructure) that the current model does not satisfy; whether the team has grown large enough that shared-knowledge enforcement of the tenant identity derivation model has become unreliable (typically beyond fifteen engineers contributing to tenant-scoped API endpoints); whether the security test suite cross-tenant access test coverage has been maintained as the endpoint surface has grown; and whether the fail-on-missing-context behavior has been maintained across all non-request execution contexts as the background processing surface has grown. Enterprise customer requirements: if a customer's contract specifies stronger isolation than the current model provides — schema-per-tenant or database-per-tenant — document the isolation upgrade path: the schema migration plan, the service account configuration for the upgraded isolation level, the connection pool routing changes required to direct each tenant's requests to the correct schema or database, and the verification procedure for confirming that the upgrade is active for that tenant's account. The upgrade path must be specified in the isolation ADR before the first enterprise customer contract is signed that specifies isolation requirements, not after — the implementation complexity of an isolation model migration is substantially higher when existing tenant data must be migrated than when the isolation model is established for a new tenant account at onboarding time. The API security decision record's annual security review should include a cross-tenant access audit as a standing agenda item: enumerate all endpoints added in the past year that accept tenant identifiers as parameters and verify that each one has an authorization check and a cross-tenant access test in the security test suite.

FAQ

What should a multi-tenant data isolation decision record specify beyond the isolation model choice?

Four things. First, the authoritative source of tenant identity: which data source — authenticated session token, database-stored session record, signed JWT claim — is the single authoritative source of tenant_id in all database queries, and the explicit prohibition on deriving tenant identity from caller-supplied parameters. Second, the non-request context isolation requirement: how background jobs, scheduled tasks, administrative tools, and migration scripts initialize a tenant context before executing any database query, and what behavior is required when no tenant context is available — explicit failure, not silent operation without a scope. Third, the database-layer enforcement specification: whether Row Level Security or another database-layer mechanism is required as a defense-in-depth layer beneath application-layer filtering, the service account privilege level required for the enforcement to be active, and the connection pool context management protocol. Fourth, the exposure detection and audit mechanism: how cross-tenant data access is detected in real time and how the exposure window and affected tenants can be reconstructed post-hoc for regulatory assessment.

How do you prevent tenant isolation from being bypassed by background jobs and batch processors?

Three mechanisms. First, fail-on-missing-context: the tenant context provider must raise an error when a tenant-scoped query is attempted without any tenant context initialized, rather than silently returning all tenant records. This makes the omission of tenant context initialization a loud failure that blocks the job rather than a silent cross-tenant data access. Second, explicit initialization requirement: all non-request code paths must initialize the tenant context before executing any database query, using either single-tenant mode (verified tenant identifier from the job's input payload) for single-tenant jobs, or an explicit cross-tenant scope identifier for jobs that legitimately need cross-tenant access. The distinction between the two modes must be visible in the code and in the application log. Third, integration test coverage: every background job must have an integration test that runs against a multi-tenant fixture with at least two tenants' data populated, verifying that the job reads and writes only the intended tenant's records. A test that runs against a single-tenant fixture cannot verify tenant isolation because there is no second tenant's data to accidentally access.

How do you verify that Row Level Security is actually enforcing isolation rather than being bypassed?

Four checks. First, service account privilege: verify the database service account used by the application connection pool is not a superuser and does not have BYPASSRLS — run SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'your_service_account' in the production database and confirm both columns are false. This check must be run against the production environment, not the development environment, using the account that the connection pool actually uses. Second, policy coverage: verify RLS is enabled and at least one USING policy exists for every table containing tenant-scoped data. Third, connection pool context verification: add an assertion before executing tenant-scoped queries that the current connection's tenant context — SELECT current_setting('app.tenant_id', true) — matches the expected tenant_id, and raise an application error if it does not. This catches context leakage before it produces cross-tenant query results. Fourth, penetration test coverage: include a cross-tenant access test that authenticates as tenant A, supplies tenant B's identifier in every endpoint that accepts one, and asserts that tenant B's records are not returned — the assertion must be on data content, not only on HTTP status code.

How do you reconstruct the exposure window and affected tenants after a cross-tenant data exposure is discovered?

Three sources. First, application logs: structured request logs that include both the authenticated session's tenant_id and the query's tenant_id in the same log event allow post-hoc comparison — any log event where the two values differ is a cross-tenant access event. This comparison is only possible if both values were logged in the same event; logging them in separate streams requires correlation by request ID, which is feasible but slower. Second, database audit logs: pgaudit combined with SET LOCAL tracking captures the full sequence of tenant context changes per session and allows reconstruction of which tenant contexts were active during which queries. Third, backup reconstruction: for exposures discovered months after the fact, PITR availability determines whether the data state at the time of exposure can be recovered for regulatory assessment. If the exposure window predates the backup retention window, only the audit log can establish which records were accessible; the actual data content cannot be recovered. The regulatory notification requirement under GDPR Article 33 turns on whether the exposure constitutes a breach of personal data — which requires knowing both which tenants' data was accessible and whether that data contained personal data — making the audit log and backup reconstruction capabilities directly relevant to the notification assessment.