# Pathrule Pattern: Multi-Tenant SaaS (1.0.0)
# ::pathrule:package:multi-tenancy

### [RULE] Enforce tenant scoping below the application code  (path: /src/db)
<!-- scope: folder | priority: high | strict -->

Application-level filtering fails the same way every time: one new query, one new report, one raw SQL fix, and the filter is missing. The enforcement has to sit under the code that forgets.

- Put the guarantee in the database where you can: row-level security policies keyed on a session variable (`SET LOCAL app.tenant_id`) mean a query with no predicate returns nothing rather than everything. Make sure the application role is not the table owner and cannot bypass RLS.
- Where RLS is not available, funnel all access through a repository or query builder that requires a tenant id as an argument and appends the predicate itself. No module may construct a query object directly.
- Every tenant-owned table carries the tenant id as a real column with an index and a foreign key, and composite uniqueness includes it (`unique (tenant_id, slug)`, never `unique (slug)`).
- Ban the escape hatches in application paths: no raw connection, no admin client, no `SET ROLE`, no query builder with the scope disabled. If an internal tool genuinely needs cross-tenant reads, it gets its own service, its own credentials, and an audit log.
- Write the leak test, not just the happy-path test: seed two tenants, run every endpoint as tenant A asking for tenant B's ids, and assert not-found. This is the only test that catches the next missing filter.

---

### [RULE] Derive the tenant from the session, never from client input  (path: /src/api)
<!-- scope: folder | priority: high | strict -->

A tenant id in a request is attacker-controlled. Treating it as authoritative is the fastest path to a horizontal privilege escalation.

- Resolve the tenant from the authenticated session or token, then verify that this principal is a member of that tenant with the role the action requires. Membership is a lookup, not a claim you trust because it is signed by you.
- If the URL or subdomain names a tenant, use it only to select which of the caller's memberships is active, and reject the request when the caller is not a member. Never let it introduce a tenant the session does not have.
- Put the resolved tenant id in a request-scoped context that the data layer reads, and derive it exactly once per request. Passing it around as a parameter that any layer can override defeats the point.
- For machine-to-machine access, scope the API key or token to one tenant at issue time so the credential itself cannot address another.
- Impersonation and support access are separate, audited flows with their own permission, an explicit expiry, and a log entry naming the operator and the tenant.

---

### [RULE] Carry tenant context into every asynchronous path  (path: /src/jobs)
<!-- scope: folder | priority: high | strict -->

Background work is where tenant scoping quietly disappears: there is no request, so there is no context, so the query runs unscoped.

- Every job payload includes the tenant id, and the worker establishes the same scope as a request (set the session variable, build the scoped repository) before touching data.
- A scheduled task that must run for all tenants iterates tenants explicitly and processes each in its own scoped unit of work. A single query across all tenants is a bug waiting to be a report emailed to the wrong customer.
- Inbound webhooks resolve the tenant from the integration record the signature belongs to, not from a payload field. Verify the signature first, then map to the tenant.
- Failures, retries, and dead letter records keep the tenant id so an operator can tell whose work failed without opening the payload.
- Long-running exports and imports run under the tenant scope end to end, including the storage path they write to and the notification they send.

See /src/db for the enforcement layer these jobs must go through.

---

### [RULE] Namespace every derived artifact by tenant  (path: /src/cache)
<!-- scope: folder | priority: high | strict -->

The database is usually the layer people protect. The leak happens in the cache, the search index, or a signed URL.

- Prefix every cache key with the tenant id (`t:<tenantId>:...`) and build keys through one helper so no caller can forget. A memoised list keyed only by page number will serve the wrong customer's data.
- Scope search: either an index per tenant or a mandatory tenant filter applied by the same wrapper that runs every query. A filter that the caller supplies is a filter that will be omitted.
- Store files under a tenant-prefixed path, and make signed URLs short-lived and specific to the object. Never generate a signature for a prefix a tenant does not own.
- Include the tenant id as a structured field on every log line, metric, and trace so operational data is filterable and an incident can be scoped to one customer.
- Rate limits, quotas, and idempotency keys are per tenant too; a global key lets one tenant lock out another.

See /src/db for the data-layer rule and the redis-caching pattern for cache invalidation design.

---

### [MEMORY] Choosing the isolation model, and living with it  (path: /src/db)

This decision shapes migrations, backups, onboarding, and cost for the life of the product. Make it explicitly, and expect to support a hybrid eventually.

- Shared tables with a tenant id column is the default for volume: one migration, one connection pool, cheap onboarding. Isolation depends entirely on enforcement (RLS or a mandatory predicate), and noisy neighbours are a real operational concern.
- Schema per tenant gives clearer separation and per-tenant restore, at the cost of running every migration N times and holding many more relations. It stops scaling somewhere in the hundreds to low thousands of tenants, depending on the engine.
- Database (or cluster) per tenant is for regulated or very large customers: strongest isolation, per-tenant residency and restore, and by far the highest operational cost. Reserve it for the accounts that pay for it.
- A hybrid is normal: shared tables for the long tail, dedicated databases for enterprise accounts. Keep the application code identical by resolving the connection and scope from the tenant record.
- Whatever you choose, plan two lifecycle operations from day one: exporting all data for one tenant, and deleting it. Both are contractual obligations and both are painful to retrofit.

See /src/api for tenant resolution and the supabase-rls pattern for row-level security specifics on Postgres.

---

### [MEMORY] Fairness: keeping one tenant from consuming the platform  (path: /src/api)

In a shared deployment, capacity is a shared resource. Without per-tenant limits, your worst-behaved customer sets everyone's latency.

- Rate-limit and quota per tenant, not just globally, and return a clear error with a retry hint. Tie limits to the plan so the product and the platform agree on what is allowed.
- Cap page sizes, export sizes, and query time server-side. An endpoint that lets a caller request 100000 rows is a denial of service you built.
- Separate the work pools that matter: interactive requests, background jobs, and bulk imports should not share one queue or one connection pool. One tenant's million-row import should not delay another tenant's login.
- Watch resource use per tenant (queries, storage, job time, tokens spent) both for capacity planning and because usage-based pricing needs it anyway.
- Give large tenants an escape valve: dedicated workers, a separate database, or a shard. Plan for how a tenant moves between shapes before a customer forces the question.

See /src/jobs for the job scoping rule and the observability pattern for per-tenant metrics.

---

### [SKILL] tenant-isolation-review  (path: /)

---
name: tenant-isolation-review
description: Review checklist for multi-tenant changes: data-layer scoping, tenant resolution, asynchronous context, and namespaced caches and files. Includes the cross-tenant leak test to run before merging.
---

# Tenant isolation review

## Data access
- [ ] Every new query goes through the scoped repository or is covered by an RLS policy.
- [ ] No raw connection, admin client, or scope-disabled builder was introduced.
- [ ] New tenant-owned tables have a tenant id column, an index, and tenant-inclusive unique constraints.
- [ ] Joins cannot cross tenants (every joined table is scoped too).

## Request path
- [ ] The tenant comes from the session, with a membership and role check.
- [ ] A tenant id in the URL, header, or body only selects among the caller's memberships.
- [ ] The resolved tenant is set once, in request context, and read by the data layer.

## Asynchronous work
- [ ] Job payloads carry the tenant id and workers establish the scope before querying.
- [ ] Scheduled cross-tenant work iterates tenants explicitly.
- [ ] Webhooks resolve the tenant from the verified integration, not the payload.

## Derived artifacts
- [ ] Cache keys, search filters, storage paths, and signed URLs are tenant-prefixed.
- [ ] Logs, metrics, and traces carry the tenant id.
- [ ] Rate limits and idempotency keys are per tenant.

## The test that matters
1. Seed tenant A and tenant B with overlapping-looking data.
2. Authenticate as a member of A only.
3. Call every touched endpoint with B's ids (path, body, filters, cursors, exports).
4. Assert not-found or forbidden, never a partial result, and never a 500 that reveals existence.
5. Repeat for the job or webhook path if the change added one.
