Security and hosting, stated honestly
Milata runs on one server that its operator owns and can touch. This page says what protects your data on it, what the numbers really are, and what is not in place. Nothing here is a marketing claim; each line names the file or record behind it.
Read this first
Beta hosting: Milata runs on a single self-hosted server under the operator’s physical control. If that machine fails, service stops until it is repaired or replaced from backup — recovery point up to 24 hours, recovery time one business day, whole-database restore. No uptime figure is promised. (M5 spec §3, §11)
Compliance is the tenant’s: Milata tracks the LiveScan clearances, insurance certificates and vendor agreements a program keeps, and shows what is expiring. Tracking a document is not being compliant, and the platform does not make a tenant compliant. (M1c documents vault; M5 spec §11)
There is one operator. Support is by email during business hours; there is no on-call rota and no named data-protection officer. (M5 spec §3)
Cards and money
- Card data is never touched by Milata. A parent pays on Stripe’s hosted Checkout page; Milata receives the amount, the outcome and the last four digits, which is the SAQ-A posture. — app/src/milata/services/stripe_payments.py; privacy policy 1.2
- Every money mutation — an invoice sent, a payment recorded, a refund issued — writes an append-only audit row. The application role holds no UPDATE or DELETE on that table. — billing_events; migrations 0002_narrow_grants, 0004_billing
Tenant and family isolation
- Tenant isolation is enforced by the database, not only by the application: every tenant table carries forced row-level security keyed on the request’s tenant, and family-level isolation sits on top of it for the parent portal. — migrations 0013_portal (tenant_isolation, FORCE ROW LEVEL SECURITY), 0014_family_rls (RESTRICTIVE family_isolation)
- The limit, in the same breath: the database enforces the scope the application asserts. Every tenant is served by one database role, so the tenant setting is a cooperative session variable, not a credential — a compromised application process, or a SQL injection, reaches every tenant. There are no network policies behind it; Postgres relies on cluster-internal addressing and role passwords. — infra/k8s/base/db-init/00-roles.sh (one milata_app role); infra/k8s/base/network-policies.yaml (removed 2026-08-30, ruling B10)
- A request whose host name matches no tenant is refused with a 404 on the portal, admin and sign-in paths — an unknown host is indistinguishable from an unknown page. — app/src/milata/tenancy.py (D-M5-11)
Transport and edge
- Traffic reaches the server only through an outbound Cloudflare Tunnel; no inbound port is open on the host. TLS terminates at Cloudflare’s edge. — infra/k8s/base/cloudflared.yaml
- Signed-in pages are never cached at the edge: the cache rule excludes the portal, admin and sign-in paths and any request carrying a session cookie, and the release smoke test probes exactly that. — infra/scripts/cloudflare-zone.sh; infra/scripts/smoke.sh
- Bot protection on public forms is Cloudflare Turnstile plus one rate-limit rule — the Free plan allows one. Beyond that there is no DDoS protection other than what Cloudflare’s free tier provides by default. — infra/scripts/cloudflare-zone.sh (item 4); app/src/milata/routers/forms.py
Accounts and sign-in
- Sign-in is Keycloak (OpenID Connect) with server-side sessions; no access token is stored in the browser. Sessions are revoked by Keycloak’s back-channel logout across every tenant. — app/src/milata/services/auth.py; app/src/milata/routers/auth.py
- Multi-factor authentication is enforced for accounts holding the platform-operator role (platform_admin) and the platform-staff role; tenant-owner MFA is not enforced in M5. That sentence is precise on purpose: the enforcing flow is keyed on those two realm roles, so it is a platform control, not a control over a tenant’s own staff. — infra/keycloak/realm/realm-milata.json (condUserRole on platform_admin / platform_staff)
Backups and restore — the honest numbers
- A nightly job at 03:00 UTC dumps the application and identity databases, encrypts each with AES-256-CBC (PBKDF2-derived key) and keeps 14 days in the cluster; every set is also copied off the host to the operator’s workstation and from there to an external disk. — infra/k8s/base/postgres-backup.yaml (schedule, openssl enc -aes-256-cbc -pbkdf2, find -mtime +14); README.md § Backups and restore
- Restore drills have actually been performed, with dates: 2026-08-30 (application database) and 2026-09-03 (application and identity databases together), each restored into a throwaway database and checked by row counts and by recomputing file checksums in the database. — README.md § Restore drill log; infra/scripts/bytes-check.sql
- Recovery point: up to 24 hours. Backups are nightly and there is no continuous write-ahead-log archiving, so a failure just before the 03:00 UTC run can lose a day. Recovery time objective: one business day, on the same or a replacement single server. — infra/k8s/base/postgres-backup.yaml; README.md § Rebuild the cluster from nothing
- A restore is whole-database. There is no per-tenant restore and no way to roll one tenant back to an earlier moment; a restore returns every tenant to the same backup. — infra/k8s/base/postgres-backup.yaml (pg_dump -Fc of the whole database)
- “Encrypted backups” should not be read as “recoverable backups” until the passphrase-escrow drill is on the log above: the ciphertext is useless without the passphrase, and proving that a copy of it survives the operator is a separate exercise. — infra/secrets/ (untracked); README.md § Backups and restore
- An external dead-man’s switch emails the operator when a night is missed, and external uptime probes watch the public pages. — infra/k8s/base/postgres-backup.yaml (healthchecks.io ping); Makefile uptime-monitor
What we hold, and for how long
- Data minimisation: no birthdates and no health fields are collected. A student is a first name and a grade band; a guardian is a name and an email address. — app/src/milata/models/__init__.py (students, families); privacy policy 1.2
- Retention follows the privacy policy, including its carve-out for records a charter school’s audit can require us to keep for five to seven years. — web/src/content/policies/privacy.en.mdx
- Export and deletion: a family’s data is deleted on written request; the self-serve export and deletion buttons are not built. A request is handled by the operator with a command that leaves the consent record itself in place, deliberately. — docs/runbooks/privacy.md; app/src/milata/cli.py delete-family
Leaving, and going wrong
- A whole workspace can be exported and then deleted on written request. The export is one file holding every row that workspace owns; its table list is read from the database catalogue when it runs rather than from a list someone maintains, so a table added later is in it, and a test asserts on the file’s bytes that a second workspace’s rows never appear. — app/src/milata/services/tenant_data.py; app/tests/test_tenant_data.py; docs/runbooks/tenants.md §8
- The deletion removes every row carrying the workspace’s identifier, the workspace record, and the sign-in accounts whose only membership was that workspace — and is verified in the same transaction, rolled back if one row survives. Accounts are disabled in Keycloak, not erased from it, so an address remains there until removed by hand. Deleted rows stay in the encrypted backups for up to 14 days. — app/src/milata/services/tenant_data.py delete_tenant/residue; docs/runbooks/tenants.md §9
- A breach affecting a workspace is notified to that workspace’s owner by email within 72 hours of Milata becoming aware, with the categories and approximate numbers read out of the database rather than estimated. What to collect before a pod restart destroys it, the queries that produce those numbers, and the order of notification are written down in advance. — docs/runbooks/incident.md; DPA §6; web/tests/unit/dpa-promises.test.ts
Sub-processors
Four, and only four. The list in the privacy policy is the one parents accept; this is the same list.
- Cloudflare — DNS, the tunnel, TLS at the edge, Turnstile, cookie-less analytics. — privacy policy 1.2
- Resend — transactional email, from one verified domain for every tenant. — privacy policy 1.2; D-M5-14
- Stripe — card payments on Stripe’s hosted page; card details never reach Milata. — privacy policy 1.2 (added 2026-09-05)
- The operator’s own hardware — a single server under the operator’s physical control, where every database lives. — infra/k8s (k3d on one host)
For program operators
A tenant workspace is provisioned by hand during the beta (request one). The Data Processing Addendum is the contract that goes with it; it repeats the limits on this page rather than promising past them.