This product is sold to security professionals — CISOs, security engineers, compliance officers — who will scrutinise it before trusting it with their organisation’s compliance data. Three principles guide every design decision:
1. Fail closed. If a security control cannot run — Vault unreachable, audit chain broken, backup provider down, RLS misconfigured — the system refuses to operate the affected surface and surfaces the failure explicitly. It does not silently degrade, skip the control, or log a warning and continue. Compliance software that silently degrades is worse than no software.
2. The database is the security boundary. Tenant isolation is enforced by Postgres Row-Level Security at the database layer, not by application-layer filters. A bug in application code that forgets to filter by tenant is harmless: the database returns zero rows because the RLS policy blocks the query. Application-layer filtering is defence-in-depth, not the primary control.
3. Tamper-evidence over tamper-prevention. Any operator with sufficient database access can in theory modify logs and backups. The architecture does not assume this is impossible — it makes any tampering detectable and traceable through the hash chain, anchor timestamps, and the verification endpoint. An organisation can verify the integrity of their own audit log at any time without trusting the Operator.
Every tenant-scoped table has the following applied at migration time:
ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <table> FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON <table>
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
FORCE ROW LEVEL SECURITY is the critical line: it applies the policy even to the table owner. The application runtime Postgres role (app) is not a superuser and is not the table owner (tables are owned by migrator). The app role is constrained by RLS on every query — it cannot bypass RLS even if an application bug constructs a query that omits the tenant filter.
The GUC pair (app.current_tenant_id, app.current_org_unit_id) is set within a SET LOCAL block at the start of each request’s database transaction:
# apps/tenants/context.py
with connection.cursor() as cur:
cur.execute("SET LOCAL ROLE app")
cur.execute("SET LOCAL app.current_tenant_id = %s", [str(tenant.id)])
cur.execute("SET LOCAL app.current_org_unit_id = %s", [str(org_unit.id)])
SET LOCAL means the GUC values expire at transaction end. pgbouncer runs in transaction pooling mode — connections are returned to the pool at transaction end, ensuring no GUC value leaks into a subsequent request’s transaction.
| Role | Privileges | Used by |
|---|---|---|
migrator | DDL; owns all tables; BYPASSRLS | Django migrations only — never used at runtime |
app | SELECT, INSERT, UPDATE, DELETE on business tables; constrained by RLS | Application runtime (Django ORM, DRF views) |
audit_writer | INSERT only on audit_log; no UPDATE, no DELETE | Audit service (writes only) |
superadmin | BYPASSRLS; SELECT on all tables | Operator super-admin panel — every action audit-logged |
The app role cannot read, update, or delete audit log rows written by audit_writer. The superadmin role is used only by the Operator’s cross-tenant administrative panel, and every action taken under superadmin is itself audit-logged.
A second isolation dimension — org_unit_id — scopes access for site-bound roles in multi-institution deployments (e.g. a JST tenant with nested school units). An ITSO assigned to School A sees only School A’s ControlTasks, incidents, and samoidentyfikacja records. This scoping is enforced by RLS at (tenant_id, org_unit_id), not application code.
Region is set at Tenant creation and is an immutable attribute. Customer business data does not cross regions. The EU region (Frankfurt) and the PL region (Warsaw — when active) are physically and logically separate deployments. Control-plane metadata (tenant routing, subscription records) is stored in the EU region and contains no customer business data.
direct, indirect, or none)..well-known/openid-configuration), client ID, client secret (envelope-encrypted in Vault).iss, aud, exp, nbf, nonce (bound to PKCE code); replay window 60 seconds.InResponseTo validated to prevent IdP-initiated replay.Secure; HttpOnly; SameSite=Lax.exceptions.exception.approve). Permission checks are performed at the API view level on every request.tenant_admin (full administrative access), submitter (create and submit exceptions/risks/incidents), approver (approve or reject exceptions/risks), auditor (read-only access to all records plus audit chain verification), itso (institution-scoped equivalent of auditor in NIS2 Schools module).The Postgres audit_writer role has INSERT privilege on audit_log and no UPDATE or DELETE. The CI/CD pipeline verifies this privilege set on every deployment by running:
SELECT has_table_privilege('audit_writer', 'audit_log', 'UPDATE');
-- must return false
SELECT has_table_privilege('audit_writer', 'audit_log', 'DELETE');
-- must return false
If either check fails, the deployment is blocked.
Each row in audit_log contains a row_hash field computed as:
row_hash_n = sha256( prev_hash_(n-1) || canonical_json(row_n) )
prev_hash_0 = 32 zero bytes (for the first row of each Tenant’s chain)canonical_json = RFC 8785 JSON Canonicalization Scheme (JCS) applied to an explicit, versioned field listschema_version) is stored on each row, so future schema changes do not retroactively invalidate old hashesThe hash is computed by the application before the INSERT and stored alongside the row. The audit_writer role inserts both the row data and the hash in a single atomic operation.
GET /api/audit/verify walks the full hash chain for the requesting Tenant and returns:
{
"ok": true,
"broken_at": null,
"total_rows": 47291,
"first_row_at": "2026-01-15T09:00:00Z",
"last_row_at": "2026-05-15T14:23:11Z",
"last_anchor_at": "2026-05-15T14:00:00Z",
"schema_versions_seen": [1]
}
If the chain is broken, ok is false and broken_at contains the ID and timestamp of the first broken row. This endpoint is available to all Users with the auditor role and to the Tenant Admin.
A Celery beat job runs every hour and records the current row_hash of the latest audit log row per Tenant into an audit_anchor table. This table is written by the superadmin role (not audit_writer) and is read-only from the application’s perspective. Anchors provide a lightweight checkpoint: to verify the chain from the last anchor point, you only need to walk rows since the last anchor, not the full chain.
On application startup, the system: (1) reads the latest row_hash from audit_log for each Tenant; (2) reads the latest stored anchor from audit_anchor for each Tenant; (3) compares them. A mismatch means rows were added, modified, replaced, or the database was restored to a previous state since the last anchor. On mismatch, a chain_discontinuity anchor event is written and the Operator’s on-call is alerted. This is a deliberate design: any restore or tampering is detectable.
On a verified GDPR Art. 17 erasure request, the actor-identity fields in audit log rows (actor_email, actor_ip) are replaced with stable tombstones (actor_tombstone_<uuid>). The hash chain is recomputed over the tombstone value, preserving chain integrity. The event record (action, timestamp, target) is retained. Legal basis: legitimate interest in audit integrity (GDPR Art. 6(1)(f)).
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload. Cipher suites: Cloudflare default (TLS 1.3 only ciphers; no RC4, no 3DES, no CBC-mode ciphers).| Header | Value |
|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
Content-Security-Policy | Nonce-based; default-src 'self'; script sources require nonce or hash; no unsafe-inline or unsafe-eval |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Cross-Origin-Opener-Policy | same-origin |
Cross-Origin-Resource-Policy | same-origin |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() (deny by default) |
gitleaks runs in CI on every commit and as a pre-commit hook on developer machines. Any detected secret in Git history triggers an immediate incident.cloudflared runs as a service on the VPS and maintains an outbound-only encrypted mTLS connection to Cloudflare’s edge. Cloudflare proxies inbound traffic through this tunnel to the application.GET /api/audit/verify, generate an audit-pack export), tear down, record the drill outcome in the production audit log.requirements.txt with SHA-256 hashes. All npm dependencies pinned in package-lock.json. Docker base images referenced by digest (no floating :latest tags in production).pip-audit (Python CVEs), npm audit (Node CVEs), Trivy (container image OS and library CVEs), Semgrep (static analysis for security anti-patterns) — all run in CI on every commit and on a nightly schedule.gitleaks runs in CI and as a pre-commit hook.| Severity | CVSS score | Patch SLA |
|---|---|---|
| Critical | ≥ 9.0 | 72 hours from confirmed vulnerability |
| High | 7.0–8.9 | 14 days |
| Medium | 4.0–6.9 | 30 days |
| Low | < 4.0 | Next minor release |
security@exceptao.com. .well-known/security.txt published at exceptao.com, parakscol.pl, and cyberzgodnosc.edu.pl.The Operator maintains a documented Incident Response (IR) runbook covering: detection and initial triage, containment, eradication, recovery, post-mortem, and regulatory notification.
| Jurisdiction / law | Supervisory authority notification | Affected individual notification |
|---|---|---|
| GDPR / EU | ≤ 72 hours from awareness (Art. 33) | Without undue delay for high-risk breaches (Art. 34) |
| UK GDPR | ≤ 72 hours from awareness | Without undue delay for high-risk breaches |
| Poland (KSC / NIS2) | 24h early warning; 72h incident notification; 30d final report (Art. 23 NIS2) | Per applicable law |
| Australia (Privacy Act 1988, NDB Scheme) | As soon as practicable | As soon as practicable |
| Canada (PIPEDA) | As soon as feasible | As soon as feasible |
The Operator’s obligation to notify the Controller (Tenant) of a personal data breach is ≤ 72 hours from awareness, per the DPA at /legal/dpa §8. This enables the Tenant to meet its own supervisory authority notification deadline.
We believe in not overclaiming security certifications or capabilities. The following are planned but not yet delivered:
| Item | Status | Plan |
|---|---|---|
| SOC 2 Type II certification | Not yet — audit engagement not started | Architecture designed for SOC 2 readiness from day one. All control evidence (access reviews, change management, backups, audit logs) is auto-generated. Audit engagement planned post-GA. |
| ISO 27001 certification | Not yet | Planned after SOC 2 Type II. |
| FedRAMP | Not applicable in v1 | No US federal customers targeted in v1. |
| RFC 3161 external timestamp authority (TSA) for audit anchors | Architecture supports it; not yet wired to a production TSA | Phase 2. Would provide externally verifiable timestamps for audit anchors that are not under the Operator’s control. |
| Recurring external penetration test | First test planned pre-first-paying-customer | Annual cadence once revenue supports the cost of a quality engagement. |
| Bug bounty program | Not yet | Phase 2/3 — Intigriti or HackerOne. |
| Warm-standby DR (RTO ≤ 30 min) | Not yet — current RTO ≤ 4 hours | Phase 3 — requires a hot-standby database replica and automated failover. |
| LUKS remote attestation / automated unsealing | [TO BE COMPLETED: document production LUKS unlock mechanism] | Currently requires manual key entry on VPS reboot. |
| Purpose | Contact |
|---|---|
| Security disclosures | security@exceptao.com |
| Privacy / DPA | privacy@exceptao.com |
.well-known/security.txt | Published at each brand domain |
| PGP public key | [TO BE COMPLETED: publish at .well-known/security.txt] |