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.
What makes the trail evidence
Section titled “What makes the trail evidence”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.
atis the writing server’s clock andrecordedAtis the store’s own. When they disagree, the disagreement is itself evidence.
The event catalogue
Section titled “The event catalogue”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.
| Area | Actions | Notes |
|---|---|---|
| Access | access.role_granted, access.role_changed, access.role_revoked | Board-level role changes, with the previous and new role in the record |
| Sharing | sharing.link_created, sharing.link_access_changed, sharing.link_revoked, sharing.board_made_public, sharing.board_made_private | Share-link lifecycle and public exposure of a board |
| Board governance | governance.board_created, governance.board_renamed, governance.board_ownership_changed, governance.board_deleted, governance.board_restored | Soft and permanent deletion are distinguished by a soft flag |
| Configuration | config.team_setting_changed | Enterprise settings changes |
| User lifecycle | login_success, member_added, member_removed, member_updated, member_provisioned, member_deactivated, the sso_* family | On the enterprise trail, and delivered by the same feed. Every administration API mutation is attributed to the key that made it |
| Audit integrity | governance.audit_gap_recorded | Written 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 |
| Export | export.audit_exported | Written when the feed delivers records, attributed to the credential that read them |
Two trails, one feed
Section titled “Two trails, one feed”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" |
actorVerified | true 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:
- Sign-in is recorded, but your identity provider remains the authoritative
record.
login_successis 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. - 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. Thegovernance.audit_gap_recordedaction exists so that a known gap is a record rather than a silence.
The feed
Section titled “The feed”GET /v1/auditOn the Team Administration API, with the same bearer key and the same enterprise gating.
| Parameter | |
|---|---|
since | Lower bound, ISO-8601 or epoch milliseconds. Defaults to 24 hours ago |
until | Upper bound, exclusive |
category | access, sharing, governance, config, auth, export or recovery. Applies to both trails |
limit | Records per page. Default 200, maximum 1000 |
cursor | Resume token from the previous response. Send it back verbatim |
format | json (default) or ndjson, one record per line for a log shipper |
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}How to poll it correctly
Section titled “How to poll it correctly”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.
Microsoft Sentinel ingestion pattern
Section titled “Microsoft Sentinel ingestion pattern”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.
1. Create the custom table
Section titled “1. Create the custom table”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.
| Column | Type | From |
|---|---|---|
TimeGenerated | datetime | at |
RecordedAt | datetime | recordedAt |
EventId | string | id |
Action | string | action |
Category | string | category |
Outcome | string | outcome |
ActorId | string | actor.id |
ActorType | string | actor.type |
ActorEmail | string | actor.email |
TeamId | string | teamId |
SubTeamId | string | subTeamId |
TargetType | string | target.type |
TargetId | string | target.id |
TargetName | string | target.name |
CorrelationId | string | correlationId |
Source | string | source |
Reason | string | reason |
ContentHash | string | contentHash |
Trail | string | trail |
ActorVerified | bool | actorVerified |
Metadata | dynamic | metadata |
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.
2. Data Collection Rule
Section titled “2. Data Collection Rule”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, Metadata3. The poller
Section titled “3. The poller”A Logic App on a five-minute recurrence, holding the cursor in a state variable:
- Initialise
cursorfrom the last run’s output, empty on first run. - HTTP GET the feed. On the first run send
sincecovering the period you want to backfill; on every later run sendcursor. Put the API key in Key Vault and reference it, rather than in the connector definition. - Send the
eventsarray to the Data Collection Endpoint’s/dataCollectionRules/{dcrImmutableId}/streams/Custom-BizbyAudit_CLpath, authenticated with the Logic App’s managed identity holding the Monitoring Metrics Publisher role on the DCR. - Store
nextCursorfor the next run. Store it after the ingestion call succeeds, so a failed post is retried rather than skipped. - Loop while
hasMoreis 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.
4. Analytics rules worth having
Section titled “4. Analytics rules worth having”The three that map most directly onto what the trail records:
// A board made publicly accessibleBizbyAudit_CL| where Action == "sharing.board_made_public"| project TimeGenerated, ActorEmail, TargetName, TargetId, SubTeamId// Ownership moving off a board, which precedes most accidental data lossBizbyAudit_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.
Retention
Section titled “Retention”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.
Other consumers
Section titled “Other consumers”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.