Skip to main content

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 fieldsPurpose
Identityid, name, slug (URL-safe), email, phone, address
Lifecyclesubscription_plan, status
Localecurrency, timezone
Marketplaceis_public, latitude, longitude, service_radius_km, headline, bio, services (JSON array), business_type
Plan gatingpremium_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).
  • statusactive, 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:

  1. Pivot role stringtenant_user.role. Coarse-grained, used by some early controllers as the gate.
  2. role_user + role_permission + permissions — the proper RBAC model added in tasks #30–41. Permissions are keyed strings (e.g. appointment.create) and live in App\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 / cancel
  • invoice.* — read / create / update / convert
  • payment.* — create / refund
  • medical_record.* / prescription.* / lab_result.* / attachment.*
  • tenant.settings.read / tenant.settings.update
  • role.* — read / manage / assign
  • audit.read
  • reminder.* — read / send
  • medicine.* / 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:

LayerWhereWhat it does
Auth guardauth:sanctumBearer token validates a user.
Tenant resolutiontenant.resolve middlewareResolves URL {tenant} to a model + (should) verify membership.
Permission middlewarerequire.permission:<key>Fast 4xx when the user lacks the key for the active tenant.
Premium gaterequire.premium:<feature>402-style block when the tenant's plan doesn't include the feature.
Form-request validatorper-controller request classRule::exists(...) is always restricted to the active tenant for nested IDs.
Controller authorizationenforcePermission, policiesRow-level decisions the URL alone can't model (e.g. only the prescribing vet can update an Rx).
Audit logAuditable trait + RecordAuditContext middlewareEvery change leaves a row in audit_logs.

Cross-tenant safe surfaces

A few surfaces deliberately operate without tenant scoping:

SurfaceWhy
/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/redeemCross-tenant by definition (clinic claims a pet from any owner).
/caregivers/acceptThe invitee may not yet belong to any tenant.
/owner/me/*Scoped by the authenticated owner's id, not by tenant.

Audit + roadmap (selected)

  • P1ResolveTenantMembership middleware should re-check membership against the URL tenant.
  • P1 — Several controllers accept *_id from request body and only validate Rule::exists without scoping to the URL tenant. Audit recommends a small AssertsTenantScope trait applied uniformly.
  • P2 — Two RBAC systems should be consolidated.
  • P2Auditable snapshot allow-list should default-deny with password, remember_token, secrets in a system-wide deny-list.

See Security architecture for the security cluster and AUDIT.md §1 for the full list.