Skip to content
Go to App

Audit Log and SIEM Integration

Bizby keeps an append-only audit trail of security-relevant and administrative activity, and exposes it over an authenticated REST feed that a SIEM can poll unattended. This page is the reference for both: what is recorded, how the feed behaves, and a worked ingestion pattern for Microsoft Sentinel.

It is available to enterprise teams and is included in the enterprise subscription. There is no additional licence, connector charge or per-event cost.

The distinction that governs the design is between telemetry and evidence. Board History is telemetry: the browser reports what it did, the server sanitises it, and a lost row is a lost line in a log. An audit event is evidence, and a record saying “this browser claims Marty revoked a share link” proves nothing about who revoked it.

So:

  • Append-only. Every write goes through one module. There is no update path and no delete path in it, and an automated check fails the build if any other file in the codebase reaches the collection. Firestore rules deny clients outright, including the owner of the team the records describe.
  • Server-established actor. The actor, the tenant and the outcome are established by the server that observed them. None of the three can be built from a request body.
  • Idempotent. A record’s identity is derived from what the event is, not when it was sent, so an action retried after a timeout is recorded once.
  • Tamper-evident. Each record carries a digest over its own content, so altering one in place is detectable by re-deriving it. A digest proves a record was not edited. It does not prove every record still exists, which is why streaming records off the platform into a SIEM you control is the stronger position and what this page is for.
  • Two clocks. at is the writing server’s clock and recordedAt is the store’s own. When they disagree, the disagreement is itself evidence.

Records are grouped into categories. The table below maps them to the five areas an enterprise audit requirement normally names, and states plainly which trail each is on today.

AreaActionsNotes
Accessaccess.role_granted, access.role_changed, access.role_revokedBoard-level role changes, with the previous and new role in the record
Sharingsharing.link_created, sharing.link_access_changed, sharing.link_revoked, sharing.board_made_public, sharing.board_made_privateShare-link lifecycle and public exposure of a board
Board governancegovernance.board_created, governance.board_renamed, governance.board_ownership_changed, governance.board_deleted, governance.board_restoredSoft and permanent deletion are distinguished by a soft flag
Configurationconfig.team_setting_changedEnterprise settings changes
User lifecyclelogin_success, member_added, member_removed, member_updated, member_provisioned, member_deactivated, the sso_* familyOn the enterprise trail, and delivered by the same feed. Every administration API mutation is attributed to the key that made it
Audit integritygovernance.audit_gap_recordedWritten by the system when the trail is known to have missed an event. It never names an actor, because that is exactly what was not captured
Exportexport.audit_exportedWritten when the feed delivers records, attributed to the credential that read them

Bizby keeps two audit trails and the feed delivers both, in one time-ordered stream, through one cursor. Every record says which trail it came from:

Field
trail"canonical" or "legacy"
actorVerifiedtrue where the actor was established from the authenticated caller or the write credential

Both trails are written only by the server and neither can be altered by a client. The distinction is narrower than trusted against untrusted: canonical records are derived from the write itself, so they cannot be missed by a code path that forgets to log, which is why newer events are written there and the trail is growing. actorVerified is false for exactly one action today, data_residency_changed, whose emitter reads the actor from a document field rather than from the session.

Filtering by category works across both.

Two things are said plainly rather than left to be discovered:

  1. Sign-in is recorded, but your identity provider remains the authoritative record. login_success is delivered by the feed. It is not a substitute for the provider’s own log, which is where the full picture lives: the source address, the device, whether MFA was satisfied, and every failed attempt that never reached Bizby at all. For Google Workspace and Microsoft Entra ID that log is already a supported Sentinel connector, and running both is the correct configuration rather than a redundancy.
  2. Denied attempts are recorded where a server was in the path. An action refused by an endpoint is recorded with outcome: "denied" and a reason. An action refused by a security rule before any application code ran is not, because nothing observed it. The governance.audit_gap_recorded action exists so that a known gap is a record rather than a silence.
GET /v1/audit

On the Team Administration API, with the same bearer key and the same enterprise gating.

Parameter
sinceLower bound, ISO-8601 or epoch milliseconds. Defaults to 24 hours ago
untilUpper bound, exclusive
categoryaccess, sharing, governance, config, auth, export or recovery. Applies to both trails
limitRecords per page. Default 200, maximum 1000
cursorResume token from the previous response. Send it back verbatim
formatjson (default) or ndjson, one record per line for a log shipper
Terminal window
curl "https://northamerica-northeast1-bizby-prod-ca-nane1.cloudfunctions.net/adminApi/v1/audit?since=2026-09-29T00:00:00Z&limit=500" \
-H "Authorization: Bearer bzk_xxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
{
"events": [
{
"id": "b2f1c8...",
"schemaVersion": 1,
"at": "2026-09-29T13:41:02.118Z",
"recordedAt": "2026-09-29T13:41:02.446Z",
"action": "sharing.board_made_public",
"category": "sharing",
"outcome": "success",
"actor": { "id": "uid_9f3", "type": "user", "email": "someone@example.gc.ca" },
"teamId": "team_abc",
"subTeamId": "sub_delivery",
"target": { "type": "board", "id": "doc_771", "name": "Service blueprint" },
"correlationId": "4f1e...",
"source": "functions",
"contentHash": "9a7c...",
"metadata": { "accessLevel": "viewer" }
}
],
"hasMore": false,
"nextCursor": "MTc2OTY5MjA2MjExODpiMmYxYzg",
"watermark": "2026-09-29T13:42:10.000Z",
"lagSeconds": 60
}

Send nextCursor back verbatim. It encodes a position, not just a time, and it is what makes delivery exactly-once across pages.

Poll once a minute. The feed deliberately trails real time by lagSeconds, which is 60, so polling faster than that returns nothing new by construction.

Drain a backlog. While hasMore is true, poll again immediately rather than waiting for the next interval. A backlog clears at limit records per request, and draining this way is expected rather than merely tolerated.

There is a rate limit, and correct polling never meets it. 120 requests per minute per key, roughly two orders of magnitude above what the guidance above needs. It exists to stop a runaway client, not to meter normal use. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a refused request returns 429 with Retry-After in seconds. If your pipeline is seeing 429s, something is looping rather than polling.

Expect to be a minute behind, on purpose. The feed never returns an event newer than watermark, which trails real time by lagSeconds. This is the one design decision worth understanding, because the alternative loses records silently:

Two servers write events using their own clocks, and a database trigger can be delivered late. An event stamped 12:00:03 can therefore land in the collection after one stamped 12:00:07. A consumer whose watermark has already advanced past 12:00:07 will never see it, and nothing anywhere reports the gap. The lag window gives a record still in flight a bounded time to arrive before the watermark passes its timestamp.

Records sharing a millisecond are both delivered. The cursor orders by timestamp and record identity together, so a tie cannot drop one of them.

A rejected cursor means start from a time, not from the beginning. If the feed answers invalid_cursor, re-poll with a since covering the period you have not yet ingested. The feed refuses a cursor it cannot read rather than silently restarting, which would replay your entire retained history as though it were new activity.

Sentinel ingests custom sources through the Logs Ingestion API: a custom table in a Log Analytics workspace, a Data Collection Endpoint, and a Data Collection Rule that maps incoming records to the table. The poller in between is a Logic App, a Function App, or any scheduler you already run. The shape below is the Logic App version, because it needs no code deployment.

In the Log Analytics workspace, create a table named BizbyAudit_CL with these columns. TimeGenerated is required by Log Analytics; the rest map one to one onto the feed’s fields.

ColumnTypeFrom
TimeGenerateddatetimeat
RecordedAtdatetimerecordedAt
EventIdstringid
Actionstringaction
Categorystringcategory
Outcomestringoutcome
ActorIdstringactor.id
ActorTypestringactor.type
ActorEmailstringactor.email
TeamIdstringteamId
SubTeamIdstringsubTeamId
TargetTypestringtarget.type
TargetIdstringtarget.id
TargetNamestringtarget.name
CorrelationIdstringcorrelationId
Sourcestringsource
Reasonstringreason
ContentHashstringcontentHash
Trailstringtrail
ActorVerifiedboolactorVerified
Metadatadynamicmetadata

Trail and ActorVerified are worth carrying rather than dropping. An analyst looking at an alert should be able to see how an event was attributed without leaving the record, and a hunting query can treat the two trails differently if it needs to.

The transform maps the feed’s JSON onto the table. TimeGenerated must be set explicitly, or Sentinel stamps records with their ingestion time and every record in a backlog drain arrives with the same timestamp.

source
| extend
TimeGenerated = todatetime(at),
RecordedAt = todatetime(recordedAt),
EventId = tostring(id),
Action = tostring(action),
Category = tostring(category),
Outcome = tostring(outcome),
ActorId = tostring(actor.id),
ActorType = tostring(actor.type),
ActorEmail = tostring(actor.email),
Trail = tostring(trail),
ActorVerified = tobool(actorVerified),
TeamId = tostring(teamId),
SubTeamId = tostring(subTeamId),
TargetType = tostring(target.type),
TargetId = tostring(target.id),
TargetName = tostring(target.name),
CorrelationId = tostring(correlationId),
Source = tostring(source),
Reason = tostring(reason),
ContentHash = tostring(contentHash),
Metadata = todynamic(metadata)
| project TimeGenerated, RecordedAt, EventId, Action, Category, Outcome,
ActorId, ActorType, ActorEmail, TeamId, SubTeamId,
TargetType, TargetId, TargetName, CorrelationId, Source,
Reason, ContentHash, Metadata

A Logic App on a five-minute recurrence, holding the cursor in a state variable:

  1. Initialise cursor from the last run’s output, empty on first run.
  2. HTTP GET the feed. On the first run send since covering the period you want to backfill; on every later run send cursor. Put the API key in Key Vault and reference it, rather than in the connector definition.
  3. Send the events array to the Data Collection Endpoint’s /dataCollectionRules/{dcrImmutableId}/streams/Custom-BizbyAudit_CL path, authenticated with the Logic App’s managed identity holding the Monitoring Metrics Publisher role on the DCR.
  4. Store nextCursor for the next run. Store it after the ingestion call succeeds, so a failed post is retried rather than skipped.
  5. Loop while hasMore is true, so a backlog drains within the run.

Recommended interval is one to five minutes. Shorter than the feed’s lagSeconds gains nothing, since the watermark will not have moved.

The three that map most directly onto what the trail records:

// A board made publicly accessible
BizbyAudit_CL
| where Action == "sharing.board_made_public"
| project TimeGenerated, ActorEmail, TargetName, TargetId, SubTeamId
// Ownership moving off a board, which precedes most accidental data loss
BizbyAudit_CL
| where Action == "governance.board_ownership_changed"
| project TimeGenerated, ActorEmail, TargetName, Metadata
// The trail reporting its own gap. This should be empty; alert if it is not.
BizbyAudit_CL
| where Action == "governance.audit_gap_recorded"

5. Prove the pipeline, then leave it running

Section titled “5. Prove the pipeline, then leave it running”

Ingestion that has never been verified end to end is not evidence of anything. Make one change that the trail records, such as creating and then revoking a share link on a test board, wait past lagSeconds, and confirm both records arrive in BizbyAudit_CL with the right actor. Then confirm the cursor advanced by checking that the next run ingests nothing.

Audit records carry a retention floor measured in years rather than days, and a tenant may configure a longer period but not a shorter one. Once records are in your own SIEM, their retention is governed by your workspace policy, which is usually the answer to a records-retention obligation rather than ours.

Scheduled deletion inside Bizby is asynchronous housekeeping: records are removed some time after their expiry, with no commitment at the instant. It is not, and is never described as, erasure at a deadline.

The feed is ordinary authenticated REST and nothing about it is Sentinel specific. Splunk, Elastic, Chronicle and Datadog all ingest a polled JSON or NDJSON endpoint with a cursor. Use format=ndjson where the shipper prefers one record per line.

A person can also export filtered records to CSV from Team settings, Audit log. That path is for an investigation or a request for records, not for monitoring: it is a person pressing a button, and a SIEM needs the feed.