# Multi-Company — Verified Tenant Registry & Build Checklist

> Status: **design artifact, pre-implementation.** Built 2026-06-14 from ground truth
> (migration `constrained()` targets + `userCompany->id` vs `user_id ?? id` call-site scoping),
> not from summaries. This file is the **security-critical foundation**: the chokepoint scopes
> every business query off this table. A wrong row here is a cross-tenant data leak. Verify each
> row against its migration before flipping that model.

## 0. Decisions

- **§5 data-ownership — OUTCOME, not literal (accepted 2026-06-14).** Each record belongs to
  exactly one company (guaranteed by the UNIQUE `companies.user_id` bijection); we do NOT
  physically re-point legacy OWNER-keyspace tables to `companies.id`. Owner-user-id is the
  agreed proxy for company identity. Revisit only if a record must move between companies in a
  group while keeping the same owner-user (nothing requires that today).
- **`company` scope = READ visibility only.** Writes stamp the home company until the P6
  write-path / cross-company permission decisions land.
- **`Vehicle` is NOT global-scoped (audited 2026-06-14).** Unlike low-surface models, Vehicle
  has ~40 unguarded `Vehicle::find($vehicleId)` by-id lookups in fleet controllers; a global
  `TenantScope` would make `find()` return null for non-accessible ids → 500s / behavior change
  on cross-tenant input. So Vehicle gets a **targeted** flip instead: scope its LIST endpoints
  via the resolver's `accessibleOwnerIds` (`whereIn('vehicles.user_id', …)`); leave `find()`-by-id
  paths alone and harden them in a separate security sweep (the unguarded ones are pre-existing
  cross-tenant leaks). The dominant `$user->vehicles()` relationship reads are already user_id-scoped.

## 1. The two keyspaces

Every tenant-scoped table keys on exactly one of two id spaces. The resolver emits **both** lists;
each model uses the one matching its keyspace.

| Keyspace | Meaning | Filter | Resolver list |
|---|---|---|---|
| **OWNER** | column is a FK to `users` (the fleet-owner user id) | `whereIn(tenantCol, accessibleOwnerIds)` | `accessibleOwnerIds` = map(accessibleCompanyIds → `companies.user_id`) |
| **COMPANY** | column is a FK to real `companies.id` | `whereIn('company_id', accessibleCompanyIds)` | `accessibleCompanyIds` = auth `company` scope ∪ home company (+ 1-level children) |

**Classification rule (authoritative):**
- `foreignId('company_id')->constrained('users')`  → **OWNER**
- `foreignId('company_id')->constrained()` (bare → infers `companies`) or `constrained('companies')` → **COMPANY**
- `foreignId('user_id')->constrained('users')` used as the tenant → **OWNER**
- `unsignedBigInteger('company_id')` with no FK → classified by controller scoping (`userCompany->id` ⇒ COMPANY); **flagged to verify**.

Rough split: **OWNER ≈ older core fleet (2017–2024); COMPANY ≈ newer modules (late-2024 → 2026).**

## 2. ⚠ Corrections to the earlier census (these were misclassified — they are leaks if trusted)

| Table | Earlier said | TRUTH | Evidence |
|---|---|---|---|
| `tickets` | OWNER (users) | **COMPANY** | `2026_04_06…tickets`: `company_id` bare `constrained()` → companies; `TicketService:20,45,79` uses `userCompany->id` |
| `generic_reminders` | OWNER (users) | **COMPANY** | `2025_12_15_083632`: `company_id` bare `constrained()` → companies |
| `charging_locations` | COMPANY | **OWNER** | create `2025_03_08` was `constrained('companies')` but **re-pointed** to `constrained('users')` by `2025_03_11_134112_edit_charging_locations_table` |
| `uploaded_documents` | OWNER | **INCONSISTENT — verify** | FK is `constrained('users')` (`2024_09_07_112100`) but `UploadedDocumentController:1351` writes `userCompany->id` (a companies.id). Latent bug; resolve before scoping. |
| `vehicle_histories` | — | **MIXED — verify** | `2024_12_18_210252`: `user_id`→users AND `company_id`→companies (both bare). Pick the tenant col explicitly. |

## 3. Registry

Legend — **Strategy**: `column` (direct), `relation` (via parent, needs `whereHas`), `nullable-shared` (scoped when set, global when NULL). **Site**: has `HasAuthServiceSiteScope` today.

### 3a. OWNER keyspace — `whereIn(col, accessibleOwnerIds)`

| ☐ | Model / Table | Tenant col | Strategy | Site | Evidence (migration) |
|---|---|---|---|---|---|
| ☐ | Vehicle / `vehicles` | `user_id` (primary) | column | ✓ | `2017_06_03…`; `company_id`→users secondary (ignore for scope; 253 rows diverge) |
| ☐ | Site / `sites` | `user_id` | column | ✓ | `2023_02_15…` foreign→users |
| ☑ | Expense / `expenses` | `company_id`→users | column | ✓ | `2024_09_01_083201` — flipped 2026-06-15 (§5.2 batch 1) |
| ☑ | FuelHistory / `fuel_histories` | `company_id`→users | column | ✓ | `2024_09_02_190556` — flipped 2026-06-15 (§5.2 batch 1) |
| ☑ | ChargingHistory / `charging_histories` | `company_id`→users | column | ✓ | `2024_09_01_081735` — flipped 2026-06-15 (§5.2 batch 1) |
| ☑ | InhouseService / `inhouse_services` | `company_id`→users | column | ✓ | `2024_09_02_191401` — flipped 2026-06-15 (§5.2 batch 3) |
| ☐ | PartnerService / `partner_services` | `company_id`→users | column | ✓ | `2024_09_02_191855` |
| ☑ | Sinistre / `sinistres` | `company_id`→users | column | ✓ | `2024_09_02_192252` — flipped 2026-06-15 (§5.2 batch 2) |
| ☑ | Inspection / `inspections` | `company_id`→users | column | ✓ | `2024_09_03_155335` — flipped 2026-06-15 (§5.2 batch 2) |
| ☐ | InspectionForm / `inspection_forms` | `company_id`→users | column | — | `2024_08_24_200527` |
| ☑ | Issue / `issues` | `company_id`→users | column | ✓ | `2024_08_27_210022` — flipped 2026-06-15 (§5.2 batch 2) |
| ☑ | Infraction / `infractions` | `company_id`→users | column | ✓ | `2024_08_31_181314` — flipped 2026-06-15 (§5.2 deferred #1; writes got home-owner guard) |
| ☑ | MeterHistory / `meter_histories` | `company_id`→users | column | ✓ | `2024_08_29_082957` — flipped 2026-06-15 (§5.2 batch 1) |
| ☑ | VehicleReminder / `vehicle_reminders` | `company_id`→users | column | ✓ | `2024_09_04_175615` — flipped 2026-06-15 (§5.2 batch 3) |
| ☑ | ServiceReminder / `service_reminders` | `company_id`→users | column | ✓ | `2024_09_04_181144` — flipped 2026-06-15 (§5.2 batch 3) |
| ☐ | VehicleAssignment / `vehicle_assignments` | `company_id`→users | column | ✓ | `2024_09_04_214227` |
| ☑ | MobilityExpense / `mobility_expenses` | `company_id`→users | column | ✓ | `2024_09_11_101054` — flipped 2026-06-15 (§5.2 batch 1) |
| ☐ | CarTrip / `car_trips` | `company_id`→users | column | — | `2024_09_07_172228` (+ `CompanyPrivateModeScope`) |
| ☑ | Alert / `alerts` | `company_id`→users | column | — | `2024_09_25_184704` — flipped 2026-06-15 (§5.2 batch 4) |
| ☑ | Device / `devices` | `company_id`→users | column | — | `2024_11_24_074221` — flipped 2026-06-15 (§5.2 batch 4) |
| ☑ | DeviceAlert / `device_alerts` | `company_id`→users | column | — | `2025_07_07_192948` — flipped 2026-06-15 (§5.2 deferred #1) |
| ☐ | `vehicle_site_changes` | `company_id`→users | column | — | `2024_09_11_130521` |
| ☐ | `vehicle_status_changes` | `company_id`→users | column | — | `2024_09_11_151635` |
| ☐ | ChargingLocation / `charging_locations` | `company_id`→users | column | — | re-pointed `2025_03_11_134112` |
| ☑ | Label / `labels` | `company_id`→users (⚠ renamed from `user_id` by `2024_08_27_210204`) | column | — | `2024_07_12_111541` — flipped 2026-06-15 (§5.2 batch 4) |
| ☐ | AenRule / `aen_rules` | `user_id`→users | column | — | `2026_06_05_000001` (no global scope today) |
| ☐ | `vehicle_security_actions` | `user_id`→users | column | — | `2026_03_06_000001` |
| ☐ | Vendor / `vendors` | `user_id` (int, **no FK**) | column | — | `2021_08_27_122127` (no global scope today) |
| ☐ | UploadedDocument / `uploaded_documents` | ⚠ FK users / code companies | **resolve first** | ✓ | see §2 |
| ☐ | VehicleHistory / `vehicle_histories` | ⚠ user_id + company_id | **resolve first** | — | see §2 |

### 3b. COMPANY keyspace — `whereIn('company_id', accessibleCompanyIds)`

| ☐ | Model / Table | Strategy | Site | Evidence |
|---|---|---|---|---|
| ☐ | MaintenancePlan / `maintenance_plans` | column | ✓ | `2026_02_03_210801` companies; `MaintenancePlanService` `userCompany->id` |
| ☐ | MaintenancePlanCategory / `maintenance_plan_categories` | column | ✓ | `2026_02_03_210800` companies |
| ☐ | TollBadge / `toll_badges` | column | — | `2025_12_15_115800` companies; `TollBadgeController` `userCompany->id` |
| ☐ | `toll_badge_services` | column | — | `2025_12_15_115755` companies |
| ☐ | FuelCard / `fuel_cards` | column | ✓ | `2025_10_20_173634` bare→companies |
| ☐ | `fuel_card_products` | column | — | `2025_10_20_173552` bare→companies |
| ☐ | VehicleOrder / `vehicle_orders` | column | — | `2025_03_21` bare→companies; `VehicleOrderController` `userCompany->id` |
| ☐ | UploadedContract / `uploaded_contracts` | column | ✓ | `2025_08_31` companies; `ContractController` `userCompany->id` |
| ☐ | Campaign / `campaigns` | column | — | `2025_06_27_093514` companies; `CampaignController` `userCompany->id` |
| ☐ | CampaignType / `campaign_types` | nullable-shared | — | `2025_06_27_124424` companies (nullable) |
| ☐ | Fps / `fps` | column | ✓ | `2026_02_10` companies; `FpsService` `userCompany->id` |
| ☐ | **Ticket / `tickets`** | column | ✓ | `2026_04_06` bare→companies; `TicketService` `userCompany->id` |
| ☐ | **GenericReminder / `generic_reminders`** | column | ✓ | `2025_12_15_083632` bare→companies |
| ☐ | TelematicsBoxOrder / `telematics_box_orders` | column | — | `2026_05_15` bare→companies; service `userCompany->id` |
| ☐ | PrivateModeRule / `private_mode_rules` | column | — | `2026_05_16` bare→companies; `forCompany(userCompany->id)` |
| ☐ | Geofence / `geofences` | column | — | `2025_05_15` bare→companies |
| ☐ | Conveying / `conveying` | column | ✓ | `2025_04_19` bare→companies |
| ☐ | DirectMessage / `direct_messages` | column | — | `2025_07_25` companies |
| ☐ | SinistreMessage / `sinistre_messages` | column | — | `2025_08_03` companies; service `userCompany->id` |
| ☐ | PartnerServiceMessage / `partner_service_messages` | column | — | `2025_09_14` companies |
| ☐ | SinistreAssuranceStatus / `sinistre_assurance_statuses` | column | — | `2025_12_27` bare→companies; controller `userCompany->id` |
| ☐ | FleetElectrificationReport / `fleet_electrification_reports` | column | — | `2025_01_26` bare→companies; `StaticEV…` `userCompany->id` |
| ☐ | CustomStatus / `custom_statuses` | nullable-shared | — | `2024_12_01` bare→companies (NULL = system default) |
| ☐ | CustomMotive / `custom_motives` | nullable-shared | — | `2024_12_19` bare→companies (nullable) |
| ☐ | Notification / `notifications` (V2) | nullable-shared | ✓ | `2025_07_11` / `2026_04_04` bare→companies |
| ☐ | `company_sidebar_preferences` | column | — | `2024_11_02`; Sidebar/Settings `userCompany->id` |
| ☐ | ReportSchedule / `report_schedules` | column | — | `2026_06_04_000004` companies |
| ☐ | Report / `reports`, `report_generations` | nullable-shared | — | nullable companies + `2026_05_28 translate…to_real_company_id` |
| ☐ | `rfid_card_mappings` | nullable-shared | — | `2026_04_22` foreign→companies, nullable |
| ☐ | Notification settings (`company_notification_settings`, `notification_sla_configs`, `company_role_notification_settings`, `company_mandatory_notification_types`) | column | — | various → companies |
| ☐ | Billing/config (`invoices`, `subscriptions`, `company_credit_cards`, `company_industries`, `private_mode_schedules`, `time_zones`) | column | — | bare→companies (treat as company-owned settings) |

#### COMPANY keyspace — newer no-FK tables (`unsignedBigInteger company_id`, indexed, **no DB FK**) — classified by controller `userCompany->id`; **verify a sample**

| ☐ | Module / tables | Note |
|---|---|---|
| ☐ | Car Policy: `car_policies`, `car_policy_versions`, `car_policy_assignments`, `car_policy_vehicles`, `policy_options`, `option_packs` | `CommandeController` filters `CarPolicyAssignment::where('company_id', userCompany->id)`; options/packs nullable-shared |
| ☐ | Commande: `commandes`, `commande_history`, `vehicle_requests` | `CommandeController` `userCompany->id` |
| ☐ | AO/BC: `appels_offres`, `cotations`, `bons_de_commande`, `bc_history`, `restitution_disputes` | `2026_05_22` epic; AO/BC controllers scope by company |
| ☐ | Approvals: `approvers`, `approval_circuits`, `approval_digest_configs`, `approval_digest_logs` | `2026_05_25` |
| ☐ | Departments: `departments` | nullable-shared (`2026_05_19_200001`) |
| ☐ | `linkbycar_telematics_alerts` | telematics, nullable |

### 3c. INDIRECT — no own tenant col; scope via parent `whereHas` (reuse `AuthServiceSiteScope` relation pattern)

| ☐ | Table | Scope via |
|---|---|---|
| ☐ | `expense_vehicles` (pivot) | `expense.company_id`(OWNER) / `vehicle.user_id` |
| ☐ | `maintenance_plan_vehicles`, `maintenance_plan_tasks`, `maintenance_plan_activity_logs` | `maintenance_plans.company_id` (COMPANY) — note activity_logs `user_id` is actor, not tenant |
| ☐ | `inspection_answers`, `inspection_form_vehicles` | parent inspection/form |
| ☐ | `fps_drivers`, `fps_payments`, `fps_histories` | `fps.company_id` (COMPANY) |
| ☐ | `campaign_recipients/responses/email_logs/attachments` | `campaigns.company_id` (COMPANY) |
| ☐ | `sinistre_message_responses`, `partner_service_message_responses` | parent message |
| ☐ | `ticket_messages`, `ticket_history` | `tickets.company_id` (COMPANY) |

### 3d. REFERENCE / GLOBAL — must NOT be scoped (fail-open by design)

Vehicle reference (`vehicle_types`, makes, models, colors, `motorisations`, `crit_air`, `license_categories`, classification), expense reference (`expense_types`, categories, `mobility_expense_types`), RBAC (`roles`, `permissions`, `modules`, `features`), plans (`subscription_plans`, plan features), `languages`, `industries`, `company_sizes`, `fleet_sizes`. **Do not add the tenant trait to these.**

## 4. Chokepoint rules derived from this registry

1. **Per-model registry config** drives one `TenantScope`: `{strategy: column|relation|nullable-shared, column, keyspace: OWNER|COMPANY, relation?}`.
2. **`nullable-shared`** → `where(fn => whereIn(company_id, accessibleCompanyIds)->orWhereNull('company_id'))` (system defaults stay visible).
3. **Fail-closed default:** any model that opts into the trait but is missing a registry entry → `whereRaw('1=0')` + log. Reference tables simply never opt in.
4. **`users` model** keeps current special case: owners (`user_id IS NULL`) unfiltered; employees scoped (mirror `AuthServiceSiteScope`).
5. **MCP tokens & no-request (console/jobs):** single-owner / skip — mirror `AuthServiceSiteScope` lines 30/36.
6. **Resolver default = today's behavior:** `accessibleOwnerIds = [user.user_id ?? id]`, `accessibleCompanyIds = [home companies.id]` when no `company` scopes exist — zero backfill, behavior identical to today.

## 5. Build checklist (phased; each phase shippable)

- [ ] **P0 Prereqs** — (a) auth: seed `company` scope_type; (b) reconcile 3 SIRET-drift companies; (c) treat `dadycar-internal` as platform full-access; (d) resolve `uploaded_documents` + `vehicle_histories` keyspace (§2); (e) verify a sample of §3b no-FK tables.
- [ ] **P1 Resolver** — `AccessibleCompanyResolver`: home ∪ `company` scopes (+1-level children via `parent_company_id`) → `{accessibleCompanyIds, accessibleOwnerIds}`; MCP single-owner; reuse 60s scope cache. Unit-tested in isolation.
- [ ] **P2 Chokepoint (inert)** — `TenantScope` + `HasTenantScope` trait + registry config from §3. Ship with resolver returning `[home]` only ⇒ behavior == today. Add to one OWNER + one COMPANY model; prove no diff.
- [ ] **P3 COMPANY rollout** (cleanest first — column == companies.id, no mapping). Per model: opt into trait, remove its explicit `where('company_id', userCompany->id)`, add a 2-company isolation test.
- [ ] **P4 OWNER rollout** — per model: opt in, remove explicit `where(user_id/company_id, owner)`, isolation test.
- [ ] **P5 INDIRECT** — relation-based scoping for §3c.
- [ ] **P6 Write path** — `target_company_id` (or `X-Company-Id`) + access validation; default home. Update `*->user_id ?? id` create sites.
- [ ] **P7 Raw-query sweep** — audit ~57 `DB::table()` business queries; convert scalar tenant filters to the resolver lists.
- [ ] **P8 Hardening** — turn on multi-company for a pilot tenant; full isolation test matrix; `EXPLAIN` the big `whereIn` on `vehicles`/`expenses`.

## 6. Known data caveats (carry from audit)

- 253 `vehicles` rows: `company_id ≠ user_id` (both valid users). Scope on `user_id`; never `vehicles.company_id`.
- ~80 controllers / ~236 explicit tenant `where()` + ~57 raw queries must be swept (P3–P7). Keep single-record security checks (`where('id',$id)->where(owner)`).
- Auth↔fleet company id bijection ≈ clean: 103/104 by id, 77/80 by SIRET, 3 drift (P0b).
