Security Architecture
This page describes the trust boundaries and security controls in place today, what's known to be missing (per the audit), and the hardening roadmap.
The full audit lives in
AUDIT.md. This page summarises the architectural picture; the audit carries every individual finding with file:line citations.
Trust boundaries
┌─────────────────┐ HTTPS ┌─────────────────┐ AWS RDS ┌────────────┐
│ vetty / vetdoc. │─────────────▶│ Laravel API │──────────────▶│ MySQL │
│ admin web │ │ (Sanctum) │ │ │
└─────────────────┘ └─────────────────┘ └────────────┘
│
├──signed POST──▶ Razorpay (payments)
├──HTTPS API──▶ Mailgun / SES (email)
└──signed POST◀── Razorpay webhook
Every boundary either uses HTTPS + Bearer token, an HMAC-signed callback, or a signed URL. There are no unsigned cross-app trust paths.
Authentication
- Sanctum personal access tokens for the API. Issued at login (
POST /auth/login), returned in the JSON body. Mobile apps store and attach asAuthorization: Bearer …. - Filament admin uses session-based auth on a separate
system_adminguard. - Owner self-serve registration at
POST /auth/owner/register. - Password reset via
password_reset_tokens— token is sent in the email, the DB stores its SHA-256 hash.requestResetis rate-limited atthrottle:6,1;resetatthrottle:10,1.
Audit gaps
- P0 — password reset doesn't revoke existing Sanctum tokens. Add
$user->tokens()->delete()in the reset path and aPOST /auth/logout-allendpoint. - P1 —
/auth/loginand/auth/verify-reset-tokenaren't rate-limited. - P3 — TOTP 2FA, Google/Microsoft SSO, magic link, IP allowlist, login-history visible to user are all on the enhancement roadmap.
Authorization
Defense in depth. See Multi-tenancy for the layers.
auth:sanctumvalidates the user.tenant.resolveresolves and (should) verify the active tenant.require.permission:<key>blocks at the route boundary.- Form-request validators tenant-scope
Rule::exists. - Controller
enforcePermissionand policy classes handle row-level decisions. Auditabletrait writes every change toaudit_logs.
Audit gaps
- P1 — Several controllers accept
*_idfrom the body and validate viaexistswithout scoping to the URL tenant. Audit recommends anAssertsTenantScopetrait. - P1 —
tenant.resolveshould re-check membership. - P1 —
require.premium:<feature>falls back to user'scurrent_tenant_idwhen route lacks an explicit{tenant}.
Audit log
audit_logs is append-only, polymorphic over the auditable model. Every clinical / billing / admin write writes a row. The RecordAuditContext middleware injects:
- Actor user id (or system-admin id).
- Tenant id (active tenant for the request).
- IP, user-agent, request id.
Custom events outside the Eloquent lifecycle (e.g. receipt.emailed, appointment.checked_in) are written via auditCustomEvent($action, $before, $after) on the Auditable trait.
Audit gaps
- P2 — The
Auditablesnapshot allow-list should be required (default-deny) withpassword,remember_token, secret columns in a system-wide deny-list. - P2 — Audit-log search + CSV export.
Webhooks
Razorpay is the only inbound webhook today. The handler:
- Verifies HMAC signature via
RAZORPAY_WEBHOOK_SECRET. - Routes on
eventfield. - Mutates the appropriate
paymentsrow.
Audit gaps
- P0 —
verifyshould re-fetch the canonical payment amount from Razorpay before mutating local state. - P0 — Webhook should also re-fetch instead of trusting
payload.amount. - P0 — The gateway silently rejects events when the secret is unset; fail loudly in production.
Public surfaces
Public, unauthenticated routes in increasing risk:
/system/health— pong only./auth/*— rate-limited where applicable./providers/*— marketplace; onlyis_public=truetenants leak through./p/{slug}— lost-pet collar tag; onlypublic_profile_enabled=truepets./shares/{code},/shares/redeem— share-code lookup (controlled by owner)./webhooks/razorpay— signed.
Audit gaps
- P0 —
/p/{slug}JSON endpoint surfaces owner'sclient.phoneeven when lost-mode is off, and isn't rate-limited. Easy enumeration target. - P1 —
/providers/{tenant}is enumerable on numeric ids. Addthrottle:30,1.
Frontend secure storage
| App | Today | Recommended |
|---|---|---|
| vetty | SharedPreferences (plain) | flutter_secure_storage |
| vetdoctor | SharedPreferences (plain) | flutter_secure_storage |
| admin web | localStorage (XSS-stealable) | httpOnly Secure cookie + CSRF token |
All three are flagged P0 in the audit and form Wave 1 of the implementation plan.
Transport security
- HTTPS-only at the reverse proxy.
- Sanctum cookies use
SecureandSameSite=Lax. - No certificate pinning in the Flutter clients today (audit P0). Recommended: pin the production cert via a
SecurityContextin theApiClient.
Secrets management
.envfor local; AWS Secrets Manager (or equivalent) in production.service_settings.valuecarries per-tenant secrets (Mailgun key, SES creds, future SMS key) encrypted with the cast.- The audit log redacts
passwordandremember_tokensnapshots; the per-model allow-list controls what gets recorded.
Resource exposure (Resource classes)
The audit recommends formalising three privilege tiers for JsonResource classes:
- Public — narrow, marketing-safe (today's
ProviderListingResource). - Scoped — for tenant members.
- Internal — for system admins.
Specific fields the audit calls out for removal: tenant_id from AppointmentResource, internal disk names from attachment resources.
Hardening roadmap
The audit's Wave 1 (≈5 dev-days) is pure security hardening. Items in priority order:
- Razorpay verify + webhook re-fetch.
- Webhook secret hard-fail when unset.
/p/{slug}rate-limit + opt-in + no fallback toclient.phone.- Hash caregiver
invite_tokenon store. - Sanctum-token revocation on password reset +
logout-allendpoint. - Frontend secure-storage pivot in vetty / vetdoctor / admin web.
- Certificate pinning in both Flutter apps.
Wave 2 carries the IDOR + race-condition cluster.
See AUDIT.md §10 for the full plan.