Security Baseline¶
Scope: the Laravel API, both clients, data stores, and integrations. Everything here is MVP unless tagged otherwise. Kuwait launch means Kuwait's DPPR (CITRA Data Privacy Protection Regulation) is the primary regime; we build to GDPR-grade practices so KSA PDPL / UAE PDPL expansion is additive.
Authentication¶
- Token scheme: Laravel Sanctum personal access tokens (bearer). Web and mobile use the same token API (no cookie/session split — API-first). Tokens are hashed at rest (SHA-256, Sanctum default), named per device, listable and revocable by the user ("logout other devices").
- Token lifetime: access tokens expire after 30 days of issue and are rotated on password/phone change; any auth-sensitive change (password reset, OTP re-verification) revokes all other tokens.
- Credentials supported: email + password, phone OTP, Google, Apple (see Integrations — OAuth). Passwords: bcrypt (cost 12), minimum 8 chars, checked against a breached-password list on set.
- OTP: 6 digits, expires in 5 minutes, single-use, stored hashed. Rate limits (Redis):
- request OTP: max 1 / 60 s and 5 / hour per phone number; 10 / hour per IP;
- verify OTP: max 5 attempts per code, then the code is invalidated;
- global circuit breaker on SMS spend anomalies (ops alert).
- Email verification required before a customer can publish an RFQ; phone verification required before booking payment. Vendors require both plus admin approval.
- Brute-force login: 5 failures / minute per account+IP → 429 with backoff; failures are audit-logged.
Authorization (RBAC)¶
Roles: customer, vendor_owner, vendor_member (Scale for granular vendor team roles), admin, plus fine-grained admin permissions via spatie/laravel-permission (e.g., vendors.approve, refunds.execute, cms.edit).
Enforcement is policy-per-model in Laravel — every controller action authorizes explicitly; there is no "default allow". Ownership checks are structural: customers only reach rows scoped by user_id, vendors by vendor_id (via team membership), and cross-tenant access is impossible through query scoping (Booking::forUser($user)) rather than per-call remembering.
Authorization matrix approach — each SRS module carries its own permissions matrix; the platform-level summary:
| Capability | Customer | Vendor | Admin |
|---|---|---|---|
| Create event / RFQ | own only | — | view all |
| Submit quotation | — | own vendor, within plan quota | view all |
| Accept quote / create booking | own RFQ only | — | on behalf (support, audited) |
| Pay / request refund | own booking | — | execute refunds |
| Messaging | own conversations | own conversations | moderation read (audited, flagged content only) |
| Vendor profile / services / packages | read published | own vendor CRUD | approve, suspend, edit |
| Reviews | write after completed booking | reply to own | moderate |
| Plans, CMS, categories, users | read | read | CRUD |
IDs exposed in the API are non-sequential ULIDs (public_id), so authorization never relies on unguessability — but enumeration is still not offered for free.
Input Validation¶
- Every endpoint has a
FormRequestwith exhaustive rules — types, lengths, enums, existence (exists:with ownership scope), and cross-field rules (e.g.,event_date≥ today,budget_max≥budget_min). - Money: validated as decimal strings with ≤ 3 dp,
0.000–999999.999; never floats. - Phone numbers normalized to E.164 (
+965XXXXXXXXfor Kuwait, 8-digit local part). - All output escaped by default (JSON API + React/Flutter rendering); rich text is not accepted anywhere in MVP — message bodies and descriptions are plain text, rendered as text.
- Mass assignment:
$fillableallow-lists only; request→model mapping through explicit DTOs in services. - SQL: Eloquent/query-builder bindings only; raw SQL requires review and bound parameters.
File-Upload Security¶
Uploads are direct-to-S3 via pre-signed URLs, then verified asynchronously (pipeline in Integrations — S3):
- Declared policy per context — e.g., vendor gallery:
jpg|png|webp≤ 10 MB; license documents:pdf|jpg|png≤ 20 MB; message attachments:jpg|png|pdf≤ 15 MB, max 5 per message. - Server-side verification job: magic-byte content sniffing (never trust extension or client MIME), image re-encode (defuses polyglot/embedded payloads), EXIF/GPS strip, dimension and page-count sanity checks, and ClamAV scan for PDFs and documents.
- Files failing verification are deleted and the
mediarow markedrejected; repeated rejections flag the account. - Private files (licenses, attachments) are only reachable through short-lived signed GET URLs issued after a policy check. Public bucket serves only verified listing media, under a CDN domain separate from the app origin (no cookies,
Content-Dispositionenforced for downloads). - SVG is not accepted anywhere (XSS vector). Video (Scale): mp4 ≤ 200 MB, transcoded before publication.
Encryption¶
| Layer | Measure |
|---|---|
| In transit | TLS 1.2+ everywhere (HSTS, redirect from HTTP); internal service traffic within a private network/VPC; webhook endpoints TLS-only |
| At rest — DB | Managed MySQL encryption at rest (AES-256); Laravel encrypted cast additionally applied to civil-record-adjacent fields if any are ever stored (none in MVP), OAuth refresh tokens, and PSP webhook secrets |
| At rest — objects | S3 SSE (AES-256) on both buckets; private bucket blocks public ACLs at the account level |
| Backups | Encrypted with a key separate from production credentials |
| Secrets in code | Never. See below |
Secrets Management¶
- Secrets live in environment configuration injected at deploy (GitHub Actions → encrypted environment secrets → runtime env / secrets manager).
.envfiles are never committed;.env.examplecarries names only. - Distinct credentials per environment; production PSP keys visible to two named people only.
- Rotation: PSP and FCM keys annually or on personnel change; automated secret scanning (GitHub secret scanning + gitleaks in CI) blocks accidental commits.
- Webhook signing secrets are per-provider, per-environment, and rotatable without downtime (accept two active secrets during rotation).
Audit Logging¶
Append-only audit_logs table (see tables.md); no updates or deletes, ever, via application credentials.
In scope (MVP):
- Auth: login success/failure, OTP request/verify, password change, token revocation, social link.
- Admin actions: vendor approve/reject/suspend, refund execution, plan/CMS/category changes, acting-on-behalf.
- Money: payment created/succeeded/failed, refund lifecycle, subscription changes, invoice issuance.
- State transitions: RFQ, quotation, booking status changes (actor, old → new).
- Data access (targeted): admin reads of license documents and moderation reads of conversations.
Each entry: actor (user id + role), action, entity type/id, old/new values (JSON, PII-minimized), IP, user agent, timestamp. Retention: 2 years online, then archived to cold storage for 5 years (financial entries 10 years). Admin UI is read-only with filtering (MVP: basic list; richer forensics Scale).
Rate Limiting¶
Redis-backed, layered (details in API conventions):
- Global per-IP baseline; stricter unauthenticated buckets.
- Per-user buckets for authenticated traffic.
- Endpoint-specific hard limits: OTP (above), login, RFQ creation (10/day per customer), quotation submission (plan quota + burst limit), messaging (30 messages/min), review creation (per booking, exactly one), media pre-sign requests (60/h).
- PSP webhook endpoint: signature check before any work; unsigned junk is dropped at ~zero cost.
- 429 responses carry
Retry-After.
OWASP Top-10 Mapping¶
| OWASP 2021 | Primary controls here |
|---|---|
| A01 Broken Access Control | Policy-per-model, ownership query scopes, ULID public ids, no default-allow routes, tests per permissions matrix |
| A02 Cryptographic Failures | TLS 1.2+/HSTS, bcrypt(12), hashed tokens & OTPs, SSE-S3 + DB encryption at rest, no PAN storage (PSP-hosted pages → SAQ-A scope) |
| A03 Injection | Eloquent bound queries, FormRequest validation, no raw HTML anywhere, Meilisearch filters built from validated enums only |
| A04 Insecure Design | Payment truth from webhooks only, idempotency keys, state machines with legal-transition enforcement, abuse limits designed per feature |
| A05 Security Misconfiguration | IaC'd Docker images, debug off in prod, CORS allow-list, security headers (CSP, X-Content-Type-Options, frame-ancestors), separate CDN domain |
| A06 Vulnerable Components | Dependabot + composer audit / npm audit in CI; images rebuilt weekly for OS patches |
| A07 Identification & Auth Failures | Sanctum token rotation/revocation, OTP + login rate limits, breached-password check, device session list |
| A08 Software & Data Integrity | Signed webhooks (PSP), locked dependencies (composer.lock/package-lock), CI provenance, no dynamic code loading |
| A09 Logging & Monitoring Failures | Audit log scope above, failed-auth alerting, Horizon failed-job alerts, PSP reconciliation job discrepancies page ops |
| A10 SSRF | No user-supplied URLs fetched server-side in MVP (uploads are direct-to-S3; geocoding uses place_id, not URLs); egress allow-list on workers |
PII Inventory & Retention¶
| Data | Where | Basis | Retention |
|---|---|---|---|
| Name, email, phone | users |
Contract (account) | Life of account + 30 days after deletion request |
| Password hash, OTP hashes | users, otp_challenges |
Security | OTPs purged after 24 h |
| Social provider subject | users |
Contract | With account |
| Commercial license documents | vendor_documents (private bucket) |
Legal obligation / contract | Vendor lifetime + 5 years (commercial-dispute horizon) |
| Event details (dates, locations, guest counts) | events, rfqs |
Contract | User-deletable; else with account |
| Messages & attachments | messages |
Contract | 2 years after conversation close, then purge |
| Payment records (no PAN — PSP holds card data) | payments, invoices, refunds |
Legal (financial records) | 10 years, exempt from erasure |
| Device push tokens | notification_devices |
Consent | Deleted on logout/unregister |
| IP addresses | audit_logs |
Legitimate interest (security) | With audit retention |
Notes: customers are not asked for Kuwait Civil ID — it is not required for our service and we refuse to collect it. Vendors provide commercial license numbers/documents, which are business data but handled with the same care.
DPPR / GDPR-style commitments: lawful-basis mapping above; privacy policy in Arabic and English; consent for marketing separate from terms; data-subject requests (export, correction, deletion) handled within 30 days — deletion anonymizes the user row (deleted-user-<id>) and cascades where the retention table allows, preserving financial and audit records as legally required; breach notification runbook targeting regulator notification within 72 hours; data-processing register covering PSP, FCM, email, and hosting providers; hosting region selected with GCC data-residency expectations in mind (KSA PDPL localization assessed before KSA launch — per-country cells are the Vision-phase answer).
Backup & Disaster Recovery¶
| Asset | Backup | RPO | RTO |
|---|---|---|---|
| MySQL | Automated daily snapshot + binlog point-in-time recovery | ≤ 15 min | ≤ 4 h |
| Redis | None needed for cache/limits; queues drain-and-replay — jobs are idempotent and re-derivable | n/a (acceptable loss) | ≤ 1 h |
| Meilisearch | No backup — full rebuild from MySQL (scout:import, ~minutes at our scale) |
n/a | ≤ 2 h |
| S3 objects | Versioning + cross-region replication on both buckets | ≤ 1 h | ≤ 8 h |
| Config/secrets | Secrets manager + sealed copies | — | ≤ 1 h |
- Quarterly restore drills (MySQL PITR to staging, verified by checksum row counts + smoke tests) — a backup that hasn't been restored is a rumor.
- Runbooks per scenario: DB loss, region outage, PSP outage (queue payments-degraded banner, subscriptions grace period), key compromise (rotate + revoke all tokens).
- Status page + incident severity ladder; Sev-1 = payments or auth down.
Related pages: Architecture Overview · Integrations · API Conventions · Tables