# Pre-production security and privacy checklist

`SPEC.md §44` requires institutional review before any real student record is
loaded into this system. This file tracks that gate.

**Current status: NOT APPROVED FOR REAL DATA.**

Every item below is outstanding. The prototype runs on generated fictional data
only, and the seed script refuses to run with `NODE_ENV=production`.

---

## What the prototype already implements

These are built and verified by tests. They are necessary, not sufficient.

| Control | Where |
| --- | --- |
| Argon2id password hashing (19 MiB, t=2, p=1, per OWASP) | `packages/db/src/password.ts` |
| Opaque database-backed sessions; only an HMAC of the token is stored | `apps/web/src/server/auth/session.ts` |
| Independent idle and absolute session expiry | same |
| Immediate revocation on logout, password reset and deactivation | same |
| Brute-force lockout, counted per identifier *and* per IP | `apps/web/src/server/auth/login.ts` |
| Login responses indistinguishable between unknown account, wrong password and disabled account — including a decoy hash to equalize timing | same |
| Single-use, expiring, hashed password-reset tokens that revoke all sessions | same |
| RBAC with permission + scope + field projection, enforced server-side | `packages/core/src/auth/` |
| Out-of-scope records return 404, not 403, so existence cannot be probed | `packages/core/src/auth/authorize.ts` |
| Append-only audit log, enforced by database triggers | `packages/db/prisma/schema/migrations/…_integrity_constraints` |
| Immutable status history on every workflow | same |
| PII-redacting logger; audit summaries pass through the same redactor | `packages/core/src/logging/` |
| CSP, HSTS, frame-deny, referrer and permissions policy | `apps/web/src/proxy.ts` |
| CSRF: SameSite=Lax cookies plus an Origin check on every mutation | `apps/web/src/server/api/handler.ts` |
| Upload validation by magic bytes, not by declared type or extension | `apps/web/src/server/adapters/storage.ts` |
| Private storage with short-lived HMAC-signed download links only | same |
| Path-traversal guard on every storage key | same |
| Money as `NUMERIC(14,2)` with database check constraints | `packages/db/prisma/schema/08-finance.prisma` |
| Small-cell suppression on demographic aggregates | `packages/core/src/util/index.ts` |

---

## Outstanding before real data

### Governance

- [ ] Data Protection Officer review and sign-off
- [x] Privacy notice published at `/privacy`, linked from the sign-in page, the
      application consent step and the footer of every portal page. Its retention
      table is read from the institution's own policies rather than written in
      prose, so the notice cannot drift from what the system does.
- [x] Data-subject rights implemented where software can honour them: access and
      portability (`/account/data` → machine-readable export, audited), objection
      (withdrawal of marketing consent), and transparency of access (who read your
      record, by office). Erasure of academic and financial records is refused and
      the notice explains why.
- [ ] Privacy notice reviewed and approved by the Data Protection Officer
- [ ] Legal-basis mapping for each processing purpose (RA 10173 §12, §13)
- [ ] Data inventory / records of processing activities
- [ ] Retention schedule approved, and enforcement authorized — the schedule is
      now reported at `/admin/retention` with counts of records already past their
      period; nothing is deleted automatically and `autoDeleteEnabled` stays false
      until an institution names an authority for it
- [ ] Data-sharing agreements for any processor
- [ ] Role matrix reviewed and approved by each office head
- [ ] Staff privacy and security training completed

### Technical

- [ ] **MFA enforced for privileged roles.** Schema exists
      (`User.mfaEnrolledAt`, `MfaFactor`); TOTP verification is not implemented.
- [ ] TLS terminated at the proxy; `SESSION_COOKIE_SECURE=true`
- [ ] Encryption at rest configured on the database and object store
- [ ] Secrets moved to a secret manager, not `.env`
- [ ] Redis-backed rate limiting (the in-memory limiter is per-process and does
      not hold behind a load balancer)
- [ ] Malware scanning wired to a real scanner — the development adapter
      deliberately records `UNSCANNED` rather than a false `CLEAN`
- [ ] Anti-bot verification enabled on public forms
- [ ] Audit logs shipped off-box, so a database superuser cannot erase them
- [ ] Field-level encryption reviewed for any sensitive field the DPIA identifies

### Assurance

- [ ] Backup schedule configured; **restore tested end to end**
- [ ] RPO and RTO agreed and documented
- [ ] Dependency vulnerability scanning in CI
- [ ] Vulnerability scan of the deployed environment
- [ ] Penetration test by an independent party
- [ ] Centralized logging with alerting on security events
- [ ] Uptime and error monitoring
- [ ] Incident response procedure
- [ ] Breach notification procedure (NPC 72-hour requirement)
- [ ] Access revocation procedure tied to HR offboarding
- [ ] Export and download controls reviewed
- [ ] Workstation and physical security controls reviewed

---

## Residual risks accepted in the prototype

Recorded so they are not mistaken for oversights.

**A database superuser can drop the audit triggers.** Application code cannot
modify `audit_log`, and neither can SQL injection, but a superuser can remove
the protection. Mitigation is off-box log shipping, which is an infrastructure
task.

**The in-memory rate limiter is per-process.** Adequate for a single-instance
prototype; not adequate behind a load balancer.

**Client IP is trusted from `x-forwarded-for`.** Only correct behind a proxy
that sets it. It is used for rate limiting and security events, never for
authorization.

**No automatic retention enforcement.** Policies are recorded and reported, but
nothing deletes. Purging an academic record requires institutional authority,
not a background job.

**Generated documents are unofficial.** Every COR and assessment carries a
visible watermark, and `documents.are_official` is a protected setting that must
stay disabled until an approved template and signing process exist.

---

## Health data

`SPEC.md §13.A.7` requires medical and chest X-ray requirements to be tracked.
The system stores **submission status and file metadata only** — submitted,
accepted, rejected. There is deliberately no field anywhere in the schema for a
diagnosis, finding, or clinical result.

Processing health data beyond "requirement satisfied" is sensitive personal
information under RA 10173 §13 and would require a separate Data Privacy Impact
Assessment before any schema change.

---

## Review record — SPEC.md §44

Reviewed against the running application on 2026-08-26, at the close of
Phases 2–6. Each item was checked by attempting the thing it forbids, not by
reading the code that is supposed to prevent it.

### Authorization

| Check | Result |
| --- | --- |
| Every mutating endpoint authorizes before any record lookup | Pass — verified across all services; an out-of-scope id returns 404, never 403 |
| Hiding a control is never the only protection | Pass — 45 Playwright checks call the API directly after confirming the screen refuses |
| A student can reach only their own record | Pass, **after a fix** — see below |
| A cashier cannot undo their own payment | Pass — `payment.void` is withheld from CASHIER by design |
| A faculty member cannot publish their own grades | Pass — `grade.publish` is a Registrar permission |
| A Dean sees only their own college | Pass — another college's interview reports 404 |

**One real failure, fixed.** `clearance.read` is held by the STUDENT role for
the holder's *own* record. The clearance guard treated possession of it as
authority over anybody's, so one student could read another's clearance.
Reaching someone else's now requires `clearance.manage`. A second, related
weakness was hardened at the same time: scope checks on student-owned records
named only the college, and a student carries no college scope — so the check
passed unchallenged and the permission guard above it was doing all the work.
Every such check now names the student as well (ASSUMPTIONS.md G8, G9).

### Data protection

| Check | Result |
| --- | --- |
| Passwords, tokens and session secrets never logged | Pass — the logger redacts by key, tested |
| Audit entries carry counts and identifiers, not the personal data itself | Pass — asserted by test for imports, exports and profile-update requests |
| Bulk export is permission-gated and audited | Pass — `student.export` required, entry written before rows are returned |
| Exported CSV cannot execute in a spreadsheet | Pass — leading `=`, `+`, `-`, `@` in *text* fields are neutralized; numbers are left usable |
| Demographic aggregates suppress small cells | Pass — suppression happens in the service, so no true count reaches the page props |
| Religion and gender identity excluded from all aggregates | Pass — no analytics query references them |
| Health data limited to "requirement satisfied" | Pass — no clinical field exists in the schema |

### Integrity

| Check | Result |
| --- | --- |
| The audit log cannot be altered by the application | Pass — database rules block UPDATE and DELETE |
| Money is integer centavos end to end | Pass — no float touches an amount; the API takes centavos |
| A payment is never deleted | Pass — void and reversal both leave the original row |
| Balances are derived, never stored | Pass — recomputed from the ledger on every read |
| A retried payment does not charge twice | Pass — idempotency key, tested with a real duplicate request |
| Grades after submission change only through a request | Pass — the sheet refuses direct edits with 409 |
| Certificates render from a snapshot, not live data | Pass — including a migration path for pre-versioning records |

### Still required before production

These are unchanged from the list above and are **not** satisfied by this
review:

1. **MFA for privileged roles.** Schema exists; TOTP verification does not.
2. **A shared rate-limit store.** The limiter is in-memory and per-process, so
   it does not hold across replicas.
3. **Malware scanning.** Uploads are recorded `UNSCANNED`; the adapter never
   reports a false clean.
4. **Off-box audit shipping.** A privileged DBA can still reach the table.
5. **An external dependency audit.** Not run.
6. **A Data Privacy Impact Assessment**, and the SPEC.md §44 sign-off by the
   institution — this document records the engineering review only.

**This system has not been reviewed for production use with real student data.**
