Skip to content

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 FormRequest with exhaustive rules — types, lengths, enums, existence (exists: with ownership scope), and cross-field rules (e.g., event_date ≥ today, budget_maxbudget_min).
  • Money: validated as decimal strings with ≤ 3 dp, 0.000999999.999; never floats.
  • Phone numbers normalized to E.164 (+965XXXXXXXX for 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: $fillable allow-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):

  1. 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.
  2. 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.
  3. Files failing verification are deleted and the media row marked rejected; repeated rejections flag the account.
  4. 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-Disposition enforced for downloads).
  5. 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). .env files are never committed; .env.example carries 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