Software Factories on AWS

Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.

Audit Events

A tamper-evident audit trail in the platform service’s MongoDB audit_events collection.

Highlights

Overview

This reference explains how Pipeline Builder produces, secures and queries its audit trail, and catalogs every action it records. It is for compliance reviewers and operators.

It covers the emitter paths, the per-tenant HMAC hash-chain integrity model with write-once published heads, sensitive-data scrubbing, and the full action catalog — platform-emitted lifecycle events plus the REMOTE_AUDIT_ACTIONS subset. The catalog stays in sync with the AuditAction union in code; see Adding a new audit event to extend it.

How it works

Two emitters feed the Mongo trail, and a separate structured-log path exists for the image registry.

Path Who How
Platform-direct the platform service Writes user and org lifecycle events straight to Mongo via the audit() helper (platform/src/helpers/audit.ts) and auditService.createEvent(...)
Service-remote every other service — pipeline, plugin, quota, compliance, image-registry, message, billing, reporting POSTs to the platform ingest POST /audit/events through the shared RemoteAuditClient (packages/api-core/src/services/remote-audit-client.ts)
Registry structured logs image-registry, additionally Emits eventCategory: 'audit' log lines to Loki for a couple of registry operations — see Registry structured-log events

Both emitter paths funnel through one appender (appendAuditEvent in platform/src/helpers/audit-chain.ts), so every stored event is hash-chained and scrubbed the same way.

Each stored record then carries:

  1. the next per-chain seq, claimed through a unique index;
  2. an HMAC digest over its canonical fields plus seq and prevHash;
  3. scrubbed details, with AWS-account-shaped tokens redacted before hashing;
  4. actorId / actorEmail, orgId (the actor’s own org) and affectedOrgId (the org actually operated on).

orgId and affectedOrgId diverge when a sysadmin acts on another org, which is what lets the trail answer “what did a sysadmin do to org X?” — SOC2 evidence for impersonation-style access (see Impersonation). admin.* actions and admin.impersonate.start set affectedOrgId to the target org so the affected org’s own admins can see them.

Configuration

  1. Set AUDIT_CHAIN_HMAC_KEY. Platform refuses to boot in production without it. At least 32 characters — generate with head -c 32 /dev/urandom | base64. Treat it as the audit trail’s signing key: rotating it invalidates verification of rows chained under the old key.
  2. Point the head export at a bucket with Object Lock. Without it you have an internally consistent chain and no way to detect tail truncation. The bootstrap Job creates the bucket and verifies WORM behaviour with a live smoke test.
  3. Keep the head retention above the event retention. AUDIT_HEAD_EXPORT_RETENTION_DAYS (default 400) must exceed AUDIT_RETENTION_DAYS (default 90), or heads age out before the rows they anchor.
  4. Configure Redis if you cannot tolerate silent loss. Without it, a dropped platform-local event is only counted in audit_local_dropped_total.
  5. Watch the loss metrics, not just the logs: audit_emitted_total, audit_dropped_total, audit_spool_{enqueued,dropped,redelivered}_total.
Env var Meaning
AUDIT_CHAIN_HMAC_KEY Chain HMAC key (required in production; never in the DB)
AUDIT_RETENTION_DAYS TTL on stored events, default 90
AUDIT_HEAD_EXPORT_S3_ENDPOINT e.g. http://rustfs:9000; export disabled when unset
AUDIT_HEAD_EXPORT_S3_BUCKET default audit-heads — created with Object Lock by the bootstrap Job, verified live at bootstrap by a WORM smoke test
AUDIT_HEAD_EXPORT_S3_REGION default us-east-1
AUDIT_HEAD_EXPORT_S3_ACCESS_KEY_ID / _SECRET_ACCESS_KEY bucket-scoped credentials (PutObject + GetObject only)
AUDIT_HEAD_EXPORT_PREFIX default audit-heads
AUDIT_HEAD_EXPORT_LOCK_MODE COMPLIANCE (default), GOVERNANCE, or none for a target without Object Lock
AUDIT_HEAD_EXPORT_RETENTION_DAYS object retention, default 400 (keep it above AUDIT_RETENTION_DAYS)
AUDIT_HEAD_EXPORT_INTERVAL_MS export cadence, default 300000

Reading the trail

Query via GET /audit — admin-only; org admins are forced to their own org, sysadmins may filter any org — or the dashboard Audit page at /dashboard/audit.

Filters: action, actorId, impersonatorId, targetType, targetId, roleId, requestId, outcome, from / to, and — sysadmin only — orgId and affectedOrgId. Every one is also a URL parameter of the Audit page, and the ids on each row narrow the list to that actor, impersonator, target or Role (roleId is the permission Role an org.role.* action touched).

Records auto-expire via a MongoDB TTL index after config.audit.retentionDays days.

Integrity & tamper-evidence

Every event is linked into a per-tenant HMAC hash chain (chain key = affectedOrgId ?? orgId):

Sensitive-data scrubbing

appendAuditEvent runs scrubAwsIdentifiers() over every event’s details before hashing and storing, redacting AWS-account-id-shaped tokens (including the account segment of any ARN) and account-named keys. An AWS account id is never persisted — orgId is the marketplace customerIdentifier, never an AWS account id. Emitters must also keep secrets/tokens out of details; the frontend applies a second redaction pass before rendering or exporting.

Service-remote ingest (POST /audit/events)

Non-platform services emit with api-core’s ONE call, recordAudit(event). The service identity is bound once at boot by wireServiceSecurity(serviceName) (every service calls it), so no call site names its service; calling recordAudit before that binding throws “audit not initialised” rather than sending an unattributed event. Delivery goes through RemoteAuditClient, which is best-effort and fire-and-forget — a failed audit never blocks or fails the originating mutation. Three properties make it safe and durable:

Observability — audit loss is metered, not just logged: audit_emitted_total, audit_dropped_total, audit_spool_{enqueued,dropped,redelivered}_total.

Querying the trail

From the UI, two admin surfaces read the MongoDB audit trail, both scoped the same way: an org admin sees events where their org was the actor’s org (orgId) or the affected org (affectedOrgId); a system admin sees every org; plain members see neither.

The cross-service logAuditEvent lines described above also land in Loki with service_name, eventCategory, event, actor, and pluginName promoted to labels, searchable in Grafana (Explore → Loki). They carry no org label, so they are not a tenant-scoped surface. Deep-link to a filtered Audit Activity view via the registry’s buildAuditLogLink helper (frontend/src/lib/registry-audit-link.ts).

Direct LogQL (hitting Loki at port 3100):

{service_name="pipeline-image-registry", eventCategory="audit", event="registry.tag.copy"}
  | json
  | isPromotionToSystem=`true`

Action catalog

The full set of platform actions lives in the AuditAction union in platform/src/models/audit-event.ts; the subset a remote service may emit is REMOTE_AUDIT_ACTIONS in packages/api-core/src/services/remote-audit-client.ts.

Platform-emitted

Area Actions
User lifecycle user.register, user.login, user.login.failed, user.logout, user.delete, user.profile.update, user.password.change, user.email.verified, user.token.create, user.tokens.revoke-all, user.session.revoke, user.step-up, user.password.change_required (a password sign-in opened no session because the password is shorter than the person’s org password policy requires — details.minLength; completed by user.password.change) — details.method is password, webauthn, totp or reauth; a passkey sign-in records details.method: 'webauthn' on user.login, and a password-plus-code sign-in records details.method: 'pwd+totp' with details.via saying whether a generated or a recovery code was used
Passkeys user.passkey.register, user.passkey.rename, user.passkey.remove — a passkey is a persistent sign-in credential, so both ends of its life are recorded (details carries the label the person gave it and whether it is a synced credential). user.passkey.clone_suspected — an authenticator’s signature counter went backwards on a credential that had counted before, i.e. two authenticators answering for one credential; the assertion is refused and this is the only trace it happened
Authenticator app (TOTP) user.totp.enrol — emitted TWICE, details.stage: 'started' when the secret is minted and 'activated' when a code confirms it, because a secret that was displayed and then abandoned is still a secret that left the building. user.totp.disable is the other end of the factor’s life. user.mfa.recovery_regenerate — the account’s recovery-code set (one set, shared by passkeys and the authenticator app) was replaced, so every previously issued code stopped working. user.mfa.recovery_used — a recovery code was spent (details.context is login or step-up, details.remaining how many are left) — on the authenticator’s sign-in leg or on the recovery-only leg a passkey-only account gets when its org requires MFA; its own action rather than a detail on the sign-in, because burning one usually means a lost device, and an attacker who obtained the sheet leaves exactly this trace. Wrong codes are user.login.failed with details.method: 'totp', so brute-force shows up on the same trail as password guessing
Assurance & required MFA (#8) auth.mfa.bootstrap_session — a sign-in used the bootstrap-administrator exception: the install’s only admin holding a single-factor session because they have not enrolled one yet. details.late is true when it happened more than 24h after the system org was created, which is the alertable case (a fresh install finishes in minutes). auth.mfa.bootstrap_closed — their first enrolment closed the exception permanently. auth.mfa.reset_requested — an org owner/admin asked for a member’s second factors to be reset (details.reason, details.requestId, details.expiresAt); the actor is the requester. auth.mfa.reset_approved — a DIFFERENT owner/admin (or a sysadmin, details.approverIsSysadmin) approved it and the reset ran: every passkey, the authenticator app and the recovery codes removed, tokenVersion bumped, and a per-user enrolment grace granted (details.graceUntil); the actor is the approver and details.requestedBy the requester. auth.mfa.reset_denied — denied, or withdrawn by its requester (details.withdrawn). auth.mfa.direct_reset — a sysadmin reset a member’s factors alone, for an org with no second admin (details.direct: true, details.reason). auth.mfa.operator_reset — an operator ran the same reset with the scripts/mfa-recover.js command because nobody could sign in; attributed to the named (self-asserted, details.operatorAsserted) operator, never to the account. org.mfa_policy.update — an org turned “require MFA” on or off, changed its grace period, changed its statement that its IdP enforces MFA, or changed its “administrative actions require MFA” policy (details.sessionsRefreshed: members signed out so turning it on applies at once; 0 when it was turned off); details carries both sides of each transition. org.password_policy.update / org.authenticator_policy.update — an org changed its minimum password length or its approved-authenticator (passkey model / AAGUID) allowlist; details carries both sides. user.mfa.prompt_declined — a member was offered a second factor by the password-only prompt and chose “don’t ask again”: a durable decision to stay on one factor, which is why it has its own action rather than being invisible. Their org’s admins see the COUNT of it on the two-factor policy panel; this is the only trail that says who. user.mfa.prompt_restored — the same person withdrew that decline (or a snooze), without which “declined” would read as permanent. The 7-day “not now” is deliberately NOT audited: a row a week per password-only account would bury both of these
Access keys user.key.create, user.key.revoke, user.key.exchange, user.key.exchange.failed
Device authorization device.authorize.start, device.authorize.approve, device.authorize.deny, device.authorize.expire
Organization org.create, org.update, org.soft_delete, org.restore, org.team.delete (a parent admin soft-deleted one of its own teams; details.parentOrgId, purgeAfter, snapshotId, membersInvalidated), org.switch (an inherited-authority switch into a team adds details.via: 'ancestor' and details.inheritedFromOrgId), org.member.add, org.member.remove, org.member.deactivate, org.member.activate, org.ownership.transfer
Invitations invitation.send, invitation.accept, invitation.revoke, invitation.resend
Permission roles org.role.create, org.role.update, org.role.delete, org.role.member.add, org.role.member.remove
Service accounts org.service-account.create, org.service-account.update, org.service-account.delete, org.service-account.key.create, org.service-account.key.revoke, org.service-account.key.rotate, org.service-account.key.rotate.failed
SSO provisioning org.idp.mapping.upsert, org.idp.mapping.delete — an IdP group → Role mapping was authored or removed (details carries the group + Role ids). sso.jit.provision — an SSO sign-in created the org membership; sso.jit.role.change — a later sign-in added/removed mapped Roles; sso.jit.refused — provisioning was turned away (details.reason, today seat_limit), which also refuses the sign-in
SAML sign-in A successful SAML sign-in is a plain user.login with details.method = 'saml' — it is the same kind of session, and splitting it would fracture every “who signed in” query. The REFUSAL gets its own action, because SAML fails in ways that are security events rather than someone mistyping a password: sso.saml.refused with details.reason — idp_initiated (an unsolicited assertion: login CSRF), replay (an assertion presented twice), invalid_assertion (signature, issuer, audience or validity window), domain_not_verified, platform_admin, seat_limit, invalid_state, no_email, plus the configuration states. sso.saml.certificate.rotate records a change to the org’s trusted IdP signing certificates — details carries the fingerprints before and after and whether an overlap window is now open, never the certificates themselves. sso.saml.logout records single logout: details.direction sp (we sent the signed LogoutRequest — stage: 'request', then 'complete' when the IdP’s signed LogoutResponse returns) or idp (the IdP’s signed LogoutRequest revoked sessionsRevoked sessions, userIds); a refused logout message is outcome: 'failure' with details.reason. See SAML 2.0
SSO setup & policy sso.test — a test connection (a dry run that creates no session, user or membership), at details.stage: 'start' and 'complete' — the latter with ok, the failure reason, the asserted email and whether it was recorded as the connection’s last test; outcome follows ok. org.sso.required.update — the org’s SSO required policy was switched (details.from / to, surface). org.idp.metadata.import — IdP SAML metadata was parsed into the form (details.source url with the fetched host, or xml; the entityId and certificate count) — saving it is a separate admin.org-idp.upsert
SCIM provisioning org.scim.user.create, org.scim.user.update, org.scim.user.activate, org.scim.user.deactivate, org.scim.user.delete, org.scim.group.create, org.scim.group.update, org.scim.group.members, org.scim.group.delete — the identity provider’s SCIM 2.0 client changed the roster or a directory group. org.scim.refused — a SCIM request was turned away (outcome: 'failure', details.reason)
Dashboards & alerts dashboard.create/update/delete/restore/purge/clone, alert.destination.create/update/delete/restore/purge/test, alert.rule.create/update/delete/restore/purge. Delete is a SOFT delete: the row is restorable from “recently deleted” until the retention sweep hard-deletes it. …restore records bringing one back, …purge records destroying a tombstone by hand ahead of the sweep — both re-verify the actor’s password (step-up) first
Admin / sysadmin admin.user.create/update/delete, admin.org.delete, admin.org.export, admin.org-idp.upsert/delete, admin.superadmin.grant/revoke, admin.org.kms-config.upsert/delete, org.kms.orphaned, admin.org.tier.update, admin.org.move (sysadmin reparent — details.fromParentOrgId / toParentOrgId, null = standalone root, the resulting tier, membersInvalidated), admin.org.seatLimit.update, admin.org.ai-config.update, admin.user.features.update, admin.impersonate.start, admin.impersonate.request/approve/deny/revoke/breakglass, admin.org.namespace.render
Denied access authz.denied — emitted by the shared permission gate when a state-changing (non-GET) request is rejected, so probing / privilege-escalation attempts leave a trail (outcome: 'failure'). Also emitted by requireInternalService for a refused INTERNAL route (/internal/*, the quota usage counters, the entity-event / audit ingests, the entitlement sync legs), with required naming the services that route admits — paired with the internal_route_refused_total{service,route,reason,caller} counter, whose reason distinguishes a user token from a wrong caller

Access keys deserve a note: a key is opaque, so no service ever sees it — they see the short-lived token it was traded for. user.key.exchange is therefore the only record that a key was used at all, and it is what “which automation is still using key X?” is answered from (targetId is the key id, details.name its label, and the row is attributed to the key’s owner even though the exchange request itself carries no identity). Its failure twin, user.key.exchange.failed, records the refusal reason (unknown, revoked, expired, authority_revoked, user_gone, malformed, and for a service-account key account_gone, account_disabled, ip_not_allowed, budget_exhausted, orphan_key) that the HTTP response deliberately does not differentiate — a run of unknown from one IP is a scanner, and the row carries that IP and user-agent.

Service accounts (#2) are principals in their own right, so every row a service account’s key produces names the ACCOUNT as the actor: actorId is the account id and actorEmail its <name>@service-account.invalid sentinel — never the person who happened to create it. user.key.exchange carries details.principalType (user | service_account) and, for an account, details.serviceAccount, so “what did this automation do” and “which human did that” never blur together. The management events above cover the durable grants: creating an account, changing its roles/budget/disabled state, deleting it, and issuing or revoking each key (details.scope records the one capability a scoped key carries instead of the account’s Roles, and details.ipAllowlistEntries whether a key was pinned to addresses — never which).

SCIM (3b) is the other machine trail, and the one an admin reads when a directory sync has quietly stopped working. The actor is the service account behind the scim-scoped key, exactly as above. details.changed names which attributes moved — never their values: a directory sync carries personal data, and the audit log must not become a second copy of it. A group write additionally carries details.membersAffected, the number of people whose Role set the write reconciled — the blast radius of one push.

org.scim.refused is the half that matters operationally, because a refused sync is otherwise visible only in the IdP’s own console. details.reason is a stable label: seat_limit (the account is full — the response names the limit), not_entitled (the SSO entitlement lapsed, so only deactivate and delete are still accepted; the org’s admins are also notified, once a day), invalid_value (most often an unverified email domain), uniqueness, invalid_filter, mutability (an attempted userName rename), owner_protected, platform_admin, not_found, and wrong_credential (something that is not a scim-scoped service-account key tried the endpoint). The same labels appear on platform_scim_errors_total{resource,operation,reason}.

Self-rotation (#N2) is the machine half of that trail. org.service-account.key.rotate is emitted when an unattended rotator replaces its own credential through POST /auth/key/rotate: no person is present, so the row is attributed to the account, targetId is the account id, and details carries keyId (the replacement), previousKeyId (what it replaces), scope, and prunedKeyIds — siblings platform retired to stay under the active-key cap. A non-empty prunedKeyIds means an EARLIER rotation never revoked its predecessor, which is worth noticing even though the rotation itself succeeded. The subsequent retirement lands as an ordinary org.service-account.key.revoke with details.via: 'self-rotation'. org.service-account.key.rotate.failed carries the same undifferentiated refusal reasons as the exchange, plus the rotation-only ones (not_service_account, self_revoke, expiry_invalid, key_limit) — again, reasons the HTTP response does not give away.

Device authorization (how pipeline-manager auth login signs in — see Authentication → CLI sign-in by device authorization) is the one flow whose trail spans an anonymous and an authenticated actor. device.authorize.start is emitted pre-auth, so its actorId is anonymous and the only identifying detail is details.client — the requesting device’s client summary. The decision events carry the person who approved or refused. All four share a targetId: a truncated hash of the device code, which is what lets “a code was requested from X and approved by Y” be reconstructed without ever recording the device code or the short user code (both are live credentials for the flow’s 10-minute life). device.authorize.expire is written by whichever side first notices a lapsed code, so a code nobody ever returns to leaves only its .start row.

Agent-drafted changes carry details.proposedBy: 'ask-agent'. The Ask panel’s “Apply” commits a drafted change through the same route the dashboard’s own button calls, with the person’s own session and permissions — the agent never writes — so without this key the trail could not tell “the admin changed this” from “an AI drafted it and the admin clicked Apply”, which mean very different things in an incident review. The actor is still the person: the key says how the change was DRAFTED, never who is answerable for it.

The browser asserts it with the X-PB-Proposed-By request header, and the routes that accept it (pipeline.create/.update, plugin.deploy/.update, pipeline_template.create/.update, reporting.settings.update, compliance.notification-preference.update, plugin.security_notifications.update) carry the proposable gate. The header’s whole vocabulary is one word: a request naming any other proposer is refused with a 400 before the write, and the handler’s own details stay authoritative — provenance can add proposedBy and nothing else. A change made by hand records no proposedBy at all, which is what makes the key’s presence mean something. The two proposal kinds that file into an approval QUEUE instead of writing (plugin install change-request, compliance exemption) carry the same fact as a sentence in the note / reason the approver reads, because there the provenance has to reach a person, not a query.

Each record carries actorId/actorEmail, orgId (the actor’s own org), and affectedOrgId (the org actually operated on). They diverge when a sysadmin acts on another org, so the trail answers “what did a sysadmin do to org X?” — SOC2 evidence for impersonation-style access (see Impersonation). admin.* actions and admin.impersonate.start set affectedOrgId to the target org so the affected org’s own admins can see them.

Log-surface egress

Action Emitted when Details
observability.logs.export Someone downloads log content from Deliver → Logs format, lines, bytes, truncated, from, to, filter, tenantCount
observability.logs.cross-org-read A system admin reads a Loki tenant other than _infra tenantCount, context (search / context / raw)

A log export is a cheap request with large egress that leaves the building, so it is audited like admin.org.export. The cross-org read is the log-surface counterpart to impersonation accountability: viewing another organization’s data is recorded even though it changes nothing. See Logs.

Service-emitted (REMOTE_AUDIT_ACTIONS)

Service Actions
Plugin plugin.build.completed, plugin.build.failed, plugin.build.timeout, plugin.delete, plugin.restore, plugin.purge, plugin.update, plugin.upload, plugin.deploy, plugin.bulk.update, plugin.bulk.delete, plugin.dlq.purge, plugin.build.retry, plugin.dlq.replay (the last two are queue-triage re-runs — re-enqueueing a failed or dead-lettered build; affectedOrgId is the job’s owning org, which differs from the caller’s on a sysadmin retry), plugin.scan.skipped (a version persisted unscanned because the build-time scan could not run and the operator escape hatch PLUGIN_ALLOW_UNSCANNED is on; details: name, version, digest). A build blocked by a scan gate ends in plugin.build.failed with details.errorCode = IMAGE_SCAN_UNAVAILABLE or PLUGIN_VULN_GATE. Two more record a decision rather than a build: plugin.shadowing.denied (an upload refused because the name would shadow an ecosystem plugin; details: name, version, what it shadows) and plugin.vuln.waived (a vulnerability gate passed under a waiver — targetId is the image DIGEST, not a plugin id, because it runs before the plugin row exists)
Plugin security notifications (org-local) plugin.security_notifications.update (org:settings; details: changed field NAMES, recipient mode + count, digest mode, rescan opt-in, the webhook HOST, whether a secret / external address is set and whether a confirmation was sent — never the secret, the URL path/query or the address), plugin.security_notifications.external_email.verify (actor anonymous: the address’s emailed single-use link was confirmed; orgId = the org whose address it is), plugin.security_notifications.test (details: the per-channel outcome of the test send)
Pipeline pipeline.create, pipeline.update, pipeline.delete, pipeline.restore, pipeline.purge, pipeline.execution.start, pipeline.execution.cancel, pipeline.registry.register, pipeline.registry.deregister
Pipeline templates pipeline_template.create, pipeline_template.update, pipeline_template.delete, pipeline_template.restore, pipeline_template.purge (own action family, gated by templates:* rather than pipelines:*; create/update details carry the template’s visibility rung)
Quota quota.reset, quota.limit.update, quota.delete
Compliance compliance.exemption.approve, compliance.exemption.revoke, compliance.rule.toggle, compliance.rule.create/update/delete/restore/purge, compliance.policy.create/update/delete/restore/purge, compliance.scan-schedule.create/update/delete, compliance.template.apply, compliance.scan.create, compliance.scan.cancel, compliance.notification-preference.update (changed field names + webhook host only — never the destination secret)
Image registry registry.gc, registry.image.delete, registry.image.copy (all carry affectedOrgId = the org owning the repository — org-<id>/…, or the system org for system/… — so that org’s admins see changes an operator made to their images); registry.image.sign — a plugin image signed and SBOM-attested with the platform plugin-signing key at the plugin build worker’s request (details.digest; affectedOrgId = the owning org)
Message message.announcement.create, message.delete, message.restore, message.purge (admin broadcasts + the destructive lifecycle only — 1:1 messages, replies, edits and attachment uploads are deliberately NOT audited, and no message body reaches details)
Billing billing.subscription.create, billing.subscription.update, billing.subscription.reactivate, billing.subscription.cancel, billing.subscription.delete, billing.tier.override, billing.addon.add, billing.addon.remove, billing.addon.prune, billing.discount.generate, billing.discount.issue, billing.discount.apply, billing.discount.remove, billing.discount.revoke, billing.credit.consumed, billing.credit.exhausted, billing.combo.expired (mirrored to the central trail alongside the service-local billing_events; details carry plan/tier/addon/discount/combo ids + cents only — never payment secrets, coupon tokens, or signing keys). Promotions are a separate, system-admin-only family: billing.promotion.create (mint), billing.promotion.update (edit), billing.promotion.revoke (soft-revoke — stops future auto-grants; credits already granted are NOT clawed back), billing.promotion.grant (grant to one org, bypassing eligibility matching but still honouring budget and idempotency) and billing.promotion.activate (fan out to the eligible base, budget-bounded)
Reporting reporting.settings.update (the incident→deploy correlation window, which moves reported CFR/MTTR), reporting.deployment.outcome (a deploy-outcome marker, same), reporting.retention.sync (an inbound billing→reporting retention entitlement — a cut destroys history at the next sweep; carries affectedOrgId). Stakeholder reports carry their own family, each naming the permission that reached it: reporting.report.definition.create/update/delete and reporting.report.ownership.transfer (reports:author); reporting.report.recipient.added and reporting.report.recipient.removed (reports:author); reporting.report.link.created and reporting.report.link.revoked (reports:share — a share link is an unauthenticated read of org data, so both ends are recorded); reporting.report.published and reporting.report.republished (reports:share); reporting.report.policy.update (org:settings); and reporting.report.paused and reporting.report.resumed, which are emitted by INTERNAL service callers rather than a person — paused when platform reports the owner left the org (authority is re-checked every run), resumed when billing restores the entitlement
Ask (assistant) ask.query (read-only how-to turn), ask.agent.turn (tool-calling turn) — one per turn on POST /ask, /ask/stream, /ask/agent/stream respectively; both carry an outcome (success/failure, incl. client-abort) and details with SAFE METADATA ONLY (tools used, proposal kinds, source count, query length) — never the raw query text. Confirmed drafts commit through the normal create routes, so the resource itself is audited as pipeline.create / pipeline_template.create / plugin.deploy. ask.feedback records a reader’s verdict on an answer (details.rating = up/down) together with the DOC IDS it cited (details.sources, e.g. authentication.md) — the doc is what gets corrected, and unlike the question it is not the user’s own words. A help search that found nothing is counted in the help_search_miss_total metric only, never here: the search text would be the one actionable field and it is user-entered
Plugin ecosystem: publishers publisher.create, publisher.update, publisher.terms.accept (details.termsVersion), publisher.verify.request/approve/reject, publisher.tier.change (details.reason = 'plan_downgrade' when a Verified grace period ends), publisher.suspend, publisher.unsuspend, publisher.transfer.request/accept/decline/approve/reject, publisher.profile-change.approve/reject
Plugin ecosystem: publish requests (tenant → system org) plugin.request.submit (details.kind: new_listing / new_version / listing_update / yank / unpause / transfer / claim / profile_change / verify / advisory, or moderation for a system-org two-person action — unsuspend, unyank, relist, tier to Verified — whose initiator’s plugin.request.approve is the first approval; details.digest; details.securityFix), plugin.request.withdraw, plugin.request.approve (details.firstOfTwo = true when it parks a two-person request), plugin.request.second-approve, plugin.request.reject (details.reason = 'listings_quota' when over the plan’s listings limit), plugin.request.auto-approve (actor system, details.autoRuleId, or details.bootstrap = true for the one-time catalog seed)
Plugin ecosystem: listings and versions plugin.listing.publish (on approval; carries digest, tier), plugin.listing.unlist, plugin.listing.update, plugin.listing.state.change (unmaintained / suspended / listed), plugin.listing.pause / plugin.listing.unpause (tenant pause; unpause on approval), plugin.version.pause, plugin.version.yank, plugin.version.unyank, plugin.version.deprecate, plugin.collection.update (featured/curated)
Plugin ecosystem: governance rules Every decision is recorded with orgId = the system org and affectedOrgId = the publisher’s org (tenant submissions: orgId = the publisher’s org). Automatic decisions use actor system. details carry ids, versions, digests, tier, state and short reason text only — never README content, metadata values or emails
Plugin ecosystem: configuration (system org) ecosystem.auto-approval-rule.create/update/delete, ecosystem.reserved-name.update, ecosystem.sla.update
Plugin ecosystem: public registry namespace registry.image.publish (copy to public/* + fresh sign + SBOM attest; also the unyank re-tag, details.retag: true, which restores the version tag from the public/* manifest alone), registry.image.resign (the re-sign job after a tier change, suspension, handle change or transfer — one per image, emitted by image-registry; the plugin service emits one more with targetId: all when an Ecosystem Manager queues a full re-sign after a plugin-signing key rotation), registry.image.yank (public/* tag removal on yank or takedown), registry.image.gc (public/* retention sweep)
Plugin ecosystem: installs and policy plugin.install.create (an install that needed no approval, active at once), plugin.install.request (an install that became a pending request), plugin.install.approve / plugin.install.deny (actor = the approver), plugin.install.upgrade (a change of version or version policy), plugin.install.change-request (a member asked for a change that needs an approver), plugin.install.change-approve / plugin.install.change-reject (actor = the approver; an approval also records the plugin.install.upgrade it applies), plugin.install.remove (uninstall, or a withdrawn request), org.plugin-install-policy.update (org-local; set by plugin_installs:manage with a step-up). Every one: orgId = the actor’s org, affectedOrgId = the installing org (a team’s own installs and policy → the team). Implicit Official installs are virtual and are never audited; creating or removing an explicit Official install is
Plugin ecosystem: reviews plugin.review.create/update/delete (by the author), plugin.review.report, plugin.review.hold, plugin.review.release, plugin.review.remove (moderator), plugin.review.anonymize (user deletion), plugin.review.reply.create/update/delete
Plugin ecosystem: anonymous submissions plugin.submission.create (actor anonymous), plugin.submission.verify, plugin.submission.gate-fail, plugin.submission.approve, plugin.submission.reject, plugin.submission.claim, plugin.submission.expire (actor system)
Plugin ecosystem: advisories plugin.advisory.create (draft: publisher, moderator, or system from a CVE rescan), plugin.advisory.update (edit an existing advisory; carries affectedOrgId = the publisher’s owning org), plugin.advisory.publish and plugin.advisory.withdraw (system org only)
(all services) authz.denied

Plugin ecosystem: actor and affected-org rules

The ecosystem actions above follow these rules:

Not audited (by design): anonymous directory reads, searches, review “helpful” votes, and notification deliveries — those are metrics, not state changes.

Review actions (W4) carry details.listing (publisher/name) and, as they apply, rating, version, verifiedUse, held (the hold reason of a new or edited review that went to moderation), changed (the edited fields) and the report category — never the review, reply or report text. A hold triggered automatically by reports or a security report is recorded with actor system, orgId = the system org and details.trigger; moderator decisions (plugin.review.hold / release / remove, and plugin.review.reply.delete with details.by: moderator) also use the system org. affectedOrgId is the publisher’s org. plugin.review.anonymize is reserved for GDPR user deletion, which is not wired to platform’s account deletion yet.

Anonymous submission actions (W5) carry details.submissionId, the plugin name and version, and, as they apply: the failed gate ids on plugin.submission.gate-fail, the request id and listing (community/name) on plugin.submission.approve, the reason text on plugin.submission.reject, and the claiming publisher on plugin.submission.claim. plugin.submission.create and plugin.submission.verify use actor anonymous; gate results and plugin.submission.expire use system; approve and reject are the deciding moderator, and the queue decision is also recorded as plugin.request.approve / plugin.request.reject on the submission request. All use orgId = the system org. The submitter’s email (plain, hashed or encrypted) is never in details, and neither are the heuristics excerpts.

Plugin build terminal outcome — plugin.build.failed / plugin.build.timeout is emitted at TRUE dead-letter-queue exhaustion, not at the tier queue’s final attempt. A job that fails the tier queue but later succeeds in the DLQ emits only plugin.build.completed — the trail records exactly one terminal outcome per build, never a “failed” that a later “completed” contradicts.

Registry structured-log events

Independently of the Mongo trail, image-registry emits eventCategory: 'audit' structured log lines (via logAuditEvent(logger, event) in packages/api-core/src/utils/audit.ts) that the log aggregator (Loki, in the default deploy) routes into a dedicated stream. The event-name union is packages/api-core/src/types/audit-events.ts.

registry.tag.copy

Emitted by image-registry’s POST /api/images/copy after a successful cross-repo tag copy.

Field Type Description
event 'registry.tag.copy' Constant discriminator
actor string req.user.sub of the sysadmin who initiated the copy
source string Source <repo>:<ref>
target string Target <repo>:<ref>
sourceDigest string Resolved digest of the source manifest
targetDigest string Resolved digest of the target manifest
isPromotionToSystem boolean true when the target repo starts with system/ — the highest-privilege case
mounted.manifests number Total manifests PUT
mounted.blobs number Count of UNIQUE blob digests cross-mounted

Why isPromotionToSystem matters: copying any tag into system/* makes the image visible to every authenticated user. Operators should alert on these specifically — they’re meaningful trust escalations.

registry.tag.delete

Emitted by image-registry’s DELETE /api/images/{name}/manifests/{reference} after a successful delete.

Field Type Description
event 'registry.tag.delete' Constant discriminator
actor string req.user.sub of the sysadmin who initiated the delete
repo string Repository name (e.g. org-acme/foo)
ref string Tag or digest the operator passed in
digest string Resolved manifest digest that was actually deleted

Adding a new audit event

Platform-emitted (user/org lifecycle):

  1. Add the action to the AuditAction union AND the ALL_AUDIT_ACTIONS array in platform/src/models/audit-event.ts.
  2. Call audit(req, 'new.action', { targetType, targetId, affectedOrgId, details }) from the controller after the mutation succeeds. Keep secrets / tokens / AWS account ids out of details.
  3. Declare it on the route with api-core’s audited('new.action') middleware, so the route table records it and the service’s route-coverage test counts the write route as audited (an undeclared write route fails that test).
  4. Document it in the action catalog above.

Service-emitted (a non-platform service):

  1. Add the action to REMOTE_AUDIT_ACTIONS in remote-audit-client.ts AND to the platform AuditAction union / ALL_AUDIT_ACTIONS (the subset-guard test enforces REMOTE_AUDIT_ACTIONS ⊆ AuditAction).
  2. Emit it with recordAudit({ action, actorId, orgId, targetId, details }) (from @pipeline-builder/api-core) after the mutation succeeds — no service argument; the service is the one bound by wireServiceSecurity. In tests, pass a spy as apiCoreMock({ recordAudit: spy }), or call bindTestAuditService(serviceName) from @pipeline-builder/api-core/testing when the suite runs the real api-core.
  3. Declare it on the route with api-core’s audited('new.action') middleware (see route coverage) — the per-service test fails on a write route that declares no action, and on an action that isn’t in REMOTE_AUDIT_ACTIONS.
  4. Document it in the service-emitted catalog above.

Use the dot-separated <area>.<entity>.<verb> naming convention so events sort and filter cleanly.