# TENANCY.md — Multi-Tenant Architecture

**Status:** Active  
**Last updated:** Sprint S02, 2026-05-07  
**Owner:** Platform architecture (all domains)

> This document describes AMIR's multi-tenant isolation model — how tenant data is separated, what the rules are, and where the documented exemptions are. Read this before touching any model that has a `tenant_id` column or before adding middleware to tenant-scoped routes.

---

## Overview

AMIR is a multi-tenant SaaS. Every user belongs to one or more tenants (koperasi). Tenant isolation is enforced at the **Eloquent query layer** via a global scope, not at the application layer. This means isolation cannot be bypassed by forgetting a `where` clause — it's automatic.

**Architecture decisions:**
- **D28** — `tenant_id UUID NOT NULL` on every domain table; `BelongsToTenant` trait required
- **E1** — User↔Tenant is a many-to-many junction (`tenant_user_memberships`), not a direct FK on users
- **D29** — Every state-changing Action writes an audit log entry

---

## How it works

### `BelongsToTenant` trait
Located at `app/Concerns/BelongsToTenant.php`. Does two things:

1. **Boots `TenantScope`** as a global Eloquent scope. Every query on a model using this trait automatically adds `WHERE tenant_id = :currentTenantId`.
2. **Auto-fills `tenant_id` on `creating`** from `app('currentTenantId')`. Models never need to explicitly set `tenant_id`.

### `TenantScope` global scope
Located at `app/Scopes/TenantScope.php`. Reads `Multitenancy::currentTenantId()` from the service container. If no tenant is bound (e.g. platform operator context), the scope is a no-op — it does not add any WHERE clause.

### `Multitenancy` helper
Located at `app/Multitenancy.php`. Wraps the `app('currentTenantId')` container binding.

```php
Multitenancy::setCurrentTenantId($id);    // binds tenant context
Multitenancy::currentTenantId();          // reads current context (nullable)
Multitenancy::withTenant($id, fn() => ...) // scoped callback — always clears after
```

### `SetTenantContext` middleware
Located at `app/Http/Middleware/SetTenantContext.php`. Resolves tenant context from:
1. `X-Tenant-Id` request header (API clients)
2. `current_tenant_id` session key (web/Inertia clients)

Platform operators (`platform_operator` role) skip context binding — they have cross-tenant visibility.

**Important:** This middleware uses `$request->hasSession()` guard before reading the session. API routes (registered via `api:` in `bootstrap/app.php`) have no session. Never call `$request->session()` without this guard.

### `RequireTenantContext` middleware
Located at `app/Http/Middleware/RequireTenantContext.php`. Applied to tenant-scoped API routes that **require** a valid tenant context. Returns 403 + audit log entry if no tenant context is bound after `SetTenantContext` runs. Only applied to routes where a missing tenant_id is a security error, not a legitimate state.

---

## Tenant-scoped models (use `BelongsToTenant`)

All models in these domains are tenant-scoped:

| Domain | Models |
|--------|--------|
| Accounting | `AccountingPeriod`, `CoaAccount`, `JournalEntry` |
| Auth | `TenantInvitation` |
| Banking | `BankAccount`, `BankReconMatch`, `BankStatement`, `BankStatementLine` |
| FixedAssets | `FixedAsset` |
| Inventory | `InventoryItem`, `InventoryMovement` |
| Parties | `Party` |
| Transactions | `Bill`, `BillLine`, `Invoice`, `InvoiceLine`, `PaymentAllocation`, `Transaction` |

**Rule:** Every new domain table with `tenant_id` MUST add `use BelongsToTenant`. The `post-edit-tenant-scope-check.sh` hook warns at write time if a new model is missing it.

---

## Documented exemptions (models with `tenant_id` but NOT using `BelongsToTenant`)

These models intentionally do not use `BelongsToTenant`. Each exemption is justified below.

### `App\Domain\Accounting\Models\ChartOfAccount`
**Table:** `chart_of_accounts`  
**Reason:** Mixed-scope model. Framework accounts (GP23 standard chart of accounts) have `tenant_id = NULL` and are shared across all tenants. Tenant-specific customisations have a non-null `tenant_id`. Applying TenantScope would hide framework accounts from all queries — breaking CoA lookup logic.  
**How it's accessed safely:** Queries that need tenant-specific accounts use `->where('tenant_id', $tenantId)` explicitly. Queries that need framework accounts use `->whereNull('tenant_id')`. The model includes a comment: `// Framework (GP23) accounts have tenant_id = null; not tenant-scoped`.

### `App\Domain\Tenant\Models\TenantSettings`
**Table:** `tenant_settings`  
**Reason:** Platform-scoped. TenantSettings is a 1:1 child of Tenant. Platform operators and the Onboarding flow access settings by tenant FK (`tenant_id`), not by the current context. Applying TenantScope would prevent platform operators from reading or writing any tenant's settings.  
**How it's accessed safely:** Always accessed via `Tenant::find($id)->settings` or `TenantSettings::where('tenant_id', $id)->first()` — never via an unscoped `TenantSettings::all()`.

### `App\Domain\Tenant\Models\TenantUserMembership`
**Table:** `tenant_user_memberships`  
**Reason:** The membership pivot IS the tenant relationship. This table is queried to determine which tenants a user belongs to — it must be readable across tenant contexts during authentication. Applying TenantScope would create a circular dependency: you need the current tenant to read memberships, but you need to read memberships to establish the current tenant.  
**How it's accessed safely:** Always scoped by explicit `user_id` or `tenant_id` FK. Platform-operator context only.

---

## Bypassing TenantScope

There are legitimate cases where code must bypass TenantScope (e.g. token-based lookups, platform-operator admin actions). The rule is:

```php
// Correct — explicit scope removal with justification comment
Model::withoutGlobalScope(TenantScope::class)
    ->where('token_hash', hash('sha256', $plain))
    ->firstOrFail();
// reason: invitation token lookup must work regardless of current tenant context
```

**Never** bypass TenantScope without:
1. The explicit `withoutGlobalScope(TenantScope::class)` call
2. A comment explaining why

Any unscoped query without this pattern is a bug. See `AcceptInvitation` action for the canonical example.

---

## Security invariants

1. **No query leaks**: Any query on a `BelongsToTenant` model without an active tenant context returns an empty result set (TenantScope adds `WHERE tenant_id = NULL` which matches nothing). It does not throw.
2. **Platform operators see all**: When `platform_operator` role is detected, `SetTenantContext` skips binding. TenantScope sees `currentTenantId() === null` and adds no WHERE clause.
3. **Cross-tenant access attempts are audit-logged**: The `RequireTenantContext` middleware logs a `severity=critical` audit entry when a request arrives with a tenant context that doesn't match the authenticated user's memberships.
4. **Token-based lookups always use `withoutGlobalScope`**: Invitation tokens, password reset tokens, and similar are always looked up with explicit scope removal (the token is the proof of identity, not the tenant context).

---

## Testing tenant isolation

Tests that verify isolation are in `tests/Feature/Domain/Tenancy/`:

- `TenantIsolationTest.php` — cross-tenant data invisibility (financial models)
- `TenantScopeAuditTest.php` — all BelongsToTenant models verified
- `TenantAttackScenariosTest.php` — URL tampering, ID enumeration, polymorphic confusion

The `TenantScopeTest.php` in `tests/Feature/Infrastructure/` tests the `Multitenancy` helper and `SetTenantContext` middleware in isolation.

---

## Adding a new tenant-scoped model

Checklist:
- [ ] Add `tenant_id UUID NOT NULL` to migration with FK → tenants.id ON DELETE RESTRICT
- [ ] Add `use BelongsToTenant` to the model
- [ ] Do NOT set `tenant_id` in the model's `$fillable` — `BelongsToTenant` fills it automatically on create
- [ ] Add a test asserting the model is invisible from a different tenant context
- [ ] Update `docs/TENANCY.md` model table above

## Adding a new exempted model

If a new model intentionally skips `BelongsToTenant`:
- [ ] Add a `// platform-scoped: [reason]` comment to the model
- [ ] Add a justification entry to the Documented exemptions section above
- [ ] Add a test asserting the exemption is intentional (not a forgotten `use BelongsToTenant`)
- [ ] Raise it in the PR description for founder review
