Skip to content

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:

Audit substrate ​

Two entities carry all audit evidence; there is no parallel compliance store:

EntityScopeWritten on
AuditLogOperator 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
ESBAuditLogPer-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 | other

Every 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 eventFrameworkactionCoverageWhat is recorded (surface)
L1Authentication success (browser / OIDC)A.8.15loginimplementedOne row per validated browser identity, with IP, user agent and application (proxy validation callback)
L2Authentication failureA.8.15, A.8.16login_failedpartialBrowser 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/*)
L3Logout / session endA.8.15logoutgapLogout happens in the proxy and Pocket ID; the Manager never sees it. Nothing emits logout
L4Credential createdA.8.15, A.5.17createimplementedPAT 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)
L5Credential rotatedA.8.15, A.8.24rotatepartialrotateMachineUserToken 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
L6Credential revokedA.8.15, A.5.17revokepartialPAT 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
L7Credential expiredA.8.15, A.5.17expireimplementedThe scheduled PAT sweep marks lapsed PATs revoked and writes one expire row each; the request path already denies expired tokens
L8Membership / grant createdA.5.18, A.8.2createimplementedTenants: invite acceptance (REST), addUserToTenant, invite create. Projects: addUserToProject. Applications: addUserToApplication and the join-policy auto-provisioning path at browser login
L9Membership / grant revokedA.5.18, A.8.2deleteimplementedremoveUserFromTenant, invite revoke, removeUserFromProject, removeUserFromApplication, and the generated join-row deletes deleteUser2Project, deleteUser2Application, deleteUser2Tenant
L10Role / privilege changeA.8.2updateimplementedSystem 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
L11Data exportA.8.15, incident evidenceexportimplementedGET /api/audit/export and GET /api/access/review each write a row on use
L12Admin configuration changeA.8.9, A.8.15create / update / deletepartialRecorded: 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
L13Access review sign-offA.5.18, A.8.2otherimplementedPOST /api/access/review/attest writes an AccessReview row and an audit row
L14Audit retention pruneA.8.15 (log integrity)delete (actor_type=system)implementedThe scheduled retention sweep; the prune record carries the count, windows and high-water hash
L15Chain anchor / verificationA.8.15 (log integrity)other (actor_type=system)implementedThe 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:

  • revoke is never emitted (L6). Credential revocations exist, but under update or delete. Anything keyed on the revoke enum value returns an empty set rather than an error.
  • logout is never emitted (L3), because the Manager does not see it.
  • A backend callback token rotates without a rotate row (L5). The re-registration that mints it is written as create with re_register: true, so credential_rotations_in_period counts 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 requirementControlEvidenceOwner
ISO A.8.15 Logging: security events recordedEvent coverage in the table above; secrets redacted before persist, at the write seam rather than in any one writerprivilege_changes_in_period, login_failures_in_periodPlatform
ISO A.8.15 Log integrity: records tamper-evidentAppend-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-anchorchain_verification_statusPlatform
ISO A.8.16 Monitoring: anomalous auth detectedlogin_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_totallogin_failures_in_periodPlatform
ISO A.8.15 Retention: records kept on policy, deletion auditedPer-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 prunedretention_prunes_in_periodPlatform
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 auditedGET /api/audit/export (administrator)Platform
ISO A.5.18 / A.8.2 Access rights: reviewed periodicallyAccess-grant report from the user2* join tables; recorded sign-off attestation over the report's content hashlast_access_review_per_scope, GET /api/access/review (administrator)Platform + reviewer
ISO A.5.17 Authentication information: credential lifecycle managedMax-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 sweepcredential_rotations_in_period, credential_expiries_in_period; alert on pfy_executor_stale_credentialsPlatform + operator
ISO A.8.24 Cryptography: sensitive columns encrypted at restAES-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 valuesstructuralPlatform
ISO A.8.15 No secret / PII in logsOne 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 itstructuralPlatform

Log integrity ​

Three rules are enforced on AuditLog (and the matching pair on ESBAuditLog) at the storage layer, so they hold for every writer:

  1. Reject update. Any update of an audit row fails with audit: AuditLog rows are append-only.
  2. Reject delete. Deletes are refused unless the caller is the retention sweep.
  3. Redact and chain on create. metadata and changes are redacted first, then row_hash = SHA256(canonical_json(core fields) || prev_hash), where prev_hash is the previous row's row_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.