Security
Isolation you can inspect.
Not “enterprise-grade”. For every control in this LMS: the mechanism, the file it lives in, and the list of what we have not built.

The ten-row summary
For the reviewer with a checklist. Everything below expands a row.
| Control | How | Where |
|---|---|---|
| Tenant isolation | PostgreSQL row-level security, ENABLE and FORCE, on 114 tables | apps/api/prisma/rls.ts |
| Database privilege | The application role is not the owner, not a superuser, holds no BYPASSRLS | apps/api/prisma/rls.ts |
| Authorization | Deny by default: 272 permission keys, 264 enforced on a route, coverage checked in CI | packages/authz, pnpm authz:check |
| Authentication | Argon2id with lockout, 15-minute access tokens, rotating refresh with family revocation | apps/api/src/modules/auth |
| Multi-factor | TOTP with recovery codes; device list and revoke | apps/api/src/modules/auth |
| Token handling | httpOnly session cookie; the JWT is attached server-side and never reaches client JavaScript | apps/web BFF |
| Audit | Every mutation, with a field-level diff, hash-chained to the entry before it | apps/api/src/modules/audit |
| Data subject requests | Export and erasure; erasure tombstones rather than rewriting the chain | apps/api/src/modules/audit |
| Secrets | The production environment schema throws on defaults and placeholders | apps/api/src/config |
| Accessibility | WCAG 2.2 AA, automated checks failing the build at 360px among other widths | Storybook + Playwright |
Tenant isolation is a database policy
Every tenant-owned table carries a tenant_id and a tenant_isolation policy applied with both ENABLE and FORCE. Without FORCE, the table owner bypasses every policy — the most common way row-level security is deployed and does nothing.
FORCE is not enough alone: a superuser, or any role with BYPASSRLS, ignores policies unconditionally. The application connects as none of those, the owner role is reserved for migrations on a separate connection string, and the check verifies the runtime role too.
The policy set is derived from the database cataloguerather than hand-written per table, so every table with a tenant_idcolumn is covered automatically and a gap fails CI. A policy that must be remembered on each new table is eventually forgotten, invisibly, until it leaks.
114 tables are covered. Thirteen are exempt, and the list is published rather than denied.
The thirteen exemptions
Two groups, two reasons.
- No tenant id at all — users, credentials, identities, permissions, tenants. A user is a global principal who may belong to several institutes; the membership is what is scoped, and it sits in the second group.
- The identity plane — sessions, permission versions, memberships, invitations, tenant domains. These carry a tenant id but are read in order to determine the tenant, before any binding exists. Refreshing a token means looking a session up by id to discover its institute, which a policy requiring the institute first makes unresolvable. Every access is keyed by an unguessable id.
Roles, scopes and overrides
A role is a named bundle of keys plus a scope. Eleven templates ship, and each institute gets editable copies. Nothing in the code branches on a role's name — a guard asks whether somebody holds a key, never what they are called.
Scopes: global, organisation, institute, branch, department, course, batch or a single record. Learners and parents hold record scope, which is why a parent sees their own children and nothing else.
On top: per-user overrides with an expiry, and out-of-office delegation. Denials win.
app.learn.algoryq.com/admin/roles
- Super AdminGLOBALEverything, and every action audited
- Institute AdminTENANTOne institute, end to end
- Academic HeadTENANTCurriculum quality and course approvals
- TeacherTENANTTheir own courses and batches
- Content AuthorTENANTWrites and submits, cannot publish
- Admissions CounsellorTENANTEnquiries through to enrolment
- Finance OfficerTENANTInvoices, payments, pricing
- HR ManagerTENANTStaff records, leave, the register
- Placement OfficerTENANTDrives, openings, panels
- StudentRECORDTheir own records only
- ParentRECORDTheir own children, read-only
Identity
- Passwords hashed with Argon2id, with lockout after repeated failures.
- Fifteen-minute access tokens and a rotating refresh token; revoking one revokes its whole family, so a stolen token cannot outlive its detection.
- TOTP multi-factor with recovery codes, and a revocable device list.
- Sign-in with Google, Microsoft and GitHub.
- People are added by invitation only. No route lets an administrator set somebody else's password.
- Impersonation for support, with dual audit attribution: the log records who acted and on whose behalf.
Not built: SAML, OIDC, SCIM, phone OTP.
The audit log is a hash chain
Every mutation emits a domain event that lands in the audit log with a field-level diff and a hash of the entry before it. Tampering breaks the chain from that point on, detectable without trusting the application that wrote it.
Retention policies, legal holds and data-subject requests are built. A hold beats retention; retention beats erasure. Erasure tombstones the record rather than rewriting the chain that proves it happened — the part most implementations get backwards.
Something you can check right now
Certificate verification is public and unauthenticated by design: whoever holds the code is usually an employer, not a user. The page renders exactly what the API returns — what was certified, to whom, when, whether it still stands — and nothing else.
Unknown and revoked both render, in different words. “Nothing found” and “withdrawn” mean very different things to the person checking.
app.learn.algoryq.com/verify/AK-7F3C-90D2
Certificate verification
This certificate is valid.
- Learner
- Sana Khan
- Course
- Data Structures & Algorithms
- Issued by
- Sunrise Academy
- Issued on
- 12 June 2026
Code AK-7F3C-90D2. No account needed.
Scale, and one honest limit
The API is horizontally scalable: the rate limiter, permission-grant cache and tenant host resolution sit in shared cache rather than per-process memory, and production refuses the in-memory driver. Rate limiting runs before the guards, and fails open on a cache outage.
The background worker is deliberately one process. Its jobs claim no rows, so a second copy would double-send email. That is a real throughput limit on a very large deployment, and it is on our list.
The exit, documented before the entrance
Storage, mail, search, AI and cache are ports with multiple drivers, selected by an environment variable. No cloud-provider SDK is imported in feature code. Moving between them is a connection string, not a project.
docker compose up boots the whole platform on a laptop with no cloud account. Everything exports. The database is ordinary PostgreSQL.
Not a developer convenience: it is what makes the rest of this page worth trusting. A vendor who has made leaving easy has fewer reasons to make staying compulsory.
What we do not have
This section is why the rest of the page is believable.
- No SOC 2 certificate. The policies exist and the audit log produces the evidence automatically, but the audit has not happened — and we will not put a badge up before it is earned.
- No published penetration-test report.
- No SAML, OIDC or SCIM.
- No formal uptime SLA — we have no telemetry to hold ourselves to one, and a status page that cannot go red is theatre.
- No multi-region data residency. Designed and deliberately not built: a region field that moves no bytes reads as a guarantee and is false.
- No customers yet, so no operational history. This is our first deployment.
Found something?
Email security@algoryq.com. Acknowledged within two working days, updates while we fix it, and credit if you want it. We will not threaten you.
Read the rest of it, then try it.
Build status, sub-processors and the accessibility statement are one click away. Or open the sandbox and look at the software instead.
Or open the sandbox (opens in a new tab) and click around first. No form.