Security & POPIA
This is the compliance due-diligence pack for a chain owner’s technical or legal advisor. It explains how MySentinel protects learner and guardian personal information, how it enforces the Protection of Personal Information Act (POPIA), and — just as importantly — exactly where a capability is shipped and proven versus where it is Roadmap or needs per-environment provisioning. We have written it so that an advisor who checks our claims against the running system finds them true.
MySentinel runs entirely on Cloudflare (Workers, D1, KV, R2, Queues, Durable Objects, Cron, Static Assets). There is no separate VM fleet, no shared monolith database, and no third-party application runtime in the data path.
Executive summary
| Control | Status | Notes |
|---|---|---|
Tenant isolation — school_id from the verified token only | Shipped, verified | A school A user can never read school B; cross-role access is bounced |
RBAC at the gateway (requireAuth(role, scope)) | Shipped, verified | Officer → /admin and admin → /operator are both rejected |
| Passkey / WebAuthn step-up for sensitive actions | Shipped, verified | Admin login forces step-up once a passkey is enrolled |
| Encryption at rest — D1 | Shipped | Cloudflare encrypts D1 at rest by default |
| Per-school AES-256-GCM photo keys + cryptographic shredding | Shipped | Deleting the per-school key renders sealed data unreadable |
| Guardian/visitor contact sealing + lookup hash | Shipped (v0.2.0), per-env activation | New writes in a keyed school are sealed-and-hashed; switching it on per environment is a supervised step |
| Append-only audit trail | Shipped — DB-trigger enforced | Honestly: a storage-layer no-UPDATE trigger, not a hash-chain or WORM appliance |
| POPIA erasure via a durable 30-day cool-off cascade | Shipped | Per-leg visibility; fail-closed; staff_user leg still fail-closed (Roadmap) |
| Time-based retention purge | Shipped | Archive-then-delete, per dataset, audited |
| Group-scoped owner login | Roadmap | A chain owner cannot yet log in scoped to only their campuses |
| Physical per-campus database isolation | Roadmap | Isolation is logical/code-enforced today; only shard_0 is live |
| Branded PDF / subject-access-request rendering | Roadmap | Needs the Cloudflare Browser Rendering binding |
Production is currently a hollow shell with no secrets provisioned, so all demonstrations run on UAT. Treat every “verified” claim as verified on the local stack and UAT, not on a live-traffic production tenant.
Tenant isolation — school_id comes from the token, never the browser
Every authenticated request carries a JWT that encodes the user’s role and scope. For school-scoped roles the token also carries the school_id. The hard rule, enforced at the gateway and never relaxed, is that school_id is read only from the verified token — never from a request header, query parameter, or body the browser controls.
This closes the most common multi-tenant data-leak class: a logged-in user cannot pivot to another school by editing a header. The same rule governs internal mechanisms. For example, real-time dashboard updates publish over an internal RPC where the tenant is the verified caller, not a request field — an earlier HTTP variant that read the tenant from the body was removed precisely because it allowed forged cross-tenant events.
Backend services compound the protection: each capability service owns its own D1 database and may only read its own data. A service that needs another’s data calls that service’s typed RPC; it never reaches into a sibling database. There is no shared schema for a bug to traverse.

RBAC at the gateway, plus passkey step-up for sensitive actions
The browser talks to exactly one public surface: the gateway. Internal services have no public route and no workers.dev exposure — the service binding is the trust boundary. The gateway’s requireAuth(role[], scope) middleware declares, per route, which roles and which scope (school or platform) may call it, and rejects everything else server-side. The PWA can hide a button, but the gateway rejects the request regardless — the front end is never the security boundary.
Verified behaviour from the live walkthrough: an officer who navigates to /admin is bounced, and a school admin who navigates to /operator is bounced. The five roles — officer, admin, class_teacher, operator, guardian — each see only their authorised surface, and the class_teacher is further scoped to only their assigned classes.
WebAuthn / passkey step-up
Sensitive, irreversible, or account/credential-changing actions require a passkey step-up (a fresh WebAuthn ceremony) on top of the session. Admin login itself forces step-up once a passkey is enrolled. Step-up gates safety drills and lockdown, POPIA subject-access / erasure / correction / cancellation, school and channel-provider configuration, import commits, staff-invitation create and revoke, session revocation, year rollover confirmation, and photo-key migration, among others.
Two engineering details matter for an auditor:
- Completeness is structural, not hand-maintained. A route-completeness test parses every state-mutating gateway route and fails the build unless the route is step-up-gated or explicitly listed in a reviewed non-sensitive allow-list. There is no third state, so a new mutating route cannot ship ungated by omission.
- Discoverable passkey login is genuinely two-factor. The “Sign in with passkey” flow enforces the authenticator’s user verification server-side (the device PIN/biometric is the second factor), preserves a per-environment relying-party ID split and a sign-count replay check, and binds the assertion to the credential owner. Lockout recovery is real: an operator can reset a staff member’s passkeys — itself a step-up-gated, audited action — so a user with no working device signs in with their password and re-enrols.
The lockdown override on the gate-scan path shows the design philosophy: the routine scan stays ungated (gate latency is sacrosanct), but the rare admin override that checks a learner out during a lockdown triggers a step-up bound to a distinct action id, asserts the admin role from the token, and is never offline-queued.
Encryption at rest
Cloudflare encrypts D1 at rest by default. On top of that platform baseline, MySentinel adds application-layer encryption for the most sensitive personal information.
Per-school AES-256-GCM envelopes
Learner and visitor photos are encrypted with a per-school AES-GCM key. Guardian and visitor contact fields are sealed under the same per-school envelope:
| Store | Fields sealed | Lookup hash |
|---|---|---|
Guardian records (identity-service) | phone, email | Yes — phone_hash, email_hash (HMAC-SHA256 of the normalised value with a per-environment pepper) |
Visitor records (visitor-service) | phone, host external name | No — never matched on |
Each ciphertext’s additional authenticated data binds it to its school and field, so a sealed value cannot be transplanted across schools or fields. The lookup hash exists only so inbound parent-message matching keeps working without decrypting anything.
Cryptographic shredding for erasure. Because contacts and photos share the per-school key, deleting that key renders every sealed record for that school unreadable — a true, fast erasure primitive for POPIA. The same sealing pattern protects per-school messaging-provider credentials (WhatsApp/SMS), which carry a key_version column for key rotation and are never returned by any read path.

Honest activation status (per environment)
Contact encryption is deliberately additive and reversible-safe, which means it is switched on per environment in a controlled sequence — it is not a single global flip:
- The decrypted read path is flag-gated, default OFF; with the flag off, behaviour is byte-identical to before.
- A one-time backfill must seal existing rows before the read flag flips (otherwise a null lookup hash would silently break inbound matching).
- Nulling out the legacy plaintext columns is a separate, later cutover.
This has been activated and verified locally on the demo school (all 11 guardians and 3 visitors sealed and hashed; reads decrypt; new writes seal; the live hash matches the stored hash). Switching it on for UAT or production is an explicit, supervised, per-environment operation. One honest nuance: a school with no provisioned per-school key stores the contact as plaintext fallback (never silently lost) and emits an operator warning until the school is keyed and backfilled — so “encrypted at rest” is per-school-key-gated, not yet universal across an unprovisioned tenant.
The append-only audit trail — what it is, and what it is not
Every security-relevant action writes an immutable audit event: logins (success and failure, with the email hashed, never stored in clear), learner and guardian record changes, movement records, emergency drafts and confirmed sends, operator actions, and the POPIA workflows.
Be precise about the immutability guarantee. Append-only is enforced by a D1 BEFORE UPDATE trigger that aborts any attempt to modify an existing audit_events row (RAISE(ABORT, 'audit_events is append-only')). Updates are blocked at the storage layer; deletes are permitted only for the sanctioned, audited POPIA retention-purge path. This is honest immutability against tampering-by-update — but it is not a cryptographic hash-chain and not a WORM storage appliance. An operator with direct database credentials and the ability to drop the trigger could, in principle, delete rows. If your due diligence requires tamper-evident chaining or external WORM, treat that as a Roadmap discussion, not a shipped claim.
Two further audit facts:
- At-least-once, duplicates accepted. Audit writes mint a fresh id per call with no idempotency key, so a retry can produce a benign duplicate row (same request id, adjacent timestamps). The state-projecting consumers are separately exactly-once; only the audit log is at-least-once, by design.
- Metadata is redacted. Audit rows never store plaintext phone numbers, emails, passwords, JWTs, or provider secrets. The redactor also strips value-shaped contact PII (emails and international
+27/0027phone numbers) from metadata, conservatively, so as not to over-redact the immutable ledger.
POPIA data-subject rights
MySentinel implements the data-subject participation rights POPIA grants (access, correction, deletion) and the security-safeguards and minimality conditions.
Right to erasure — a durable, fail-closed cascade
Erasure runs as a Cloudflare Workflow with a 30-day cool-off implemented as a durable sleep. After cool-off, the request cascades across every store that holds a copy of the subject’s PII:
- Identity (name, contact, photo)
- Movement history (redacts learner and guardian names)
- Inbound parent messages (redacts the raw sender phone/email, names, message body, parsed intent)
- Visitor records (name, phone and its sealed copy, host name, vehicle registration, plus the R2 photo)
- Notification subscriptions (hard-deletes the guardian’s push device keys)
A new erasure_legs table gives the operator per-leg visibility — which store ran and which failed. A request is marked completed only if every leg succeeds; a partial failure leaves it re-runnable and writes a popia.erasure_failed audit event. A cancellation requested during cool-off is honoured. Unsupported subject types fail closed — they throw rather than falsely report completion while PII still exists.
Honest gap: the staff_user erasure leg is currently fail-closed (it never falsely completes, but the staff-PII cascade itself is Roadmap).
Right of access (SAR) and correction
Subject-access and correction requests are first-class, step-up-gated admin actions. Note the polish gap an advisor will find: branded PDF rendering for a SAR pack needs the Cloudflare Browser Rendering binding and is Roadmap — today the data is assembled and exportable, but the styled, branded PDF output is not yet wired.
Time-based retention and minimisation
Each school has a retention policy that is now actually enforced, not just displayed. A shared per-service purge contract archives-then-deletes the oldest rows past each dataset’s cutoff, batched and atomic, writing the real archived rows to an R2 cold archive and auditing popia.retention_purged. Defaults:
| Dataset | Default retention |
|---|---|
| Movement events | 2555 days (~7 years) |
| Audit events | 2555 days |
| Notification delivery attempts | 90 days |
| Parent messages | 365 days |
| Visitor records | 2555 days |
The append-only ledgers keep their no-update invariant; POPIA retention is the one sanctioned deletion path, and it is archive-first.
Network, edge, and secret hygiene
- CORS is a strict allowlist per environment — the custom domain plus the workers.dev hostname only, no wildcards and no
*.pages.dev. Unapproved origins receive noAccess-Control-Allow-Origin. - Edge rate-limiting fronts the list-heavy admin/operator routes and the unauthenticated portal/login/invitation routes (keyed on the verified token id or client IP, never a browser header), backed by strict app-level limiters on the sensitive login and portal-token paths.
- Cloudflare Turnstile is live bot-protection on the tokenised parent portal mutations and the marketing site’s demo-request form; the secret is a per-environment worker secret, never committed.
- Secrets never live in git.
.dev.vars,.env, generated configs, logs, and exports are all gitignored; production secrets are set viawrangler secretor GitHub Environment secrets. Passwords use versioned salted PBKDF2-SHA-256.
What needs per-environment provisioning (state plainly)
A buyer’s advisor should know that several controls are shipped in code but require a deliberate per-environment provisioning step before they are live in that environment:
- Production secrets. Production is a hollow shell today (no JWT secret, etc.). It is not functionally equivalent to UAT — demonstrate and pilot on UAT.
- Contact encryption — the pepper secret, the read flag, the backfill, and the plaintext null-out, in that order, per environment.
- Web push — VAPID secrets plus real-device proof per environment. The cryptography is implemented and RFC-8291-proven in code; end-to-end on a real device is a separate verification per environment.
- WhatsApp and SMS — per-school provider credentials. Until connected they show a Preview badge and silently demote to email; the system is honest about which channels are Live.
- Branded PDF / SAR rendering — the Cloudflare Browser Rendering binding.
Roadmap and known limitations (no overclaiming)
- No group-scoped owner tenant. “Operator” is Cybertron platform staff who can see every school on the platform. A chain owner cannot today log in scoped to only their own campuses. A group-owner role is Roadmap.
- Logical, not physical, per-campus isolation. The platform is shard-ready, but only
shard_0is live, and cross-school operator/audit reads are bound toshard_0. Isolation between campuses is code-enforced and token-enforced today, not separate physical databases. Physical per-campus sharding is Roadmap. - Audit is trigger-enforced, not hash-chained/WORM (see above).
staff_usererasure cascade is fail-closed pending the final leg.- No consolidated cross-campus academic report yet — only channel-usage and billing aggregates exist at the platform level.
- Polish cracks an advisor will see: the report-builder still has a free-text “Class ID” box, and the admin attendance page is hardcoded English (the rest of the app is trilingual en/af/zu).

Bottom line
MySentinel ships the load-bearing POPIA controls — token-only tenancy, server-side RBAC with passkey step-up, per-school encryption with cryptographic shredding, an append-only audit trail, a durable fail-closed erasure cascade, and enforced retention — and we have been explicit about the four areas that are Roadmap (group-owner scoping, physical sharding, hash-chained audit, branded SAR PDFs) and the controls that need a supervised per-environment switch-on. That separation is the point of this document: nothing here is a hollow claim, and every “verified” item can be re-checked on the running UAT system.