Compliance Controls (NIS2 / ISO 27001)
The security events that NIS2 and ISO 27001 Annex A ask you to record, whether the Manager records each one, and the evidence (a named, runnable query or an export) that proves each control operates. Use it when you answer an auditor, or when you need to know whether a specific event leaves a trace.
A control marked implemented names the event it records and the surface that records it; a control marked gap or partial says what is missing.
Related pages:
- Access Model: the authorization model these controls audit.
- Session Policy & Logout: session lifecycle and stored credentials.
Audit substrate
Two entities carry all audit evidence; there is no parallel compliance store:
| Entity | Scope | Written on |
|---|---|---|
AuditLog | Operator and customer domain events (auth, membership, credential, config, export) | Every accepted GraphQL mutation listed below, the REST auth and credential routes, and the scheduled sweeps |
ESBAuditLog | Per-request ESB proxying (method, path, status, redacted headers and bodies) | Every proxied /esb/* request, per endpoint logLevel |
AuditLog.action is a fixed enum:
create | update | delete | view | export | import | login | logout |
login_failed | rotate | revoke | expire | otherEvery row also carries actor_type (user / machine_user / system) so machine-user and cron-originated events are attributable without a User edge, and the two hash-chain columns prev_hash / row_hash (see log integrity).
Redaction is not a property of any individual writer. It is enforced once, at the write seam, before anything is read for the hash, so redaction, prev_hash/row_hash and immutability apply to every row regardless of which mutation or sweep wrote it.
Logging events (framework requirement to coverage)
ISO 27001 A.8.15 (logging) and A.8.16 (monitoring), and the NIS2 Art. 21 technical measures, require the events below to be recorded. Each row states whether the Manager records it and what the row contains.
| # | Security event | Framework | action | Coverage | What is recorded (surface) |
|---|---|---|---|---|---|
| L1 | Authentication success (browser / OIDC) | A.8.15 | login | implemented | One row per validated browser identity, with IP, user agent and application (proxy validation callback) |
| L2 | Authentication failure | A.8.15, A.8.16 | login_failed | partial | Browser path: a validated user with no access to the application, or a caller that hit the validation lockout. PAT and machine-credential failures are not written to AuditLog; they are rate-limited and locked out instead (metrics pfy_auth_credential_lockouts_total for the proxy validation path, pfy_auth_machine_auth_lockouts_total for /api/machine/* and /esb/*) |
| L3 | Logout / session end | A.8.15 | logout | gap | Logout happens in the proxy and Pocket ID; the Manager never sees it. Nothing emits logout |
| L4 | Credential created | A.8.15, A.5.17 | create | implemented | PAT create (GraphQL), the CLI device-flow mint (REST), createMachineUserWithCredentials (GraphQL; records no token, hash or prefix, and a failed audit fails the mint) and backend registration (POST /api/machine/register-backend) |
| L5 | Credential rotated | A.8.15, A.8.24 | rotate | partial | rotateMachineUserToken writes rotate with a project edge and key_rotated in the metadata, so a machine-user rotation is askable by action from the project audit page as well as the export. Backend re-registration re-mints the callback token without a rotate row: it appears only as the create row carrying re_register: true. The max-age policy (credential_rotation_max_age, default 90 days) is enforced by reporting, not by rotating: the sweep writes one other row per credential per lapse, actor_type=system with metadata.kind=credential_staleness, and raises pfy_executor_stale_credentials{kind}. The rotation itself is an operator act |
| L6 | Credential revoked | A.8.15, A.5.17 | revoke | partial | PAT revoke and revoke-all are written as update; PAT delete and machine-user delete as delete. Nothing emits the revoke enum value, so an evidence query keyed on it returns an empty set rather than an error |
| L7 | Credential expired | A.8.15, A.5.17 | expire | implemented | The scheduled PAT sweep marks lapsed PATs revoked and writes one expire row each; the request path already denies expired tokens |
| L8 | Membership / grant created | A.5.18, A.8.2 | create | implemented | Tenants: invite acceptance (REST), addUserToTenant, invite create. Projects: addUserToProject. Applications: addUserToApplication and the join-policy auto-provisioning path at browser login |
| L9 | Membership / grant revoked | A.5.18, A.8.2 | delete | implemented | removeUserFromTenant, invite revoke, removeUserFromProject, removeUserFromApplication, and the generated join-row deletes deleteUser2Project, deleteUser2Application, deleteUser2Tenant |
| L10 | Role / privilege change | A.8.2 | update | implemented | System role (setUserSystemRole), tenant member role (updateUserTenantRole), tenant role definitions, project join policy, project role (updateUserProjectRole), application role (updateUserApplicationRole), and the generated updateUser2Project / updateUser2Application / updateUser2Tenant. Each carries the before/after role in changes |
| L11 | Data export | A.8.15, incident evidence | export | implemented | GET /api/audit/export and GET /api/access/review each write a row on use |
| L12 | Admin configuration change | A.8.9, A.8.15 | create / update / delete | partial | Recorded: deployment specs, services, data dependencies, dedicated-tenant topology, comms configuration, proxy-config pushes, project / tenant / application lifecycle, environments, endpoints and endpoint access grants, triggers, the Settings/BackendSettings/FrontendSettings cascade, Tenant2Application writes, pilet package and rollout changes (GraphQL and the REST upload and delete), and language packs (createLanguagePack, updateLanguagePack, updateLanguagePackContent, deleteLanguagePack, recording the pack's name, display name and entry count and never the translated strings). Not recorded: process configuration, which the Manager reads from the environment and config file at startup and never sees change |
| L13 | Access review sign-off | A.5.18, A.8.2 | other | implemented | POST /api/access/review/attest writes an AccessReview row and an audit row |
| L14 | Audit retention prune | A.8.15 (log integrity) | delete (actor_type=system) | implemented | The scheduled retention sweep; the prune record carries the count, windows and high-water hash |
| L15 | Chain anchor / verification | A.8.15 (log integrity) | other (actor_type=system) | implemented | The scheduled anchor sweep; optional external witness (see log integrity) |
Authentication, the credential lifecycle, membership and role changes at all three scopes, entity CRUD, the configuration cascade, language packs, pilets, exports, retention and chain integrity are recorded. Three gaps remain, of different kinds:
revokeis never emitted (L6). Credential revocations exist, but underupdateordelete. Anything keyed on therevokeenum value returns an empty set rather than an error.logoutis never emitted (L3), because the Manager does not see it.- A backend callback token rotates without a
rotaterow (L5). The re-registration that mints it is written ascreatewithre_register: true, socredential_rotations_in_periodcounts machine-user rotations only.
Coverage is therefore not complete, and this page states no count of audited resolvers. Check the specific mutation in the Access Model before relying on a row existing.
Controls matrix
Each in-scope requirement, the control that implements it, the evidence that proves it, and the owner. Every evidence name is a registered query that can be run with GET /api/audit/evidence/:name (system administrator only).
| Framework requirement | Control | Evidence | Owner |
|---|---|---|---|
| ISO A.8.15 Logging: security events recorded | Event coverage in the table above; secrets redacted before persist, at the write seam rather than in any one writer | privilege_changes_in_period, login_failures_in_period | Platform |
| ISO A.8.15 Log integrity: records tamper-evident | Append-only rows plus a per-row SHA-256 hash chain (prev_hash / row_hash); in-place update and out-of-sweep delete are refused; appends are serialized so the chain cannot fork; periodic verify-and-anchor | chain_verification_status | Platform |
| ISO A.8.16 Monitoring: anomalous auth detected | login_failed events; credential-validation throttles with escalating lockouts; metrics pfy_auth_credential_lockouts_total, pfy_auth_machine_auth_lockouts_total and pfy_executor_audit_chain_verification_failures_total | login_failures_in_period | Platform |
| ISO A.8.15 Retention: records kept on policy, deletion audited | Per-action retention windows (audit_retention_default 8760h, audit_retention_view 2160h); the sweep re-anchors, prunes, and writes a self-audit row with a high-water hash; anchors and prune records are never pruned | retention_prunes_in_period | Platform |
| Incident evidence / SIEM handoff (NIS2 Art. 23) | Time- and entity-filtered JSON-lines and CEF export carrying row_hash / prev_hash for downstream re-verification; each export is itself audited | GET /api/audit/export (administrator) | Platform |
| ISO A.5.18 / A.8.2 Access rights: reviewed periodically | Access-grant report from the user2* join tables; recorded sign-off attestation over the report's content hash | last_access_review_per_scope, GET /api/access/review (administrator) | Platform + reviewer |
| ISO A.5.17 Authentication information: credential lifecycle managed | Max-age policy (credential_rotation_max_age, default 90 days) enforced by a sweep that reports rather than rotates: pfy_executor_stale_credentials{kind} plus one other audit row per credential per lapse. The rotation is performed by an operator, rotateMachineUserToken for a machine user (audited rotate) or a re-registration for a backend. PAT expiry is enforced at request time and audited expire by sweep | credential_rotations_in_period, credential_expiries_in_period; alert on pfy_executor_stale_credentials | Platform + operator |
| ISO A.8.24 Cryptography: sensitive columns encrypted at rest | AES-256-GCM on secret columns (RegisteredBackend.token, Endpoint.auth_password, Endpoint.auth_token), keyed by encryption_key / PFY_ENCRYPTION_KEY; PATs and machine-user keys are salted PBKDF2-SHA256 hashes, not encrypted values | structural | Platform |
| ISO A.8.15 No secret / PII in logs | One redaction policy for AuditLog metadata/changes and ESB header maps (Authorization, Cookie, *token*, *secret*, password, plus secret-bearing URL query parameters), applied at the write seam so a new writer cannot bypass it | structural | Platform |
Log integrity
Three rules are enforced on AuditLog (and the matching pair on ESBAuditLog) at the storage layer, so they hold for every writer:
- Reject update. Any update of an audit row fails with
audit: AuditLog rows are append-only. - Reject delete. Deletes are refused unless the caller is the retention sweep.
- Redact and chain on create.
metadataandchangesare redacted first, thenrow_hash = SHA256(canonical_json(core fields) || prev_hash), whereprev_hashis the previous row'srow_hash(genesis = 64 zero hex characters). Order matters: redacting after hashing would leave a hash that no longer matches the stored row. Both hash columns are immutable and absent from GraphQL inputs so no caller can forge them.
An append takes a database-level lock on the chain head for the rest of its transaction, so two concurrent writers cannot both chain onto the same head: the chain cannot fork, rather than merely being fork-detecting. A row is written in the same transaction as the change it describes, so a rolled-back mutation leaves no row. The three writers whose fact a rollback cannot undo (proxy-config pushes, PAT creation, administrator cross-membership reads) append after the request settles, in a transaction of their own that takes the same lock; a failure there is logged and counted (pfy_audit_deferred_append_total) rather than reported to the caller.
The anchor sweep re-verifies the recent tail and writes a system row committing the verified head hash; a failed verification is recorded in the anchor row's metadata and increments pfy_executor_audit_chain_verification_failures_total. With audit.external_anchor_endpoint and audit.external_anchor_signing_key set, the head hash is also Ed25519-signed and POSTed to an external witness, so an attacker with database write access cannot simply recompute the chain forward.
CEF field mapping
The export (GET /api/audit/export?format=cef) emits one event per row:
CEF:0|Productify|Manager|1.0|<action>|<action> on <entity_type>|<severity>|<extension>Severity is 6 for login_failed, 3 otherwise. The extension carries rt (created_at, ms epoch), suid (actor id), src (ip), and cs1..cs4 = entity id / row_hash / prev_hash / actor_type, each with a csNLabel. The two hashes let a SIEM re-verify the chain segment on ingest.
Related
- Access Model: which mutations are gated on which permission
- Audit Logs: browsing audit rows in the Manager UI