Qwizin — Security Architecture
Status: Draft v0.1 · Date: 2026-07-20
Traces to: QAS-SEC-*, QAS-INT-*, QAS-CMP-*, R-03…R-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-3andTB-7are the two boundaries teams routinely miss.TB-3because "tenant" is modelled as a single flat axis when there are actually two.TB-7because 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:
- A global
beforehook returningfalsebreaks 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 returnnullon the happy path. Reserve the global hook for super-admin elevation only.- Inline authorization helpers skip
before/afterhooks entirely. Any such call silently escapes a global account-state check. Ban them outside the capability service.- 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-6 — the 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:
- 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.
- 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.
- 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#
APP_DEBUG=false/APP_ENV=productionverified by automated check- Confirm the payment flow is hosted/redirect so card data never touches Qwizin infrastructure
- Plaintext secrets out of version control; secret scanning in CI; rotate anything ever committed
- Server-side grading only; explicit API resources with field allowlists
- Password change → revoke all token families
Month 1 — structural#
- RLS policies alongside the first tenant-scoped migration — retrofitting onto a populated table needs a maintenance window
TenantContextthat throws; transaction-scoped session variable + terminating reset- Tenant context re-established in every queued job, throwing if absent
- Adversarial cross-tenant CI suite — the control that actually keeps you safe over time
- Upload pipeline: quarantine → AV → promote; separate domain; magic bytes
- Refresh-token rotation with reuse detection
- Admin MFA + separate origin + audit coverage
- Application-layer envelope encryption for legal documents
Quarter 1 — depth#
- Hash-chained audit ledger with signed checkpoints
- Certificate signed-QR + public verification endpoint + revocation
- Step-up auth on payout changes, with delay and out-of-band notification
- Log redaction at the logging layer; scrubbed exception reporting
- Personal-data inventory — required for
QAS-CMP-01to 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.
- SAMA licensing (
R-01) — before building payouts - ZATCA e-invoicing (
R-02) — scope, and marketplace supplier-of-record - 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.