Self-service AWS CodePipeline platform — developers ship compliant CI/CD pipelines in minutes via dashboard, CLI, CDK, or AI prompt, while platform teams enforce policy-as-code guardrails, governance, and per-team isolation.
Pipeline Builder keeps a tamper-evident audit trail in the platform
service’s MongoDB audit_events collection. Two emitters feed it, and a
separate structured-log path exists for the image registry.
platform service writes user/org lifecycle events
straight to Mongo via the audit() helper
(platform/src/helpers/audit.ts) and
auditService.createEvent(...).POST /audit/events through the shared RemoteAuditClient
(packages/api-core/src/services/remote-audit-client.ts).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.
Query the trail 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. Records auto-expire via a MongoDB TTL index after
config.audit.retentionDays days (default 90, overridable via
AUDIT_RETENTION_DAYS).
This reference explains how Pipeline Builder produces, secures, and queries its audit trail, and catalogs every action it records. It’s for compliance reviewers and operators. It covers the emitter paths (platform-direct writes, the service-remote POST /audit/events ingest, and the registry’s Loki structured logs), the per-tenant SHA-256 hash-chain integrity model, sensitive-data scrubbing, and the full action catalog — platform-emitted lifecycle events plus the REMOTE_AUDIT_ACTIONS subset (including billing subscription, tier, addon, and discount actions). The catalog stays in sync with the AuditAction union in code; see Adding a new audit event to extend it.
Every event is linked into a per-tenant SHA-256 hash chain: each row stores a
hash over its immutable fields plus the prevHash of the previous event in the
same chain (chain key = affectedOrgId ?? orgId). Altering, reordering, or
deleting a stored event breaks the chain.
GET /audit/verify?orgId=<id> (sysadmin-only) walks a tenant’s
chain and returns { ok, brokenAt?, count }. ok:false with brokenAt set
means the chain is broken at that event. The dashboard Audit page surfaces
this as a Verify integrity action for sysadmins.prevHash, so an org older than the retention window (whose genesis rows have
aged out under the TTL) does not false-alarm. Tampering with any event that
still has a surviving successor is detected; truncation of the oldest
contiguous prefix is indistinguishable from normal TTL pruning.occurredAt — events carry an occurredAt (when the action really
happened), stored for reviewers. It is deliberately not the chain-ordering
field — the chain orders by ingest createdAt — so a delayed/spooled delivery
never perturbs chain consistency or verification.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.
POST /audit/events)Non-platform services deliver events 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:
requireServiceAuth) and validates action against the
REMOTE_AUDIT_ACTIONS allow-list (isRemoteAuditAction), not the full
platform AuditAction union. A service token therefore cannot forge
platform-authority events (admin.superadmin.grant, org.ownership.transfer,
user.login, …). A REMOTE_AUDIT_ACTIONS ⊆ AuditAction test guards drift.Idempotency-Key; the ingest
dedups on it (unique index), so a retried delivery collapses to a single stored
row and a single chain link.Idempotency-Key, so a live attempt and its later re-delivery
dedup to one row.Observability — audit loss is metered, not just logged:
audit_emitted_total, audit_dropped_total,
audit_spool_{enqueued,dropped,redelivered}_total.
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.
| 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 |
| Organization | org.create, org.update, org.soft_delete, org.restore, org.switch, 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 |
| Dashboards & alerts | dashboard.create/update/delete/clone, alert.destination.create/update/delete/test, alert.rule.create/update/delete |
| 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.seatLimit.update, admin.org.quota.override, admin.org.ai-config.update, admin.user.features.update, admin.impersonate.start, 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') |
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. admin.* actions and
admin.impersonate.start set affectedOrgId to the target org so the affected
org’s own admins can see them.
REMOTE_AUDIT_ACTIONS)| Service | Actions |
|---|---|
| Plugin | plugin.build.completed, plugin.build.failed, plugin.build.timeout, plugin.delete, plugin.upload, plugin.deploy, plugin.bulk.update, plugin.bulk.delete, plugin.dlq.purge |
| Pipeline | pipeline.create, pipeline.update, pipeline.delete, pipeline.execution.start, pipeline.execution.cancel, pipeline.registry.register, pipeline.registry.deregister |
| Quota | quota.reset, quota.limit.update, quota.delete |
| Compliance | compliance.exemption.approve, compliance.exemption.revoke, compliance.rule.toggle, compliance.rule.create/update/delete, compliance.policy.create/update/delete, compliance.scan-schedule.create/update/delete, compliance.template.apply, compliance.scan.cancel |
| Image registry | registry.gc, registry.image.delete |
| Message | message.announcement.create, message.delete (admin broadcasts + deletes only — 1:1 messages are not audited, and no message body reaches details) |
| Billing | 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) |
| (all services) | authz.denied |
Plugin build terminal outcome —
plugin.build.failed/plugin.build.timeoutis 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 onlyplugin.build.completed— the trail records exactly one terminal outcome per build, never a “failed” that a later “completed” contradicts.
Independently of the Mongo trail, image-registry emits eventCategory: 'audit'
structured log lines (via emitAudit 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.
Audit log lines land in Loki with service_name, eventCategory, event,
actor, and pluginName promoted to labels. From the UI, the Audit
Activity dashboard at /dashboard/observability/audit-activity is the
operator-facing surface; deep-link to a filtered 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`
registry.tag.copyEmitted 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.deleteEmitted 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 |
Platform-emitted (user/org lifecycle):
AuditAction union AND the ALL_AUDIT_ACTIONS array in
platform/src/models/audit-event.ts.audit(req, 'new.action', { targetType, targetId, affectedOrgId, details })
from the controller after the mutation succeeds. Keep secrets / tokens / AWS
account ids out of details.Service-emitted (a non-platform service):
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).getAuditClient().record({ action, actorId, orgId, targetId, details }, '<service>') after the mutation succeeds.Use the dot-separated <area>.<entity>.<verb> naming convention so events sort
and filter cleanly.