Motivation / problem
AskMyDocs is multi-tenant: many customers’ knowledge lives in one deployment. A single query that forgets its tenant scope is not a bug — it is a GDPR-class data breach. And the obvious scope (project_key) is not safe: two different
customers can legitimately both own a project called engineering. The only safe
boundary is the tenant.
Theory & background
Tenant isolation must be structural, not disciplinary. Relying on every developer to remember awhere('tenant_id', …) clause guarantees a leak
eventually. The design therefore pushes isolation into three layers that are hard
to bypass: a request-scoped context, a model trait that auto-stamps on write and
provides an explicit forTenant() scope, and architecture tests that fail the
build when any controller or service reads a tenant-aware table without scoping.
Design
ResolveTenant(middleware, runs early) sets the active tenant on the request-scopedTenantContextsingleton — from theX-Tenant-Idheader, the authenticated user’stenant_id, or'default'(v3 backward-compat).AuthorizeTenantHeader(afterauth:sanctum) closes the escalation hole: a header that differs from the user’s own tenant is rejected403unless the user holds thetenant.cross-accesspermission (audited).BelongsToTenanttrait: acreatinghook auto-stampstenant_idon every insert; it also exposesscopeForTenant(string $tenantId)for explicit query scoping. There is no global read scope — every read query must call->forTenant($tenantId)(or an equivalent explicitwhere('tenant_id', …)) explicitly. TheTenantReadScopeTestarchitecture test fails the build when a controller or service omits this.- Project-scoped composite FKs on
kb_edges→kb_nodes((project_key, node_uid)) enforce referential integrity within a project; combined with thetenant_idfilter at query time, cross-tenant edges are prevented at both the application and DB layers.
Data model / contract
- Every tenant-aware table carries
string('tenant_id', 50)->default('default')->index(). Tables introduced with the multi-tenant refactor also start their composite uniques withtenant_id; legacy v3 tables received thetenant_idcolumn but their pre-existing composite uniques were not rebuilt (explicitly deferred in the2026_04_28_000001_add_tenant_id_to_v3_tables.phpretrofit migration). - Tenant-aware tables include:
knowledge_documents,knowledge_chunks,chat_logs,conversations,messages,kb_nodes,kb_edges,kb_canonical_audit,project_memberships, plus the admin/insights tables — the canonical list is enumerated in the architecture tests.embedding_cacheis intentionally cross-tenant (notenant_idcolumn) so that identical text from any tenant reuses the same cached embedding vector. - Rules R30 (scope every tenant-aware query) and R31 (
tenant_idmandatory on every tenant-aware model + migration) codify this.
Decision rationale (ADR-style)
- Why the tenant boundary, not
project_key? Project keys are customer-chosen and collide across tenants by design — sharingdec-cache-v2is legitimate. Onlytenant_idis a safe isolation scope. - Why explicit
forTenant()instead of a global scope? A global scope is invisible at the call site — it can be accidentally bypassed bywithoutGlobalScope()or by a rawDB::table()query. Explicit->forTenant($tenantId)makes the scoping visible and auditable, and theTenantReadScopeTestarchitecture test fails the build if a controller or service forgets it. Defense-in-depth: fail loudly at CI, not silently at runtime. - Why project-scoped composite FKs on
kb_edges? Referential integrity at the DB level catches bugs that application-layer discipline alone would miss.(project_key, node_uid)ensures an edge cannot reference a node that does not exist in the same project. - Why architecture tests?
TenantIdMandatoryTest+ the read-scope test enumerate the tenant-aware model list and fail the build when a new model forgets the trait — so isolation cannot silently regress.
Worked example
X-Tenant-Id: victim from a user whose own tenant is acme
is rejected 403 tenant_forbidden unless that user holds tenant.cross-access.
Gotchas & operations
- A new tenant-aware model must
use BelongsToTenant;and be added to both architecture-test completeness lists (TenantIdMandatoryTestFQCN list and the read-scope test short-name list) — run the fulltests/Architecturesuite, not one file. - Queue workers re-bind the tenant via a try/finally restore — background jobs are tenant-scoped too.
KB_PROJECT_ISOLATION_ENABLED(default-off) adds optional per-project isolation within a tenant; it does not replace the tenant boundary.
Architecture overview
The project-scoped composite FKs and canonical graph schema.
Core concepts
The full isolation and auth posture in context.