Security audit trail
Beacon records every security-relevant action in one append-only trail: sign-ins and sign-outs, changes to users,
roles, API keys and project access, data sources, recipients and subscriptions, settings, saved queries, approvals,
ownership, alert tasks, grounding curation, data contracts and migration jobs, and every read of audit data. Each event
says who acted, what they did, how it ended and what it concerned, carries a keyed signature over its fields, and is
copied to the Beacon.Audit log stream once it is committed, so a SIEM holds a copy outside Beacon’s database.
The trail is read by an Auditor (an Admin holds the permission too, see Functional permissions) on the Security Audit page or through the API. It is separate from the MCP audit, which records every MCP tool call and the statements it ran.
The database is the source of truth. The SecurityAuditEvents table is the trail; the log-stream copy is a
best-effort second copy (see What the trail does and does not prove).
What an event records
Section titled “What an event records”Events are stored in the SecurityAuditEvents table (security_audit_events on PostgreSQL), one row per event. No
column has a foreign key: an event outlives the user, key or project it names.
| Field | Meaning |
|---|---|
Id, EventGuid | The row id, and a unique id the log-stream copy carries too. |
CreatedTime | When the event was recorded (UTC, kept to the microsecond). |
Category, Action | The event family and what happened, named {category}.{what happened} in the past tense; see the catalogue. |
Outcome | success, failure (the attempt did not succeed, such as a wrong password), refused (Beacon refused it by rule: local login off, a setup token that does not match, a purge of events that fail verification) or empty (there was nothing to act on: a verification that found no event to check, which is not a success). |
ActorKind | How the actor was authenticated: user, session (a signed-in session without a stored Beacon user), api_key, mcp_caller, system (Beacon’s own background work) or anonymous. |
ActorUserId | The acting user’s Users.Id (an API key’s owner); null for a caller without a stored Beacon user. |
ApiKeyId | The API key the actor used. |
CallerHash | For a caller without a Beacon user (a single sign-on subject, a host session, an MCP caller, the name typed at a failed password sign-in): a keyed hash, never the subject or the name itself. For an MCP caller it is the same hash the MCP audit records. |
ProjectId | The project the action concerns, when it concerns one. |
ResourceType, ResourceId | What was acted on: user, api_key, project, data_source, repository, query, subscription, recipient, data_contract, migration_job, ai_actor, ai_actor_plan, approval_request, change_approval_request, task, settings, learned_pattern, documentation_patch, eval_case, schema_relationship, dashboard, glossary_term, documentation_section, mcp_audit or security_audit. |
DetailsJson | What else the event needs, as a small JSON object of identifiers, counts, flags, codes and keyed digests (below). |
TraceId | The W3C trace id of the request or job that recorded the event. |
KeyId, Hmac | The integrity key the event was signed with, and the signature; see Integrity. |
The details hold identifiers (addedRoleIds, removedRoleIds, recipientIds, changeApprovalId, the settings-history
ids of a settings change), counts, flags (secretChanged, destinationChanged, connectionChanged, forced), times
(UTC, to the second) and short codes (a reason, a kind, the keys of the settings that changed, the fields of a data
source that changed, a status before and after). People a resource records by their identity-provider id (an owner, a
requester, an assignee) are written as their Users.Id, or null for nobody or an unknown id.
A settings change also carries, for each key it changed and in the order of changedKeys, a keyed digest of the value
before and after (beforeDigests, afterDigests: 32 hex characters under a key derived from the integrity key; an
absent value has a digest of its own). The values never go into the event; the digests let an Auditor tell whether the
settings history still holds the values the event was sealed over; a history row edited later no longer matches.
What is never stored
Section titled “What is never stored”The trail names people by id only and keeps no secret. The details are restricted by construction, not left to care at each event site:
- Never stored: names, display names and user names, e-mail addresses, the sign-in name someone typed, external
(identity-provider) ids, IP addresses, user agents, SQL, questions, comments, notes, descriptions, free-text errors,
setup tokens, passwords, API keys, credentials, connection strings, setting values and recipient destinations. A
change of a recipient’s destination or secret, or of a data source’s connection, is recorded as a flag
(
destinationChanged,secretChanged,connectionChanged), never the value. A single sign-on subject is recorded only as its keyedCallerHash; so is the name typed at a failed password sign-in (trimmed and lower-cased, under a key of its own), so attempts on one name can be told from attempts spread over many without the name being stored. - Codes only. A text detail is kept only when it is a code (letters, digits and
_ . : -, at most 64 characters) and its key does not name text (a name, an e-mail address, mail, SQL, a question, a comment, a note, a description, text, a message, a destination, a URL, a token, a secret, a password, a header, a body, an address, an agent, a subject, an external id or a login, in any case and anywhere in the key). Anything else is stored as[withheld], so a mistake at an event site cannot store free text; the Security Audit page shows such a detail as withheld. - Bounded. A list of ids keeps its first 50 entries and records how many there were (
<key>Count). The whole details object stays under 2,000 characters: when it would not, every single value is kept and the lists are shortened, the longest first, each one’s full length recorded as<key>Count, and the details are marked"truncated": true.
Event catalogue
Section titled “Event catalogue”Every action belongs to one category, the part of its name before the dot.
Authentication (auth), recorded on their own, since a sign-in has no change to join (see
How events are written).
| Action | When |
|---|---|
auth.password_sign_in_succeeded | A password sign-in issued a session. |
auth.password_sign_in_failed | A password sign-in did not succeed (failure); names the user the sign-in name resolved to, when it did, and the attempt by a keyed hash of the name typed. |
auth.password_sign_in_refused | A password sign-in refused by rule (refused): local login is off and the account is no break-glass account (local_login_off), or the password was right but an administrator set it and the user must choose a new one first (password_change_required). |
auth.break_glass_signed_in | A break-glass account signed in with its password while local login is off. |
auth.signed_out | A session signed out. |
auth.signed_out_everywhere | Every session of a user was ended (Sign out everywhere, by the user or an administrator). |
auth.session_ended | Beacon ended a session: refused at sign-in or at a check against the user store (the user was disabled, archived or their sessions revoked), with the reason. |
auth.sso_signed_in | A single sign-on was admitted. |
auth.sso_refused | A single sign-on was refused, with the reason; the subject only as its keyed hash. |
auth.setup_token_refused | First-run setup was attempted with a setup token that does not match. |
Identity (identity)
| Action | When |
|---|---|
identity.super_admin_created | First-run setup created the super admin. |
identity.user_created | An administrator created a user. |
identity.user_provisioned | Single sign-on created a user at their first sign-in. |
identity.user_updated | A user’s account changed (which fields changed, as flags, and whether their sessions and API keys ended with it). |
identity.user_enabled, identity.user_disabled, identity.user_archived | A user was enabled, disabled or archived (with how many of their API keys were revoked). |
identity.password_changed | A user changed their own password. |
identity.password_reset | An administrator set a user’s password. |
identity.roles_changed | A user’s roles changed (role ids added and removed). |
identity.owned_resources_read | Someone read what a user owns, created or changed last (the kind and the page). |
API keys (keys): keys.api_key_created, keys.api_key_revoked (by its owner) and
keys.api_key_revoked_by_administrator.
Project access (access): access.project_created, access.member_added, access.member_role_changed,
access.member_removed, access.access_mode_changed (a project opened or restricted), access.data_sources_linked,
access.data_source_unlinked, access.repository_token_changed (whether a token is now set, never the token),
access.repository_scan_requested, access.dashboard_shared (the grantee as Users.Id and the permission level) and
access.dashboard_share_revoked. The project-access log events (ProjectAccessEvents, 7101 onwards) are still written
as before; refused and hidden reads of a project (7108, 7109) and skipped scheduled work (7111) stay on the log only, for
their volume.
Data sources (data_sources): data_sources.data_source_created, data_sources.data_source_updated (which fields
changed, by name), data_sources.credentials_rotated (the connection changed: its credentials, host or database; never
a value), data_sources.data_source_deleted, data_sources.metadata_refreshed (a forced refresh someone started, and
whether they were exempt from its cooldown) and data_sources.migration_writes_changed. An edit that changes the
connection is recorded as credentials_rotated only, with the other fields it changed.
Delivery (delivery): delivery.recipient_created, delivery.recipient_updated (which fields changed, whether the
destination or the secret changed, and the change request that governs it), delivery.recipient_deleted,
delivery.subscription_created, delivery.subscription_updated, delivery.subscription_archived,
delivery.subscription_reactivated, delivery.subscription_recipients_added,
delivery.subscription_recipient_removed and delivery.subscription_sla_changed (the SLA hours before and after).
Settings (settings): one event per save that changes something (settings.mcp_settings_changed,
settings.mcp_project_settings_changed and settings.admin_settings_changed), naming the keys that changed, the
settings-history rows that hold the values before and after, and a keyed digest of each value (above). The values
themselves never go into the event; an Auditor reads them in the settings history.
Saved queries (queries): queries.lock_changed (a query locked or unlocked) and queries.mcp_tool_changed (a
query exposed to MCP callers as a tool, renamed, or withdrawn).
Approvals (approvals): a change submitted for a second person’s review, and the decision on it.
approvals.query_change_submitted, approvals.query_change_approved, approvals.query_change_rejected (query
versions); approvals.change_submitted, approvals.change_approved, approvals.change_rejected (change requests of
migration jobs, Custom SQL rules and recipients, with the requests a submission supersedes);
approvals.ai_plan_submitted, approvals.ai_plan_approved, approvals.ai_plan_rejected and
approvals.ai_plan_revision_requested (AI actor plans).
Ownership (ownership): ownership.owner_changed: the owner of a subscription, data contract or AI actor changed,
with the previous and the new owner as Users.Id. Setting the owner a resource already has changes nothing and records
nothing.
Alert tasks (tasks): tasks.task_claimed, tasks.task_reassigned, tasks.task_resolved and
tasks.task_auto_resolved (recorded with the system actor when the data recovered).
Grounding curation (curation): curation.pattern_status_changed (status before and after),
curation.documentation_patch_applied, curation.documentation_patch_rejected, curation.golden_case_promoted,
curation.golden_case_edited, curation.relationship_created, curation.relationship_verified,
curation.relationship_unverified, curation.relationship_edited, curation.relationship_deleted,
curation.grounding_rechecked (a run of the grounding recheck, with what it checked and refused),
curation.glossary_term_created, curation.glossary_term_updated, curation.glossary_term_deleted and
curation.documentation_section_edited.
Data quality (data_quality): data_quality.contract_created, data_quality.contract_updated,
data_quality.contract_archived and data_quality.contract_evaluated (an evaluation someone started; scheduled
evaluations are not recorded).
Data migrations (migrations): migrations.job_created, migrations.job_deleted, migrations.job_started (a run
began (by the caller, or the system actor for a scheduled run), recorded before it touches a data source, with the
execution id and the source and destination data sources) and migrations.job_run_ended (success, failure with the
row counts, or refused before it started, with the reason: not_approved, write_target_refused).
The audit itself (audit): audit.read (every read of the trail, with the filters’ shape and the page),
audit.verified (every verification, with its counts; outcome failure when an event failed, empty when nothing was
checked; the scheduled verification’s also with its checkpoint id and the ids it passed as never committed),
audit.purged (every retention purge, with the number of events it verified, deleted and left for the next purge;
refused, deleting nothing, when an event it would delete fails verification) and
audit.mcp_audit_read (every read of the MCP audit
export, with the filters’ shape and the page).
How events are written
Section titled “How events are written”- With the change they record. An event that records a change is added to the same unit of work as the change
(
ISecurityAuditRecorder.StageAsync, orSaveWithEventsAsyncwhen the event needs what the save produces, such as a new id): it commits with the change and rolls back with it, so a rolled-back change leaves no event and an event that cannot be stored fails its change. The actor is resolved before the change runs, so no second connection is opened while the change holds its locks. - Retries.
SaveWithEventsAsyncopens its transaction through the context’s execution strategy (unless the caller’s transaction is open, whose own strategy then owns retries). A bulk update or delete is run again on a retried attempt, on a cleared change tracker, so no event of the failed attempt is saved twice. Tracked changes are saved again only when the failed attempt never saved them; once a save accepted them, the retry fails with nothing committed, and the request fails rather than saving a change without its event. - On their own. An event without a change to join (a sign-in, a refusal) is written by
ISecurityAuditRecorder.RecordAsync, which never fails the action it records. It is not cancelled with the request (an aborted sign-in attempt is still recorded) but is given up after five seconds, so a database that does not answer never holds up a sign-in. An event that could not be written goes to the log stream instead, with every field it would have been stored with (Beacon.Audit9107 at Error,Persisted=False), and is counted on thebeacon.security_audit.write_failurescounter (taggedbeacon.security_audit.action). Alert on that counter: each increment is an event the database does not hold. - Reads of audit data fail closed. A read of the trail, of the MCP audit export or of a user’s owned resources, and a verification, is recorded before anything is returned, and fails when its event cannot be written: audit data is never handed out without a record of who read it. An identical read (the same filters and page) by the same actor within 60 seconds is recorded once per Beacon process, so paging back and forth does not flood the trail.
- Append-only, inside Beacon. Beacon refuses to change or delete an event through its data context (an
InvalidOperationException, before any statement is sent); only the retention purge deletes events. See What the trail does and does not prove for what lies outside Beacon. - The actor is the request’s caller as Beacon resolved it (
IBeaconActorAccessor), never a value from the request body; sign-in events name the user they signed in.
A host that wires Beacon’s sign-in endpoints without AddBeaconServices must register ISecurityAuditRecorder itself:
the sign-in events require it, and a sign-in without it fails instead of going unrecorded.
Reading the trail
Section titled “Reading the trail”The Security Audit page (in the Admin section of the sidebar, shown only to an Auditor or an Admin) lists the events newest first, 50 to a page, for a window of whole UTC days: the last seven by default, at most 93. Filter by category, action (the actions of the chosen category), actor user id, resource type and id, and project id; click an actor to show only their events. Details shows every field of an event and its details, a withheld value as withheld. The page reads nothing while the window is invalid.
The same read is GET /beacon/api/security-audit (Auditor; any other caller gets 403):
| Query parameter | Required | Notes |
|---|---|---|
from, to | Yes | UTC (a value without an offset is taken as UTC). to must not be earlier than from, and the window must not exceed 93 days (400 otherwise). |
category, action, actorUserId, resourceType, resourceId, projectId | No | Exact-match filters. |
page, pageSize, sort | No | Zero-based page, page size up to 200 (default 20), newest first unless sort says otherwise. |
Every read is recorded first (audit.read, and Beacon.Audit 9105 on the log stream with the count returned), with
which filters were used (a filter’s text value only when it is a code) and fails when it cannot be. The trail is not
filtered by project membership: an Auditor reads every project’s events, which hold no project data.
Integrity: signatures and verification
Section titled “Integrity: signatures and verification”Each event carries an HMAC-SHA256 over every other field but its database id (in a fixed order, with its time to the
microsecond and the id of the key) under the current integrity key, and the id of that key (KeyId, k1- and a digest
that reveals nothing of the key). The database id is not covered: it is generated by the database after the event is
sealed.
Verify checks events against their signatures:
- On the Security Audit page, Verify shown events checks the events of the current page and Verify on a row (or in its details) checks one. Each row then shows the verdict the server gave it: Intact, Modified (changed or forged), Unknown key (signed with a key this deployment does not hold), Not found (not in the window), or Unverified when the answer named no verdict for it; only Intact says the event verified. A banner gives the counts; a verification that found nothing to check says Nothing was verified (it is not a success), and while another verification runs the page says Verification busy.
POST /beacon/api/security-audit/verifywith{ "from": "…", "to": "…" }checks every event of a window (at most 93 days), and with"eventIds": [ … ](at most 200) only those events of it. Events are read in batches of 1,000. The answer:
| Field | Meaning |
|---|---|
checkedCount, intactCount | Events checked, and how many of them verified. |
modifiedCount | Events whose fields no longer match their signature: changed or forged, or written without the key. |
unknownKeyCount | Events signed with a key this deployment does not hold (a key no longer configured, or a forged key id). It may be genuine; Beacon cannot tell. |
outOfRangeCount | Events dated outside the range a genuine event can have (later than five minutes past now, or older than the retention plus two days, taking the longest retention accepted while none is set), counted as failing and not checked. |
outcome | success (every checked event intact), failure (any event failed or was out of range) or empty (nothing was checked, which is not a success). |
failedEventIds | The ids of the first 100 failing events; the counts say how many failed. |
verdicts | For a verification by id: one { "eventId", "verdict" } per requested id, in the order asked: intact, modified, unknown_key or not_found (not in the window: never stored, deleted, or out of range). null for a whole window. |
One verification runs at a time per Beacon process, the scheduled one included; another request answers 429 until it
has finished. Every verification is recorded (audit.verified, and 9105) before its result is returned, and fails when
it cannot be. A verification finds changed and forged events; it cannot find a deleted one, which leaves nothing to
check; see the checkpoints below.
Scheduled verification
Section titled “Scheduled verification”ISecurityAuditVerificationService.VerifyNewEventsAsync verifies the events written since its last run, so a changed or
forged event is found within the schedule’s period without anyone asking:
- It continues after the event id its last intact checkpoint names (a
systemaudit.verifiedevent; one that does not verify itself is passed over and logged) and checks every newer event, in id order, at most 50,000 per run; a backlog is worked off over the following runs. Events are selected by id alone, never by their time: the time is one of the fields the signature covers, so an event whose time was changed fails as modified, and one that verifies but is dated where no genuine event can be is counted as out of range. - The checkpoint advances only as far as every lower id has been checked. An id with no event below a stored one (an insert whose transaction has not committed yet) is waited for: the checkpoint stays below it, and the events above it, already checked, are checked again by the next run, which also checks the late event once it is there. Once the nearest intact event above the missing id is an hour old, the id is taken as never committed (an insert that rolled back still consumes its id) and passed; the checkpoint records how many ids it passed this way and the first and last of them, and the id still waited for, if any.
- Events that fail are logged at Error (
Beacon.Audit9108, with the counts and the first failing ids) and counted onbeacon.security_audit.verification_failures(tagged with the action,audit.verified). Alert on that counter. - Every run writes a checkpoint to the log stream (9109: the event id it started after and its checkpoint id, how many it checked and how many failed, the signature and key id of the checkpoint event, and the ids passed as never committed). Kept in your SIEM, the checkpoints anchor the trail outside the database: an event deleted later leaves a gap below a checkpoint that once covered it, and an id passed as never committed is named there.
- A run that checked events records them as a
systemaudit.verifiedevent, which is the next run’s starting point.
The sample host schedules it hourly as the Warp recurring job security-audit-verify (15 * * * *,
VerifySecurityAuditEventsJob in Beacon.SampleProject/Warp/Jobs/McpMaintenanceJobs.cs). A host embedding Beacon
schedules it itself, for example with a Warp job of its own like the sample’s:
await recurringJobPublisher.AddOrUpdateRecurringJob(new VerifySecurityAuditEventsJob(), "security-audit-verify", "15 * * * *");or by calling ISecurityAuditVerificationService.VerifyNewEventsAsync(cancellationToken) from its own scheduler.
Keys and rotation
Section titled “Keys and rotation”{ "Beacon": { "Audit": { "IntegrityKey": "<a secret of at least 32 characters>", "PreviousIntegrityKeys": [ "<the integrity key used until now>" ], "RetentionDays": null } }}Beacon:Audit:IntegrityKeyis the secret events are signed with: at least 32 characters, supplied like the encryption key (a secret store orBeacon__Audit__IntegrityKey), never committed. A value that is too short or contains${stops the host at startup, with a message that never contains the key. Unset, Beacon derives the key, for this purpose only, fromBeacon:Mcp:CallerHashKey, else fromBeacon:EncryptionKey; no secret is used as the signing key itself.Beacon:Audit:PreviousIntegrityKeyslists keys used earlier: accepted when verifying, never used to sign. Verification also accepts the keys derived from the caller hash key, the encryption key and eachBeacon:PreviousEncryptionKeysentry, so events signed under the derived key keep verifying while that secret is configured.- Set
IntegrityKeybefore you rotate the encryption key or the caller hash key. While it is unset, events are signed under a key derived from one of them, and once the old secret leaves the configuration the events signed under it verify as unknown key. With a dedicated integrity key, events written from then on do not depend on either. To keep verifying events signed before, keep their secret configured (as the caller hash key, a previous encryption key, or inPreviousIntegrityKeys). - To rotate the integrity key, set the new value and move the old one to
PreviousIntegrityKeys. Remove a previous key only once no event you still need to verify was signed with it. - At startup Beacon logs, at Information, the id of the key events are sealed under and where it comes from
(
integrity_key,derived_from_caller_hash_keyorderived_from_encryption_key), never the key, and, whileIntegrityKeyis unset, a Warning naming the setting the key is derived from.
An event under a key Beacon does not hold is reported as unknown key, not as intact: it may be genuine, but Beacon cannot tell.
Retention
Section titled “Retention”Events are kept forever by default. Set Beacon:Audit:RetentionDays (between 365 and 36,500; any other value stops
the host at startup) to delete older events with ISecurityAuditRetentionService.PurgeExpiredAsync, which does nothing
while RetentionDays is unset:
- Verified before deleted. The purge first verifies every event it is about to delete, in the transaction that
deletes them. While any of them fails (changed, or under an unknown key), it deletes nothing: it logs 9108 at Error with
the counts and the first failing ids, counts them on
beacon.security_audit.verification_failures(actionaudit.purged), records arefusedaudit.purgedevent with the failing ids, and throws, so its scheduled job fails visibly. Investigate the events it names before purging: tampered evidence is never deleted unseen. - Only what it verified is deleted. Otherwise the purge reads the expired events again, batch by batch, verifies each, and deletes the ones that verify, each by its id together with the signature it verified, never by date. An event changed after it was verified (its signature replaced), one that fails now, and one that became expired while the purge ran (an event whose time was moved back) are not deleted: they are left for the next purge, which verifies them before it deletes anything.
- Deleted with its record. The
audit.purgedevent (by thesystemactor, with the number of events it verified, deleted and left, and the retention) commits with the deletion, in one transaction;Beacon.Audit9106 follows, at Warning when it deleted fewer events than it verified or left expired events behind. - A purge that does not run (the database is unreachable, say) is logged at Error (9110, the failure’s type and code
only), counted on
beacon.security_audit.write_failures(actionaudit.purged) and rethrown. - At startup, a host with a retention set logs that the purge deletes nothing until the host schedules it.
The sample host schedules it as the Warp recurring job security-audit-retention (45 3 * * *,
PurgeExpiredSecurityAuditEventsJob in Beacon.SampleProject/Warp/Jobs/McpMaintenanceJobs.cs). A host embedding
Beacon schedules it itself, like the MCP audit purge, for example
with a Warp job of its own like the sample’s:
await recurringJobPublisher.AddOrUpdateRecurringJob(new PurgeExpiredSecurityAuditEventsJob(), "security-audit-retention", "45 3 * * *");or by calling ISecurityAuditRetentionService.PurgeExpiredAsync(cancellationToken) from its own scheduler.
Shipping to a SIEM
Section titled “Shipping to a SIEM”Every event is logged at category Beacon.Audit once the transaction that wrote it has committed; an event whose
change rolled back is never logged. Point your log shipping at this category (JSON console logs into CloudWatch, a
collector, a SIEM agent; see CloudWatch and collectors)
to keep a copy Beacon’s database cannot change.
| EventId | Name | Level | When | Fields |
|---|---|---|---|---|
| 9104 | SecurityAuditRecorded | Information | An event was committed. | Action, Outcome, EventGuid, AuditId, Persisted (True), ActorKind, ActorUserId, ApiKeyId, CallerHash, ProjectId, ResourceType, ResourceId, Details, TraceId |
| 9105 | SecurityAuditRead | Information | The trail was read or verified. | The window, which filters were used, the counts |
| 9106 | SecurityAuditPurged | Information (Warning when it left expired events behind) | The retention purge ran. | Deleted and verified counts, cutoff, retention days, expired events left |
| 9107 | SecurityAuditWriteFailed | Error | An event written on its own could not be stored. | The fields of 9104 with Persisted=False and no AuditId, and Failure (the failure’s type and code) |
| 9108 | SecurityAuditVerificationFailed | Error | The scheduled verification or the retention purge found stored events that fail verification (or a checkpoint that does). | The counts by verdict, the first failing ids |
| 9109 | SecurityAuditCheckpoint | Information | Each run of the scheduled verification. | FromEventId, MaxEventId, Checked, Failed, LastHmac, KeyId, NeverCommitted, FirstNeverCommittedId, LastNeverCommittedId, WaitingForEventId |
| 9110 | SecurityAuditPurgeFailed | Error | The retention purge did not run. | The cutoff, the failure’s type and code |
Match a log-stream copy to its row by EventGuid. The copy holds the same identifiers, codes and digests as the row,
nothing more.
| Metric | Type | Alert |
|---|---|---|
beacon.security_audit.write_failures | Counter, tagged beacon.security_audit.action | On any increment: an event written on its own was not stored (its 9107 copy holds it), or a purge did not run (audit.purged). |
beacon.security_audit.verification_failures | Counter, tagged beacon.security_audit.action | On any increment: stored events failed verification (audit.verified, audit.purged). |
The copy is best-effort. 9104 is written by the application after the commit, so it is missing when the log category is filtered out or below Information, when the process stops between the commit and the log write, and for a transaction committed directly on the underlying database transaction rather than through Beacon’s data context. The database is the source of truth; the copy, the 9107 lines and the checkpoints are what lets you notice when the two disagree.
What the trail does and does not prove
Section titled “What the trail does and does not prove”- Changes and forgeries are found. An event whose fields were changed, or that was written without the integrity key, fails verification: on request, every hour, and before the purge deletes it.
- Deletions are not found by verification. Append-only is enforced inside Beacon: its data context refuses to change
or delete an event. A database account with write access to the table can still delete rows, and a deleted row leaves
nothing to verify. The checkpoints (9109) and the 9104 copies in your SIEM are the anchor outside the database. To make
the table append-only at the database itself, give Beacon’s connection a role that may not update or delete
SecurityAuditEventsrows other than through the purge, or add triggers; that is the host’s to configure. A chain of signatures across events, which would make a deletion visible inside the trail, is not part of this version. - MCP audit rows are evidence, not sealed. The MCP audit rows and their statements carry keyed hashes of their content, but no signature over the row: they record what a call ran, and an edit at the database is not detected. The security audit trail records who read that export.
Table growth
Section titled “Table growth”The trail grows with use and, by default, is never trimmed. Sign-ins and reads of audit data are the most frequent
events; changes are rarer. A row is a few hundred bytes (details stay under 2,000 characters), indexed by time, by
actor and time, by resource, and by EventGuid. Plan for it like any audit table: watch its size, set
RetentionDays to what your retention policy requires (at least a year), and keep the long-term copy in your SIEM.
Who created and changed what
Section titled “Who created and changed what”Queries, subscriptions, recipients, data contracts and migration jobs also record who created them, who changed
them last, and when (CreatedByUserId, ModifiedByUserId, ModifiedTime; the caller’s identity-provider id, like a
resource’s owner, never a value from the request). An Auditor or a UserAdmin lists the resources a user owns, created or
changed last (for handing them over when the user leaves) in the user dialog or with
GET /beacon/api/users/{id}/owned-resources (see
User Management → Owned resources). Each read is recorded first
(identity.owned_resources_read); a UserAdmin sees the names only of the resources its projects reach, the others by id
and kind.
Upgrading
Section titled “Upgrading”This version records security-relevant actions in an append-only, signed trail an Auditor reads and verifies, verifies new events on a schedule, records who created and last changed queries, subscriptions, recipients, data contracts and migration jobs, records every read of audit data, keeps the statements every audited MCP call ran on a data source, and lets a deployment pin the MCP audit’s content on. See MCP observability and audit.
- Events start at the upgrade. Nothing is backfilled: the trail holds the actions taken after the upgrade only.
Earlier actions are only in the logs you kept (the project-access events 7101 onwards, the
Beacon.Auditevents 9100 onwards), which are written as before. - Existing resources have no attribution. Queries, subscriptions, recipients, data contracts and migration jobs that exist at the upgrade record no creator, no last modifier and no modification time until they are next changed; a user’s owned resources find them by owner only.
- Set
Beacon:Audit:IntegrityKey, before you change the encryption key or the caller hash key. Without it, events are signed with a key derived fromBeacon:Mcp:CallerHashKey, else fromBeacon:EncryptionKey; once the old secret leaves the configuration, events signed under it verify as unknown key. Supply it as a secret (at least 32 characters); see Keys and rotation. The host logs the key’s id and source at startup, and a Warning while it is unset. - Schedule two jobs. The sample host schedules the Warp jobs
security-audit-verify(hourly at:15; it verifies the events written since its last run and writes a checkpoint to the log stream) andsecurity-audit-retention(daily; a no-op untilBeacon:Audit:RetentionDaysis set). A host embedding Beacon schedulesISecurityAuditVerificationService.VerifyNewEventsAsyncandISecurityAuditRetentionService.PurgeExpiredAsyncitself; nothing is verified on a schedule, and nothing is purged, until it does. - Retention needs no action now. Events are kept forever until you set
Beacon:Audit:RetentionDays(365 to 36,500 days). The purge verifies the events it is about to delete first and, while any fails verification, deletes nothing, records a refusedaudit.purgedevent and throws (its job fails): investigate the events it names. - Alert on two counters.
beacon.security_audit.write_failures(an event written on its own was not stored, and the log stream holds it as 9107 withPersisted=False; or a purge did not run, 9110) andbeacon.security_audit.verification_failures(stored events failed verification, 9108). Ship theBeacon.Auditevents 9104 to 9110 to your SIEM; the database stays the source of truth, the 9104 copy is best-effort. - Reads of audit data fail closed.
GET /security-audit,POST /security-audit/verify,GET /mcp/auditandGET /users/{id}/owned-resourcesare recorded in the trail before they return anything and fail when that record cannot be written (so they fail while the database cannot take writes). An identical read by the same actor within 60 seconds is recorded once. One verification runs at a time per process; another answers429. - Owner changes are made only from the owner read.
PUT /subscriptions/{id}/owner,PUT /data-quality/contracts/{id}/ownerandPUT /ai-actors/{id}/owneranswer409when the owner was changed by someone else (or the resource archived) since it was read; nothing is changed. Setting the owner a resource already has stays a no-op: it changes nothing, records nothing and does not check whether that owner may still do the work. - A host that wires sign-in without
AddBeaconServicesmust registerISecurityAuditRecorder: the sign-in, sign-out and single sign-on events now require it, and fail the request without it instead of going unrecorded. - Plan for the table’s growth. One row per event, kept forever by default; sign-ins and reads of audit data are the most frequent.
- Where content is retained, the MCP audit now stores the SQL of every statement a call ran. Besides the question, an
askkeeps the text of its first execution, its repairs, its self-consistency candidates, its value-grounding probes and its dry runs (oneMcpAuditStatementrow each, at most 100 per call; past that, atruncatedmarker row counts the rest), and every audit row keeps a keyedContentHashof its parameters. Review this against your privacy policy: switch content retention off for projects that must not keep it, pin it off withBeacon:Mcp:ForceNoContentRetention, or bound it withBeacon:Mcp:Audit:RetentionDays. Under no retention, statements keep their keyedSqlHashand no text. Audit rows written before the upgrade have no statements and no content hash. MCP audit rows are evidence but not sealed: they carry no signature. - Audit content can be pinned on.
Beacon:Mcp:Audit:RequireContent: truepinsRetainQueryContenton, globally and for every project; a save that switches it off is refused with409, an Admin’s too, and the MCP Settings page shows the switch disabled with the reason. A call whose project settings cannot be read keeps its content under the lock (logged at Error). WithBeacon:Mcp:ForceNoContentRetentionon as well, the host does not start. - New pages for an Auditor. The Security Audit page lists, filters and verifies the trail (shown to an Auditor or an Admin only), marking each verified row with the server’s verdict; the user dialog of User Management shows a user’s Owned resources to an Auditor or a UserAdmin, a UserAdmin seeing the names only of resources its projects reach.
- Sign-in attempts name their user, never their name. A failed or refused password sign-in of an internal user is
recorded with the
Users.Idthe sign-in name resolved to and a keyed hash of the name typed, never the name:AuthenticationResult.BeaconUserIdis set on such a result for the audit (it never signs anyone in), also when the password was right but must be changed first, andAuthenticationResult.LocalLoginRefusedmarks a password refused only because local login is off. - More actions are recorded: data source edits (
data_sources.data_source_updated) and connection changes (data_sources.credentials_rotated, recorded from this version on), saved-query locks and MCP tool exposure (queries.*), dashboard shares, relationships created, glossary terms, documentation sections, migration job runs (migrations.job_started,migrations.job_run_ended) and reads of the MCP audit and of owned resources; settings changes carry keyed digests of each value before and after. See the catalogue.
Migration. AddSecurityAuditTrail (PostgreSQL 20261014130000, SQL Server 20261014130001) is additive and changes
no stored value: it creates security_audit_events (indexed by created_time, by actor_user_id and created_time, by
resource_type and resource_id, and uniquely by event_guid; no foreign keys) and mcp_audit_statements (a foreign
key to mcp_audit_logs that deletes a row’s statements with it, and an index on it), adds the nullable
mcp_audit_logs.content_hash (character varying(64) / nvarchar(64)), and adds the nullable created_by_user_id,
modified_by_user_id (character varying(200) / nvarchar(200)) and modified_time to queries, subscriptions,
recipients, data_contracts and migration_jobs (PascalCase names on SQL Server). Nothing is backfilled. Rolling it
back drops the tables and columns, and with them the trail. Append-only is enforced inside Beacon; to enforce it at the
database as well, give Beacon’s connection a role that cannot update or delete security_audit_events rows outside the
purge, or add triggers.
Public API.
- New types and members:
Beacon.Core.SecurityAudit:ISecurityAuditRecorder(StageAsync,SaveWithEventsAsync(two overloads),RecordAsync,CallerHashOf; registered withTryAdd, so a host may replace it),SecurityAuditEntry(On,InProject,With,Ending,By,Naming),SecurityAuditDetails,SecurityAuditActor(Anonymous,System,OfUser,OfCaller),SecurityAuditActions,SecurityAuditCategories(withQueries),SecurityAuditOutcomes(withEmpty),SecurityAuditResources(withDashboard,GlossaryTerm,DocumentationSection,McpAudit),SecurityAuditActorKinds,SecurityAuditIntegrity(CurrentKeyId,KeySource,Seal,Verify,Digest,StoredTime),SecurityAuditKeySources,SecurityAuditVerdict,SecurityAuditVerdicts(intact,modified,unknown_key,not_found),SecurityAuditLogEvents(9104–9110:Recorded,Read,Purged,WriteFailed,VerificationFailed,Checkpoint,PurgeFailed),ISecurityAuditRetentionService,ISecurityAuditVerificationServiceandSecurityAuditCheckpoint,SecurityAuditUserIdsandAttribution(RecordChangeBy, for aBeaconActoror the caller’s id).SecurityAuditOptions(Beacon:Audit:IntegrityKey,PreviousIntegrityKeys,RetentionDays) andMcpAuditOptions.RequireContent;BeaconTelemetry.SecurityAuditWriteFailures(beacon.security_audit.write_failures) andBeaconTelemetry.SecurityAuditVerificationFailures(beacon.security_audit.verification_failures).- Entities:
SecurityAuditEvent,McpAuditStatement,IAttributedEntity(implemented byQuery,Subscription,Recipient,DataContractandMigrationJob, which gainCreatedByUserId,ModifiedByUserIdandModifiedTime),McpAuditLog.ContentHashandStatements;BeaconContext.SecurityAuditEventsandMcpAuditStatements;SecurityAuditInterceptor(Beacon.Core.Data.Interceptors, added by both database providers beside the content-retention interceptor). Beacon.Core.Mcp:IMcpStatementLog(scoped),McpStatement,McpStatementsTaken(Dropped),McpStatementPurposes(withTruncated),McpStatementTables(Unparsed,More,IsMarker),McpStatementLogExtensionsandMcpContentHasher.- Requests:
GetSecurityAuditEventsQueryandSecurityAuditEventItem;VerifySecurityAuditCommand(From, To, EventIds)andVerifySecurityAuditResult(withOutOfRangeCount,OutcomeandVerdicts, a list ofSecurityAuditEventVerdict(EventId, Verdict));GetUserOwnedResourcesQuery,OwnedResourceItem(withNameWithheld) andOwnedResourceKind;McpAuditStatementItem;McpDeploymentLockandGetMcpDeploymentLocksResult.Locks. AuthenticationResult.LocalLoginRefused.- The sample host’s Warp jobs
PurgeExpiredSecurityAuditEventsJobandVerifySecurityAuditEventsJob, with their handlers (the sample is not shipped; copy them, or schedule the services another way).
- Changed signatures (code that constructs or implements these must change):
TaskServiceandAppSettingsServicetake anISecurityAuditRecorder(registered byAddBeaconServices); a host that builds either itself passes one.McpAuditLogItem(a positional record) gainsContentHashandStatements.IMcpStatementLog.TakeAll()returnsMcpStatementsTaken, anIReadOnlyList<McpStatement>that also counts the statements not kept: code that reads the list compiles unchanged; an implementation of the interface changes.
- Changed behaviour:
- The changes the trail records stage their event in the same unit of work, so an event that cannot be stored fails that change; events written on their own (sign-ins, refusals) never fail their action and give up after five seconds, logging the event (9107) instead. Reads of audit data fail when their event cannot be written.
ISecurityAuditRecorder.SaveWithEventsAsyncreplays a retried attempt only when the failed attempt never saved its tracked changes; otherwise the retry fails with nothing committed.ISecurityAuditRetentionService.PurgeExpiredAsyncthrowsInvalidOperationExceptionwhen it refuses (an expired event fails verification), and rethrows when it cannot run.- The sign-in events resolve
ISecurityAuditRecorderas a required service. AuthenticationResult.BeaconUserIdis also set on a failed password check, and on a right password that must be changed first, as above; saving MCP settings that contradictAudit:RequireContentthrowsSettingLockedException(409).- Owner changes of subscriptions, data contracts and AI actors throw
BeaconConflictException(409) when the owner changed since it was read. McpContentHasher.Hash(null)differs fromHash("").
- REST and OpenAPI:
- New routes:
GET /beacon/api/security-audit(from,to,category,action,actorUserId,resourceType,resourceId,projectId,page,pageSize,sort; a paged list ofSecurityAuditEventItem) andPOST /beacon/api/security-audit/verify(from,to, optionaleventIds; aVerifySecurityAuditResultwithcheckedCount,intactCount,modifiedCount,unknownKeyCount,outOfRangeCount,outcome,failedEventIdsandverdicts;429while another verification runs), both for an Auditor;GET /beacon/api/users/{id}/owned-resources(kindas a number,page,pageSize,sort; a paged list ofOwnedResourceItem, withnameWithheld), for an Auditor or a UserAdmin. - New schema
SecurityAuditEventVerdict(eventId,verdict). - New response fields:
locksonGET /beacon/api/mcp/settings/locks;contentHashandstatementson each row ofGET /beacon/api/mcp/audit. - New
409s:PUT /beacon/api/mcp/settingsandPUT /beacon/api/mcp/projects/{projectId}/settingsthat switch content retention off underAudit:RequireContent;PUT /beacon/api/subscriptions/{id}/owner,PUT /beacon/api/data-quality/contracts/{id}/ownerandPUT /beacon/api/ai-actors/{id}/ownerwhen the owner changed meanwhile. GET /beacon/api/mcp/auditand the security audit and owned-resources routes answer an error, and return nothing, when their read cannot be recorded.
- New routes: