Multi-Tenancy Model
Vetty is a row-based multi-tenant SaaS. Every tenant-scoped table carries a non-nullable tenant_id foreign key; the only resources that legitimately exist outside a tenant boundary are platform-level (system admin, password reset tokens, platform-default reminder templates) or owner-level (caregivers, public pet shares).
Tenant model
A tenants row represents one operating customer — a clinic, pharmacy, retail shop, breeder, groomer, or trainer. The business_type column carries the role.
| Cluster of fields | Purpose |
|---|---|
| Identity | id, name, slug (URL-safe), email, phone, address |
| Lifecycle | subscription_plan, status |
| Locale | currency, timezone |
| Marketplace | is_public, latitude, longitude, service_radius_km, headline, bio, services (JSON array), business_type |
| Plan gating | premium_features (JSON map of unlocked feature slugs) |
The current set of SUPPORTED_BUSINESS_TYPES and PREMIUM_FEATURES is exposed as constants on App\Models\Tenant so FormRequest validators and the Filament admin agree on the enum.
Membership
tenant_user (pivot) carries the membership graph. A user can belong to multiple tenants:
role— short staff role string (clinic_admin,veterinarian,staff,pharmacy,retail_counter).status—active,invited,disabled.is_default— defaults the user's "current tenant" if they don't pick one.
users.current_tenant_id is the working tenant for staff users; the tenant.resolve middleware uses the URL {tenant} placeholder rather than this column for clinic endpoints (audit recommends the middleware also re-checks membership against the URL value — currently skipped).
Two parallel RBAC systems (and the audit note)
The codebase carries two role-related representations:
- Pivot role string —
tenant_user.role. Coarse-grained, used by some early controllers as the gate. role_user+role_permission+permissions— the proper RBAC model added in tasks #30–41. Permissions are keyed strings (e.g.appointment.create) and live inApp\Auth\PermissionRegistry.
The two are still both referenced in different places. The audit (AUDIT.md §1) recommends consolidating on the second and migrating away from the pivot string.
Permission catalog
App\Auth\PermissionRegistry::all() returns the canonical key set. Examples by cluster:
appointment.*— read / create / update / cancelinvoice.*— read / create / update / convertpayment.*— create / refundmedical_record.*/prescription.*/lab_result.*/attachment.*tenant.settings.read/tenant.settings.updaterole.*— read / manage / assignaudit.readreminder.*— read / sendmedicine.*/stock_movement.*/supplier.*/purchase_order.*order.*— read / manage (seller side)patient.read
Default role bundles per business_type are seeded by RoleSeeder and inheritable into platform-default rows (tenant_id IS NULL).
Enforcement layers
Defense in depth, one layer per request stage:
| Layer | Where | What it does |
|---|---|---|
| Auth guard | auth:sanctum | Bearer token validates a user. |
| Tenant resolution | tenant.resolve middleware | Resolves URL {tenant} to a model + (should) verify membership. |
| Permission middleware | require.permission:<key> | Fast 4xx when the user lacks the key for the active tenant. |
| Premium gate | require.premium:<feature> | 402-style block when the tenant's plan doesn't include the feature. |
| Form-request validator | per-controller request class | Rule::exists(...) is always restricted to the active tenant for nested IDs. |
| Controller authorization | enforcePermission, policies | Row-level decisions the URL alone can't model (e.g. only the prescribing vet can update an Rx). |
| Audit log | Auditable trait + RecordAuditContext middleware | Every change leaves a row in audit_logs. |
Cross-tenant safe surfaces
A few surfaces deliberately operate without tenant scoping:
| Surface | Why |
|---|---|
/auth/* | The user has no current tenant yet at login time. |
/providers/* (marketplace) | Public; only is_public=true tenants leak through. |
/p/{slug} (lost-pet) | Public; only public_profile_enabled=true pets. |
/shares/redeem | Cross-tenant by definition (clinic claims a pet from any owner). |
/caregivers/accept | The invitee may not yet belong to any tenant. |
/owner/me/* | Scoped by the authenticated owner's id, not by tenant. |
Audit + roadmap (selected)
- P1 —
ResolveTenantMembershipmiddleware should re-check membership against the URL tenant. - P1 — Several controllers accept
*_idfrom request body and only validateRule::existswithout scoping to the URL tenant. Audit recommends a smallAssertsTenantScopetrait applied uniformly. - P2 — Two RBAC systems should be consolidated.
- P2 —
Auditablesnapshot allow-list should default-deny withpassword,remember_token, secrets in a system-wide deny-list.
See Security architecture for the security cluster and AUDIT.md §1 for the full list.