Security Incident & Personal-Information-Breach Response Procedure
Accuracy notice. Sections 2, 3 and 5 were rewritten on 2026-08-04 against the Go implementation. The previous version listed five detection sources as implemented, of which four did not exist, and instructed responders to query
auth_events/query_audit_eventstables and rotate per-tenant ClickHouse credentials — none of which exist. Every source reference now resolves to a real file, enforced byfrontend/src/__tests__/docsSources.test.ts.
Owner: Information Officer, Cognizance Processing (Pty) Ltd Last updated: June 2026 · Version: 1.0 Statutory basis: POPIA s22 (notification of security compromises), s19 (security safeguards), s21 (operator duties).
This procedure is grounded in KeyOne’s actual architecture. It does not invent capabilities the system lacks; where a control is a deployment requirement rather than an implemented feature, it is marked [deploy].
1. Scope
A “security compromise” (POPIA s22) is any event giving reasonable grounds to believe that personal information has been accessed or acquired by an unauthorised person. This covers KeyOne acting as responsible party (our users/employees) and as operator (customer-uploaded data).
2. Detection sources
Checked against the Go implementation on 2026-08-04. The previous version of this table was headed “implemented” and listed five sources, of which four described the retired Python stack and do not exist in KeyOne. An incident response plan whose detection sources are fictional fails at exactly the moment it is needed, so what is actually available is separated from what is not.
Available today:
| Source | Where |
|---|---|
| Authentication events | Login success and failure, refresh-token reuse (distinct from an ordinary rejected token), logout, TOTP enrolment/verification/failure, password-reset requests and invite redemption — all written to audit_log with target_type = 'auth', the client IP, and a server-side failure reason the client never sees (core/internal/api/authaudit.go). Failed logins against addresses with no account are recorded too. |
| Refresh-token reuse | Detected on rotation and revokes the whole token family; detection is sticky and is not rolled back by a later legitimate refresh (core/internal/auth/refresh.go), and now leaves an auth.refresh_reuse row. |
| Action audit trail | audit_log records who did what to which object, under RLS, readable through the ops surface (migration 0014, core/internal/ops/auditlog.go). Scope is decision actions. |
| Application logs | Structured server logs from the single Go binary, via the platform’s log drain. |
| Database-level controls | Postgres row-level security FORCEd on 32 tables, exercised by TestRLS_TenantIsolation (core/internal/store/rls_test.go). Prevents cross-tenant reads; does not itself report attempts. |
Not available (previously listed here as implemented):
| Claimed source | Actual position |
|---|---|
auth_events table with login_failed / refresh_reuse | No such table, and there never was. The events themselves are now recorded — in audit_log under target_type = 'auth', see above. |
query_audit_events data-access trail | No such table. Query execution is not audit-logged. |
| Per-tenant ClickHouse user + isolation verification | KeyOne has no ClickHouse. Isolation is Postgres RLS only. |
| Production fail-closed on default secrets (SEC-K01) | Config validation does not check for default secrets (core/internal/config/validate.go). |
Inbound data-platform alerts (watch_breach / freshness_stale / query_failed) | No such webhook or event path exists in the Go tree. |
What this means for response. An authentication trail now exists, so a credential compromise can be reconstructed: which addresses were attempted, from which IP, whether any succeeded, and whether a refresh token was replayed. Two limits are real and must be stated. Nothing alerts — the rows are only found by someone looking. And the trail starts from the deploy that added it; it says nothing about anything earlier.
3. Response stages
- Detect & record — capture the
audit_logrows for the window (target_type = 'auth'for authentication events, plus any action rows) and the application logs. Where the trail cannot answer something — anything before the trail existed, or any question about which data was read, which is still not recorded — say so explicitly in the incident record rather than letting “no evidence” read as “no access”. - Contain — KeyOne is a single-tenant binary: one deployment serves one
customer, so a credential compromise is bounded by that deployment and
cannot reach another customer’s data. Containment actions: revoke the
affected user’s sessions (refresh-token revocation revokes the whole family),
rotate the relevant secret, and rotate the JWT signing key in
signing_keysif token forgery is suspected. - Assess scope — enumerate what can be established from
audit_logand application logs, and state plainly what cannot be established because it is not recorded (data-access events are still not audited). POPIA permits the time reasonably necessary to determine scope before notifying (s22(2)). - Notify (see §4).
- Remediate & learn — fix root cause, add a regression test, record the incident in
the security register (
SECURITY_REGISTER.md).
4. Notification obligations (POPIA-accurate)
| Recipient | Trigger | Timing | Basis |
|---|---|---|---|
| Information Regulator | Compromise of personal information for which KeyOne is the responsible party | As soon as reasonably possible after discovery, allowing time to determine scope and restore system integrity | POPIA s22(1)–(2) — no fixed hour count |
| Affected data subjects | Same as above, unless their identity cannot be established | As soon as reasonably possible (may be delayed only if law enforcement/Regulator directs) | POPIA s22(1) |
| Affected customers (when KeyOne is the operator of their data) | Compromise affecting Customer Data | Within 72 hours of discovery — a contractual SLA, not the statutory standard | Operator duty (s21) + DPA |
Regulator contact: Information Regulator of South Africa, JD House, 27 Stiemens Street, Braamfontein, Johannesburg · inforeg@inforegulator.org.za · inforegulator.org.za.
Notifications to data subjects must, per s22(5), describe the possible consequences, the measures KeyOne intends/took, recommended protective steps, and (if known) the identity of the unauthorised person.
5. Known hardening items (must close before processing real personal information)
Verified against the Go tree on 2026-08-04. The previous version of this list opened with “TOTP secrets — FIXED”, describing Fernet envelope encryption in a Python file that no longer exists, with a contradicting correction spliced into the middle of the same bullet. Both halves are replaced by the actual position.
- No field-level encryption.
core/internal/store/crypto.goprovides an AES-256-GCM encryptor forsources.credentials_ciphertext, but nothing calls it. TOTP secrets are stored in plaintext in thetotp_secretcolumn. Stored data is protected only by the database volume and by row-level security. AKEYONE_FIELD_ENCRYPTION_KEYnamed in earlier revisions does not exist. - No alerting. The authentication trail is recorded but nothing watches it. A credential-stuffing run produces rows and no notification; someone has to look. This is now the largest gap in this procedure.
- The trail has no retention policy.
audit_loggrows without bound and nothing prunes it. Under POPIA’s retention limits this needs a decision before real personal information is processed. - Encryption at rest (storage volumes) relies on the host platform’s
encrypted volumes — provision encrypted Postgres and object storage in
africa-south1. [deploy]
Two documents cited here in earlier revisions, HONESTY_AUDIT.md and
SECURITY_REGISTER.md, do not exist in this repository. The register referenced
in §3 stage 5 is likewise not a file that exists; recording incidents somewhere
durable remains an organisational commitment, not an implemented artefact.