Architecture العربية

Qwizin — Security Architecture

Status: Draft v0.1 · Date: 2026-07-20 Traces to: QAS-SEC-*, QAS-INT-*, QAS-CMP-*, R-03R-07, CON-10 (MoM NFR-1) Companion: Runtime View (the flows) · DevOps (cluster hardening)

Scope note. This document covers application and data security. Kubernetes and supply-chain hardening live in the DevOps deep-dive §5. Regulatory obligations are in Risks §A — and remain unverified pending Saudi counsel.


1. Trust Boundaries#

flowchart TB
    subgraph UNTRUSTED["🔴 Untrusted"]
        MOB["📱 Mobile client<br/><i>attacker-controlled</i>"]
        ANON["🌐 Anonymous internet"]
        UPLOAD["📎 Uploaded files<br/><i>from unverified parties</i>"]
    end

    subgraph SEMI["🟠 Semi-trusted"]
        AUTH["Authenticated user<br/><i>may attack other tenants</i>"]
        EMP["Employee<br/><i>may attack employer/colleagues</i>"]
        EXTW["External webhooks<br/><i>spoofable</i>"]
    end

    subgraph TRUSTED["🟢 Trusted — inside the boundary"]
        API["API tier"]
        WORK["Workers"]
        DB[("PostgreSQL + RLS")]
        OBJ[("Object Storage")]
    end

    subgraph PRIV["🔵 Privileged"]
        ADM["🛡️ Admin portal<br/><i>highest-value target</i>"]
    end

    MOB & ANON -->|"TB-1: TLS · WAF · rate limit<br/>authn · input validation"| API
    AUTH -->|"TB-2: authz · tenant scope · RLS"| API
    EMP -->|"TB-3: employer/colleague isolation"| API
    UPLOAD -->|"TB-4: quarantine · magic bytes · AV"| OBJ
    EXTW -->|"TB-5: signature verify · idempotency"| API
    ADM -->|"TB-6: MFA · separate origin · full audit"| API
    API --> DB & OBJ
    WORK -->|"TB-7: re-establish tenant context"| DB

    style UNTRUSTED fill:#fde8e8,stroke:#c94a4a
    style SEMI fill:#fff4e5,stroke:#d98c1f
    style TRUSTED fill:#e8f5e9,stroke:#2d8659
    style PRIV fill:#e8f0fe,stroke:#1168bd
ID Boundary Primary threat Detail
TB-1 Internet → API Injection, credential attacks, enumeration §3
TB-2 Authenticated user → data Cross-tenant access (BOLA) §2
TB-3 Employee ↔ employer/colleague Second isolation axis §2.4
TB-4 Upload → storage Malware, stored XSS, IDOR §4
TB-5 External webhook → API Spoofed payment confirmation §5
TB-6 Admin → everything Account compromise = platform compromise §6
TB-7 Queued job → data Tenant context absent by default §2.3

TB-3 and TB-7 are the two boundaries teams routinely miss. TB-3 because "tenant" is modelled as a single flat axis when there are actually two. TB-7 because jobs have no request, so every request-scoped safety mechanism silently evaporates.


2. Tenant Isolation — the highest-priority control#

QAS-SEC-01 · R-05 · Mechanism detail in Flow 6

2.1 Why application-level scoping alone fails#

The tempting design is a per-endpoint check: $employee->company_id === $user->company_id. It fails structurally — it is correct only if every developer remembers it on every endpoint, forever. The first forgotten check is a breach, it will be forgotten, and nothing surfaces the omission. No error, no crash, just Company A reading Company B's staff records.

An Eloquent global scope is better but still bypassed by withoutGlobalScopes(), raw queries, DB::table(), some relationship paths, and anyone unaware it exists.

2.2 Five layers, ordered by how late each catches#

# Layer Fails closed? Role
1 PostgreSQL RLS with FORCE ROW LEVEL SECURITY Yes The backstop. Returns zero rows regardless of application bugs.
2 TenantContext that throws when unset ✅ Yes Never returns null, never defaults to "no filter"
3 Eloquent global scope ❌ No Convenience and index usage — not a security boundary
4 Architecture test: every company_id table uses the trait ✅ At build Catches the model added next year
5 Adversarial CI suite ✅ At build Tests observable behaviour, not internals

FORCE ROW LEVEL SECURITY matters: without it the table owner bypasses policies, and the table owner is usually the application role.

Layers 1 and 5 survive staff turnover. Layers 2–4 make the right thing easy.

2.3 ⚠ The two RLS failure modes#

Both are cases where the policy is written correctly and data still leaks.

flowchart TB
    subgraph F1["Failure 1 — connection reuse"]
        R1["Request A<br/>company=1"] --> C1["Pooled connection<br/>SET app.company_id = 1"]
        C1 --> R2["Request B<br/>company=2"]
        R2 --> LEAK1["🚨 GUC still = 1<br/>Company 2 sees Company 1's data"]
    end

    subgraph F2["Failure 2 — queued job"]
        J1["Job dequeued"] --> J2["No HTTP request<br/>→ no GUC set"]
        J2 --> LEAK2["🚨 Unscoped query<br/>or silent empty result"]
    end

    style LEAK1 fill:#fde8e8,stroke:#c94a4a
    style LEAK2 fill:#fde8e8,stroke:#c94a4a
Failure Mitigation
Connection reuse — transaction-mode pooling or persistent workers carry the session variable into the next request Set it transaction-scoped inside an explicit transaction, and reset in terminating middleware. This is the actual leak vector — the policy works perfectly and still serves wrong data.
Queued jobs have no request Serialise the tenant identifier into the job payload; set it in job middleware; a job that cannot establish context must throw, not default

This is a primary reason Laravel Octane is deferred. Octane's persistent workers make failure mode 1 substantially more likely, trading the system's top quality attribute for performance the launch scale does not need.

2.4 The second isolation axis#

ACT-EMP introduces an isolation dimension beyond company↔company:

  • An employee must not read a colleague's assessment scores
  • A company admin's access to employee records should be scoped and audited, not unlimited
  • An employee's personal certificates are arguably theirs, not their employer's (OQ-07)

Do not model this as a single flat tenant boundary. Company scoping is necessary and not sufficient.


3. Identity & Authentication#

Flow in Flow 1.

3.1 Token strategy#

Verified constraint: Laravel Sanctum has no native refresh-token mechanism. It provides token expiration (expiration config, per-token expiry, sanctum:prune-expired) and revocation by row deletion — but rotation and reuse detection must be built.

Control Specification
Access token Ability-scoped, ≤ 15 min
Refresh token Hashed at rest, single-use, rotated on every use
Reuse detection Replay of a consumed refresh token → revoke the entire token family + alert
Device binding One token pair per named device → real "sign out this device" UX
Revocation triggers Password change, account rejection/suspension, explicit user action, company suspension cascade
Client storage iOS Keychain / Android Keystore — never AsyncStorage or SharedPreferences

Reuse detection is the single highest-value auth control. It converts token theft from persistent silent access into a detected incident.

Not stateless JWT: revocation is a functional requirement here — rejecting a company must immediately cut off its employees. Stateless tokens make that hard.

3.2 Google Sign-In#

Verify the ID token server-side: signature against Google's JWKS, plus aud, iss, exp, and nonce. Never trust a client-supplied email or profile.

⚠️ Account-linking pre-hijacking. Auto-linking a Google login to an existing account by matching email address is exploitable if that email was never verified: an attacker registers with the victim's email, the victim later signs in with Google, and the accounts merge into attacker-controlled access. Require the existing account's email to be verified before linking.

3.3 OTP / SMS#

Control Rationale
Hashed at rest, constant-time compare, single-use, short expiry Standard
Rate limit per phone, per IP, per device; exponential backoff
Country allowlisting
App attestation (Play Integrity / App Attest)
Quotas as a cost control, not just a security one SMS pumping fraud is a direct financial loss vector — an attacker burns the SMS budget without ever creating an account

⚠️ Unverified: KSA sender-ID registration requirements with the telecom regulator. Confirm before selecting a provider.

3.4 Authorization architecture#

Three separate concerns that must not share one mechanism:

flowchart LR
    REQ["Request"] --> S1{"1. Account state<br/><i>precondition</i>"}
    S1 -->|"not approved"| ALLOW{"On the explicit<br/>allowlist?"}
    ALLOW -->|No| DENY1["❌ 403 + machine-readable state"]
    ALLOW -->|Yes| S2
    S1 -->|"approved"| S2{"2. Capability<br/><i>may this role ever?</i>"}
    S2 -->|No| DENY2["❌ 403"]
    S2 -->|Yes| S3{"3. Resource policy<br/><i>this record, now?</i>"}
    S3 -->|No| DENY3["❌ 404 for cross-tenant"]
    S3 -->|Yes| OK["✅ Proceed"]

    style DENY3 fill:#fff4e5,stroke:#d98c1f

Account state is a precondition on the session, not a permission. Modelling it as a permission means every new endpoint is unprotected by default — backwards for an approval-gated platform. Implement as an explicit allowlist: everything denied while pending except a short reviewable list (view own profile, upload documents, resubmit, browse public marketplace, log out).

⚠️ Documented framework footguns — all three are real and all three are easy to hit:

  1. A global before hook returning false breaks every policy in the application. A non-null return is treated as the final authorization result, short-circuiting everything — including the admin's ability to review pending accounts. It must return null on the happy path. Reserve the global hook for super-admin elevation only.
  2. Inline authorization helpers skip before/after hooks entirely. Any such call silently escapes a global account-state check. Ban them outside the capability service.
  3. A policy's own before() is not called when the policy lacks a method matching the ability. A base-policy approach therefore has a blind spot for abilities the concrete policy does not implement.

Return 404, not 403, for cross-tenant resource probes — a 403 confirms the resource exists and enables enumeration.


4. Document Upload Pipeline#

TB-4 · QAS-SEC-02, QAS-SEC-03 · R-07 · Flow in Flow 2

The threat that defines this pipeline: onboarding accepts arbitrary files from unverified parties — by definition, since verification is what the upload is for. A stored XSS payload served same-origin executes against the admin reviewer's session, the highest-privilege session in the system.

flowchart LR
    U["Upload"] --> V1["Size · count · declared type"]
    V1 --> V2["🔍 <b>Magic-byte inspection</b><br/><i>never extension or client MIME</i>"]
    V2 --> V3["Server-generated filename<br/><i>never the user's</i>"]
    V3 --> Q[("🔒 Quarantine bucket<br/>encrypted · no admin read")]
    Q --> AV["ClamAV<br/><i>separate Deployment</i>"]
    AV -->|"infected"| REJ["❌ Rejected<br/>admin never sees it"]
    AV -->|"clean"| PROM[("✅ Documents bucket")]
    PROM --> SIGN["Signed URL, TTL ≤ 5 min<br/>issued AFTER authz"]
    SIGN --> SEP["Separate domain<br/>Content-Disposition: attachment<br/>X-Content-Type-Options: nosniff"]

    style Q fill:#fde8e8,stroke:#c94a4a
    style REJ fill:#fde8e8,stroke:#c94a4a
    style PROM fill:#e8f5e9,stroke:#2d8659
Invariant Why
Quarantine first, promote on clean An admin reviewer must never be the malware-detection mechanism
Magic bytes, not extensions Both extension and client-declared MIME are attacker-controlled
Server-generated filenames User filenames carry path traversal, null bytes, and Unicode tricks
Separate domain — not a cookie-sharing subdomain Same-origin user content is stored XSS against the admin session
Short-TTL signed URLs issued after authz Bounds leaked-URL exposure (QAS-SEC-02)
Fail closed Scanner unavailable = not yet clean, never assume clean
Immutable versions An audit trail referencing a mutated document proves nothing (QAS-CMP-02)
Flattened preview for review PDFs carry embedded JavaScript; SVGs carry XXE; polyglots exist. Reviewers should never open the original in a browser PDF engine.

ClamAV runs as a separate Deployment, not a per-pod sidecar — the signature database is ~1 GB and would otherwise be replicated into every application pod.

Encryption at rest — application layer, on top of storage encryption#

Recommended for legal documents specifically. Storage-level encryption protects only against physical media theft. It does not protect against a compromised application credential, a misconfigured bucket, or an over-privileged operator.

Use envelope encryption, not the framework's string encrypter — that API loads whole files into memory and is wrong for multi-MB PDFs:

per-file random DEK → stream-encrypt AES-256-GCM
DEK wrapped by a KMS-held KEK → stored alongside the object

Per-file keys isolate blast radius; only 32 bytes cross the KMS boundary, avoiding latency and per-byte cost.

State the cost plainly: KMS integration, key rotation tooling, a re-wrapping job, careful in-memory DEK hygiene (never log, never persist decrypted), and permanent data loss if key custody fails. Budget the operational maturity, not just the code.


5. External Integrations#

TB-5

Control Applies to
Verify the signature synchronously, before enqueuing All webhooks
Idempotent on the provider's event ID All webhooks — providers retry
Never trust client-reported payment success Billing
Reconcile server-side on a schedule Billing, Video
Egress restricted to known provider domains All

Reconciliation is not optional. Webhooks are lossy. Billing is on session duration — a missed SessionEnded is a billing error or an indefinitely-open paid session. A scheduled job must compare provider state against local state and close orphans.


6. Admin Portal#

TB-6the highest-value target in the system. A platform admin can approve accounts, read every tenant's legal documents, and touch payments. One compromised admin account ≈ platform compromise.

Control Requirement
MFA Mandatory, no exceptions
Separate origin Not a path on the user-facing domain
Step-up authentication Required for: approving documents, issuing/revoking certificates, refunds, and changing payout bank details
Full audit Every action, with actor, IP, timestamp
Impersonation Explicitly logged, time-boxed, visibly flagged in the session
IP restriction Where operationally feasible

Payout detail changes deserve their own paragraph. Account takeover → redirect payouts is the classic marketplace fraud. Required: step-up MFA, a delay window before the change takes effect, and out-of-band notification to the previous contact channel — giving the legitimate owner a chance to intervene.


7. Assessment & Certificate Integrity#

QAS-INT-01/02/03 · R-06 · Flow in Flow 4

Why this ranks as a security concern rather than a correctness one: these certificates assert food-safety competence. A forged certificate transfers real-world risk to restaurant patrons and destroys the platform's central value claim.

7.1 Answer-key confidentiality#

The rule is absolute: the correct answer never leaves the server — not as a flag, not in metadata, not inferable from option ordering or object key order.

Leak path Control
ORM over-serialisation — a model serialised via toArray() ships correct_option_id Explicit API resources with a field allowlist, plus $hidden as defence in depth. This is a very common real-world breach path.
The review endpoint — showing a learner their wrong answers legitimately, and re-exposing the whole key Gate it; reveal keys only for questions actually attempted
Client-side grading for "instant feedback" Round-trip each answer; the server decides what to reveal

CI contract test asserts the absence of correctness fields from the delivery schema.

7.2 Score integrity#

Threat Control
Client submits a forged score No score field exists in the API. Submission carries answers only.
Time-limit bypass Enforced against the server-recorded start time, never a client clock
Replay / double-scoring race Idempotency key; a second submission for a finalised attempt is rejected, not re-graded
Retry-until-pass, presenting only the pass All attempts append-only and retained; attempt limits configurable
Answer-sheet sharing Question pooling (draw N from M) + randomised option order per attempt
Post-hoc score edit Immutable once finalised; corrections only via a separate, audited, admin-only flow

7.3 Certificate tamper-evidence — three independent layers#

flowchart LR
    C["Certificate PDF"] --> L1["1️⃣ Signed QR payload<br/><i>cert id · holder · course ·<br/>issue · expiry + signature</i><br/><b>→ offline verification</b>"]
    C --> L2["2️⃣ Public verification endpoint<br/><i>/verify/{ULID}</i><br/><b>→ revocation + current status</b>"]
    C --> L3["3️⃣ Digitally signed PDF (PAdES)<br/><i>requires a trust service provider</i><br/><b>→ strongest for third parties</b>"]

Each defeats a different attacker:

  1. Signed QR payload. A visually-forged certificate fails signature check even with no network access — which matters for a municipal inspector in a kitchen. Use a dedicated signing key, not the application key.
  2. Public verification endpoint. Use a ULID or 128-bit random token, never a sequential certificate number — sequential IDs let anyone enumerate the entire certificate corpus. Reveal minimum PII: course, issue/expiry, valid/revoked, at most a partial holder name. A public page returning full name plus national ID is a self-inflicted PDPL breach. Rate-limit it.
  3. PAdES signature. ⚠️ Unverified whether a KSA-trusted signing certificate is obtainable — worth investigating, since a locally-trusted signature carries more weight with Saudi regulators.

Revocation must exist from day one. Certificates get issued in error, get issued to accounts later found fraudulent, and expire. The verification endpoint is the revocation mechanism — which is precisely why the QR must link to it rather than being self-contained only. Design the offline payload to carry an expiry so it degrades safely.


8. Audit Logging#

QAS-CMP-02

What must be logged#

Authentication events (success, failure, MFA, credential change) · all authorization denials (the attack signal) · document upload/approve/reject with reviewer identity · assessment attempts and score corrections · certificate issuance and revocation · all financial events · payout detail changes · admin impersonation · role changes · every export or bulk read of personal data (PDPL-relevant).

Integrity#

Approval workflows and financial transactions create repudiation risk, so an ordinary mutable log table is insufficient — anyone with database write access can rewrite history.

Use an append-only hash-chained ledger:

chain_hash(n) = SHA256( chain_hash(n-1) || payload_hash(n) )

with periodically signed checkpoints anchoring the chain head.

Complement with database-level protection: revoke UPDATE and DELETE on the audit table from the application role, and ship logs to write-once external storage.

PII hygiene#

Redact at the logging layer, not by developer discipline.

Never log Always
National IDs, OTP codes, tokens, document contents, card data Log identifiers, not values
Full request bodies in exception reports Configure exception reporting to scrub request payloads — a stack trace with a full request body is a routine PII leak
APP_DEBUG=false in production, verified by automated check

⚠️ Retention conflict. Audit retention (≥ 7 years for financial alignment) is in tension with PDPL data minimisation and erasure rights. Resolve deliberately with counsel and document it in the record of processing — do not improvise at the time of a deletion request.


9. STRIDE Threat Model#

Highest-severity threats by boundary.

Boundary Threat (STRIDE) Sev Control
TB-2 Elevation — cross-tenant access (BOLA) 🔴 Critical RLS + scoped bindings + adversarial CI suite (§2)
TB-4 Tampering/Elevation — malicious upload → stored XSS against admin 🔴 Critical Separate origin, magic bytes, quarantine + AV (§4)
TB-4 Info disclosure — IDOR on another company's licences 🔴 Critical Signed short-TTL URLs post-authz; ULIDs
TB-6 Elevation — admin compromise → full tenant access 🔴 Critical MFA, separate origin, step-up, full audit (§6)
TB-1 Tampering — client-submitted score / answer key in payload 🟠 High Server-side grading; explicit resources (§7)
TB-1 Spoofing — stolen refresh token 🟠 High Rotation + reuse detection → family revocation (§3.1)
TB-1 Info disclosure — over-broad serialisation leaking correct_option_id, national IDs 🟠 High Explicit resources, $hidden, response-shape tests
Offline Spoofing — forged food-safety certificate 🟠 High (real-world safety) Signed QR + verification endpoint + revocation (§7.3)
TB-5 Tampering — forged webhook confirming unpaid transaction 🟠 High Signature verification; never trust client-reported success (§5)
TB-5 Elevation — payout redirect via account takeover 🟠 High Step-up MFA + delay + out-of-band alert (§6)
TB-7 Info disclosure — job runs without tenant context 🟠 High Context from payload; throw if absent (§2.3)
TB-1 Info disclosure — recorded consultations containing health data 🟠 High In-Kingdom storage; signed playback URLs; OQ-08/R-04
TB-2 Info disclosure — SQLi via raw expressions bypassing tenant scope 🟠 High Parameterised queries; ban raw SQL in tenant paths; least-privilege DB role
Ledger Repudiation — disputed payout or approval 🟡 Medium Hash-chained audit ledger (§8)

The four to fix first: cross-tenant BOLA · document IDOR · malicious upload · admin compromise. Each is independently capable of exposing every customer's data.


10. Implementation Priority#

Ordered by risk reduction per hour, not by interest.

Week 1 — highest ratio, lowest breakage risk#

  1. APP_DEBUG=false / APP_ENV=production verified by automated check
  2. Confirm the payment flow is hosted/redirect so card data never touches Qwizin infrastructure
  3. Plaintext secrets out of version control; secret scanning in CI; rotate anything ever committed
  4. Server-side grading only; explicit API resources with field allowlists
  5. Password change → revoke all token families

Month 1 — structural#

  1. RLS policies alongside the first tenant-scoped migration — retrofitting onto a populated table needs a maintenance window
  2. TenantContext that throws; transaction-scoped session variable + terminating reset
  3. Tenant context re-established in every queued job, throwing if absent
  4. Adversarial cross-tenant CI suite — the control that actually keeps you safe over time
  5. Upload pipeline: quarantine → AV → promote; separate domain; magic bytes
  6. Refresh-token rotation with reuse detection
  7. Admin MFA + separate origin + audit coverage
  8. Application-layer envelope encryption for legal documents

Quarter 1 — depth#

  1. Hash-chained audit ledger with signed checkpoints
  2. Certificate signed-QR + public verification endpoint + revocation
  3. Step-up auth on payout changes, with delay and out-of-band notification
  4. Log redaction at the logging layer; scrubbed exception reporting
  5. Personal-data inventory — required for QAS-CMP-01 to be satisfiable at all

On the personal-data inventory: the hard part of a data-subject request is not deletion, it is enumeration. Once data has spread into a search index, a reporting projection, and a log pipeline, "find everything about this person" is unanswerable without an inventory maintained as a first-class artifact. Build it while the system is small.


11. Where Counsel Is Required, Not Research#

⚠️ These are not engineering questions. Detail in Risks §A.

  1. SAMA licensing (R-01) — before building payouts
  2. ZATCA e-invoicing (R-02) — scope, and marketplace supplier-of-record
  3. PDPL operational duties (R-03) — breach timelines, DSR deadlines, DPO threshold, retention vs audit-retention conflict, lawful basis for employer access to employee training records, and whether recorded consultations constitute sensitive health data

No breach-notification deadline or data-subject-rights response time appears in this document, because those are exactly the numbers that would be encoded into runbooks and SLAs — and they could not be verified against primary sources. They are deliberately absent rather than guessed.

Tech Lead · Amir Haroun Draft v0.1 · research & design only