Skip to content
BitExactdocs
Documentation
On this page

Identity, access scoping, and the access log#

BitExact is not an identity provider: a principal and role are asserted by your deployment and recorded, never minted here. On top of the coarse three-role RBAC (auditor-read < operator < admin), this adds SSO integration, access scoping by workflow/environment, and an access log of who viewed what.

SSO via a trusted reverse proxy#

Terminate SSO/OIDC at your reverse proxy (oauth2-proxy, an ingress with an auth filter, an API gateway) and have it inject headers, then set BITEXACT_TRUST_PROXY_AUTH=1. BitExact reads:

  • X-Forwarded-User — the principal (required; a missing user is a 401).

  • X-Forwarded-Role — one of the three built-in roles, or a custom role the deployment has defined with bitexact roles define. A header naming a role that does not exist, and a missing header, are both refused (401): a proxy that does not say which role has not made the decision, and BitExact will not make it on your behalf.

    This changed. An unknown or absent role used to resolve to auditor-read. If your proxy forwards a user without a role, it must now forward one, or those requests will be refused. Coercing meant a typo'd role granted read access while looking like it worked, and once a deployment can define its own roles there is no reason to believe auditor-read is the floor it intended.

  • X-Forwarded-Scope — the optional access scope (below).

Map your IdP's group membership to the role and scope headers at the proxy — that is the integration point, and it keeps BitExact out of the credential path. The alternative modes (a token→role map, a single bearer token, or the no-auth local/admin dev default) remain for non-SSO deployments.

The forwarded identity is only as trustworthy as the peer that sent it, so the headers are honored only from the proxy's own address. BITEXACT_TRUSTED_PROXY_IPS names it (comma-separated; default 127.0.0.1,::1, the loopback a sidecar proxy uses). A request carrying the headers from any other peer — someone who reached the viewer directly — is a 401, not the identity it claims. Set it to * only on a network where the viewer is reachable through the proxy alone.

The recorder verifies who acts#

Without it, a principal is the inspector's statement. In a deployment with a recorder (BITEXACT_RECORDER), the inspector hands each act to the recorder over mutual TLS. The recorder knows which certificate sent it (submitted_by), but the person the act names - who placed the hold, who asked for and who approved a redaction - reaches it only as the inspector's claim, and it records that as asserted. Two-person redaction control, the distinct-principals rule for a role grant, and every act's by then rest on the inspector's word: a compromised inspector could name anyone, or name one person as two.

With it, the recorder checks the person itself. Set on the recorder:

Setting
BITEXACT_SSO_ISSUERThe OpenID Connect issuer, exactly as its ID tokens' iss.
BITEXACT_SSO_AUDIENCEThe client id the tokens must be issued for (aud; with several audiences, also azp).
BITEXACT_SSO_JWKS_FILEA pinned JWKS file of the issuer's signing keys, for an offline or air-gapped recorder. Read again whenever it changes, so rotating is replacing the file.
BITEXACT_SSO_JWKS_URLOr the issuer's JWKS endpoint, https:// (plain http:// only on loopback). Exactly one of the two.
BITEXACT_SSO_PRINCIPAL_CLAIMsub (default) or email, which the issuer must have verified (email_verified: true).
BITEXACT_SSO_LEEWAY_SClock skew allowed on exp, nbf and iat: default 60 s, at most 300.
BITEXACT_SSO_JWKS_CACHE_SHow long fetched keys are used before they are fetched again (default 900).
BITEXACT_SSO_JWKS_REFRESH_SThe least time between two fetches for a key id the set does not hold (default 30).

Every act that names a person - a hold, a release, a redaction request, approval or rejection, an immediate redaction, a custody or access record, a dead-letter retry, a ledger event (a role, a group binding, a case, a workflow, a capture expectation) - must then carry that person's ID token. The recorder verifies it with its own copy of the issuer's keys: the signature (RS256/384/512, PS256/384/512, ES256/384/512 or EdDSA, by the key the token's kid names, which must be for that algorithm if the key says; none, every HMAC algorithm and a symmetric key are refused, so a public key can never be used as an HMAC secret), the issuer, the audience, the expiry and the not-before time. The act's by must be the principal the token's claim names. An act that carries no token, or one that does not prove its by, is refused with the reason, kept against the act's id like any other refusal, and logged on the recorder as recorder_identity_refused.

Fetched keys are used for the cache period; a token whose kid the set does not hold fetches it again, at most once per refresh period, and each fetch replaces the whole set, so a key the issuer rotated in is picked up at once and one it withdrew is withdrawn. Keys past the cache period that cannot be fetched again are not used: acts naming a person are refused until they are.

What the recorder writes for a verified act carries a verified_identity beside the principal - {issuer, subject, claim, principal}, never the token - and the chain it lands on covers it: the custody record (whose identity is then verified), the access-log event, the ledger event, and on the run's own chain the hold, hold_release and redaction entries. Two-person control then compares people, not names: the request records its verified identity, and an approval by the same subject of the same issuer is refused at the recorder, whatever two names the inspector gave the two acts. A request made before verification was turned on cannot be approved under it; reject it and ask again. A role that grants redaction-request is defined without the definer's stated posture (see Custom roles), since the recorder itself refuses any act naming a person without that person's token. The open verifier checks that each verified_identity on a chain names the record's principal, and that a redaction's requester and approver are different subjects (see the bundle spec).

The inspector forwards the token its SSO proxy hands it with each request, from the header BITEXACT_SSO_ID_TOKEN_HEADER names - by default Authorization, where oauth2-proxy puts the ID token with --pass-authorization-header (a Bearer scheme is taken off). It is read only in the proxy mode and only from a trusted proxy peer, and a header sent twice is not read. A command line that names the recorder sends the token in BITEXACT_SSO_ID_TOKEN_FILE, and its --by must be the principal that token proves. A token-map or no-auth inspector has no ID token to send, so a recorder that verifies who acts refuses every act in which it names a person.

The recorder refuses to start with any of these settings set without the rest. Unset, principals are the inspector's statement, recorded as asserted with the certificate that made them, and the posture a role definition states is what the distinct-principals rule judges.

Viewer tokens, and signing in from a browser#

Without an SSO proxy, BITEXACT_VIEWER_TOKENS gives each token one role (<token>:auditor-read,<token>:admin), and may name its holder (<token>:<name>:<role>, below). An API client sends its token as Authorization: Bearer <token>, and an unauthenticated API call is a 401 carrying WWW-Authenticate: Bearer. Every refusal the viewer sends - a 401, the license gate's 402, a 403, a scope 404, a throttled sign-in's 429 - is JSON with its reason in detail, so a client reads one field whichever gate said no (the sign-in form answers a browser with its page instead, the same words on it, and a browser asking for a page while the license is missing or invalid gets a page saying what is wrong and what to do). A person opening the inspector in a browser is redirected to its sign-in page and back to the screen they asked for. The sign-in page exchanges the token, once, for a session cookie:

  • HttpOnly (no script can read it) and SameSite=Strict (no other site can make the browser send it). It is Secure when the browser used HTTPS, directly or through a trusted proxy sending X-Forwarded-Proto: https, or always with BITEXACT_VIEWER_COOKIE_SECURE=1. State-changing requests still need the X-BitExact-Action header as well.
  • It lasts BITEXACT_VIEWER_SESSION_HOURS (default 8, at most 168).
  • It is an expiry signed with the token itself, so nothing is stored and it works across restarts and replicas. Removing a token from the configuration ends every session it opened. Sign out ends this browser's.
  • A sign-in posted from another site is refused, and a return address that leaves the inspector is ignored.

bitexact view refuses to start on a token map with an unusable entry - a role that is neither built in nor a defined custom role, a holder name outside ._@+-, letters and digits, an entry with no role, or one that is not token:role / token:name:role. The refusal names each such entry by its position in the map (entry 2 names the role 'oprator', ...) and never prints a token. Such an entry could never authenticate anyone, so starting with it would leave its holder facing a 401 that names nothing.

Named tokens: one principal per person#

An unnamed token's principal is token:<role>: every holder of an admin token is the same principal on every custody entry, and a two-person redaction can never complete, because the approval would always come from the principal that asked. Name each token after the person (or system) that holds it, and its principal is token:<name>:

export BITEXACT_VIEWER_TOKENS="$(openssl rand -hex 32):ann:admin,$(openssl rand -hex 32):bob:admin,$(openssl rand -hex 32):auditor-read"

Here Ann and Bob are two principals, so Ann can request a redaction and Bob approve it, and each custody entry says which of them acted. A name is letters, digits and ._@+- (an email address works); an entry whose name is anything else is unusable, like any malformed pair. The unnamed form keeps its meaning, so existing values need no change. Give each person their own name: two tokens with one name are one principal. GET /api/whoami reports the caller's principal, and dual_control - whether a two-person redaction can complete with the identities this inspector resolves, and if not, what to change ({"satisfiable": false, "reason": "..."}). With no sign-in at all every request is one principal, so it never can. To record a person from your identity provider instead, sign in through an SSO proxy (above).

Give compliance staff their own read-only token#

Issue one token per audience, and give the people who read evidence an auditor-read one:

export BITEXACT_VIEWER_TOKENS="$(openssl rand -hex 32):admin,$(openssl rand -hex 32):auditor-read"

Send the auditor-read token to compliance and audit staff and keep the admin one for whoever administers the deployment. An auditor-read sign-in holds none of fork, regress, redact, place-hold or settings, so the inspector opens in Simple mode (below) and offers only what that role may do; an admin sign-in opens in Advanced. A deployment with only an admin token puts every reader on the full surface with every power, which is neither what they need nor what an access review wants to find. The pilot (deploy/pilot/docker-compose.yml) reads the same variable, so the same two tokens go there.

Failed sign-ins#

Tokens are long random secrets, so the throttle is not what makes a guess fail; it keeps anyone from trying at line rate. It counts failures per client over a sliding minute. Nine cost nothing. From the tenth, each failure makes that client wait before its next try, twice as long each time (1 s, 2 s, 4 s, up to 60 s), and a try inside the wait is a 429 that names it and carries Retry-After. It is a delay on the client that is guessing, never a lockout of the form: anyone else signs in at once.

The client is the address the request came from. Behind a reverse proxy that is the proxy's address for everyone, so one person's mistyped token would slow every user. Name the proxy in BITEXACT_TRUSTED_PROXY_IPS and the throttle keys on the client the proxy reports in X-Forwarded-For instead: the rightmost address that is not itself a named proxy, because a proxy appends the address it saw and anything to the left of it came from the client. X-Forwarded-For is never read unless BITEXACT_TRUSTED_PROXY_IPS is set and the request came from an address it names. The loopback default that trusts identity headers from a sidecar does not extend to it.

Custom roles#

The three built-in roles are a hierarchy, and a hierarchy cannot express "this team may export and open cases, and nothing else". A deployment defines its own roles over a fixed permission vocabulary:

bitexact roles define underwriting-auditor --allow export,case --by alice
bitexact roles list
bitexact roles verify

A custom role holds exactly what it was granted - it does not inherit, and it sits outside the built-in ordering. X-Forwarded-Role: underwriting-auditor then resolves to those permissions and nothing else.

What may be granted. export, case, fork, regress, place-hold and redaction-request. The vocabulary is closed on purpose:

  • read, verify, anchor, replay and purge-preview are gated by scope rather than by role, so granting one would read as authority and confer nothing.
  • release-hold and redact are the destructive halves of their pairs - lifting a hold re-exposes a run to purge, and redact is the single-operator break-glass primitive. Their delegable halves are grantable; these are not.
  • settings administers roles, so a role holding it could grant itself everything.

A role may not take the name of a built-in: can() resolves the built-in hierarchy first, so a custom admin would never be read.

redaction-request is refused with no sign-in: the two-person flow compares principals, every request there is one principal, and no approval could ever satisfy it. Under an SSO proxy or the token map it may be granted; the approval still needs an admin principal other than the requester. A role granting it cannot be held through an unnamed token (a BITEXACT_VIEWER_TOKENS entry token:role with no name): every holder of one is the single principal token:<role>, so two of them would be one person to the two-person check. Granting it to a role the map already gives an unnamed token is refused, and the viewer refuses to start on a map that gives such a role an unnamed token - name the token (token:name:role). A keyless definer's act states its deployment's posture (how it resolves principals) for the recorder to judge by; a recorder that verifies who acts (above) judges by its own verification instead and consults no posture.

Definitions are signed, and the signature is the authority. Each event is signed with the recorder key. The hash chain alone would not be enough: it hashes each event with no key, so anyone who can write the data volume could append a well-formed grant and verify would still come back clean - that is tamper-evidence against edits, not against appends. And the key must be one the license names in recorder_keys: a signature under a key the event itself carries proves only that somebody held a key, so the vendor-signed license is what says which key is this deployment's. An unsigned event, a chain that fails verification, a chain whose signing key changes part-way through, or a key the license does not name grants nothing at all - and a license issued without --recorder-key names none, so custom roles need a bound license.

Roles are administered from the command line with bitexact roles. The recorder is the only process that holds the signing key: in the shipped multi-service deployments the viewer and the command line hold none, name the recorder with BITEXACT_RECORDER, and hand it each role event as an act over mutual TLS; the recorder applies the same checks and signs it with its own key, and refuses an act its policy does not grant that peer. Only a single-process install with no recorder named signs locally, because that process is then the recorder. The viewer offers no route that defines a role and none that signs anything: granting authority is an administrator's act, not a web request. Settings ▸ Access reads roles; it cannot define them. Rotating the recorder key means redefining the roles under the new key - a chain that changes key mid-way is a chain somebody else continued.

Removal is append-only, like everything else here. bitexact roles remove ends the grant; the chain still shows that it existed, who made it, and when.

Binding IdP groups to roles#

Your IdP knows who is in underwriting-auditors. What that means inside BitExact is the part this product owns, and it is the only part it stores - there is no user, credential, or membership list here.

bitexact roles bind underwriting-auditors underwriting-auditor --by alice
bitexact roles unbind underwriting-auditors underwriting-auditor --by alice

The binding lives on the role's own chain, so a role's history shows who may reach it as well as what it grants. Binding a group to an undefined role is refused: it would grant nothing while reading as access. A group reaches one role: binding it to a second is refused, naming the role it is bound to, and a store that holds a group bound to two roles anyway resolves that group to neither.

For this the proxy forwards X-Forwarded-Groups and the deployment sets BITEXACT_TRUST_PROXY_GROUPS=1. It is off by default because the header is one many deployments already emit for other consumers, and reading it without an opt-in would silently change what an existing proxy grants. With it off the header is not read at all, so how many copies of it arrive changes nothing. With it on, a request carrying the header twice is refused (401) - even one that also names its role in X-Forwarded-Role - because a proxy that let a second copy through is passing a client's own header into identity.

A principal in one bound group holds that group's role. Where a principal is in more than one bound group, the administrator decides which one counts, in BITEXACT_GROUP_PRECEDENCE: the bound groups, comma-separated, most authoritative first. The first of the principal's bound groups in that list wins, whichever order the header lists them in. A principal in two or more bound groups that the list does not all rank holds no role (401), and the viewer logs groups_unranked naming them: which group was meant is a decision nobody made, and BitExact does not make it by name order or header order. An intersection of two roles is no answer either - it has no name, and a name is what the access log and every custody entry carry. For example, with BITEXACT_GROUP_PRECEDENCE=b-auditors,a-engineers, a principal in b-auditors (bound to evidence-only) and a-engineers (bound to forkers) is evidence-only; with no precedence set, the same principal is refused. The identity the viewer resolves carries the group that conferred the role (auth.Identity.group, None for a role named by X-Forwarded-Role or a token), so the decision can say why the principal holds it. Every access-log event and custody record made under a role a group conferred carries that group beside the role; one whose role came from no group carries none.

Which definition a record was made under. A role can be redefined after it was used, so a role name alone does not say what it granted. Every custody entry and every access-log event carries role_head beside role: the hash of the head event of that role's ledger when the record was written. A record made before a redefinition keeps naming the old head, which resolves on the role's chain (bitexact roles list / the ledger) to the definition in force then. A built-in role carries the marker built-in - its permissions are fixed in the product, so there is no ledger to name - and a name with no readable ledger carries null. Archiving a run carries the ledgers of the custom roles its custody names (see administration), so the head still resolves from the archive alone.

Simple and advanced mode#

Two audiences use the same viewer. A compliance reader wants a date, a filter, and one button that produces a verifiable pack. An operator wants the fork, regress and retention surfaces as well. Simple mode shows the first: Overview, Runs, Workflows, Cases, Verify, Trust and Get started, with the secondary filters collapsed.

The mode defaults from capability, not from storage - a principal who holds none of fork, regress, redact, place-hold or settings lands in simple mode. Browser storage only ever overrides that choice, because it silently resets in a private window or with site data blocked, which would otherwise put every compliance user on the full surface.

The switch sits in the header and in the ≡ brand menu; below 1040px the header is too tight for it and the menu carries it alone. It only ever hides: it grants nothing, and advanced mode shows no view the principal's role does not already allow.

Access scoping by workflow and environment#

A principal can be limited to a subset of the estate. The proxy sets, e.g.:

X-Forwarded-Scope: workflows=claims,pricing;environments=prod

Under a scope, the viewer:

  • lists only in-scope runs and workflows;
  • returns 404 for an out-of-scope run — its existence is hidden, not merely its content;
  • treats an unclassified run (no workflow, or no environment) as out of scope when that dimension is restricted — deny-by-default.

An omitted dimension is unrestricted; no X-Forwarded-Scope header means full access. whoami reports the resolved scope, and the Settings ▸ Access panel shows it.

Access log#

Evidence-touching acts (export, verify, redact, hold) are recorded on each run's tamper-evident custody chain. Views and listings are operational access visibility rather than evidence mutations, so they are recorded in a global, append-only, hash-chained access log — the companion that answers "who looked at this run?". GET /api/access-log (admin only) and the Settings ▸ Access log panel show the recent events; each carries the principal, role, role_head, action, run, time, and decision.

The log is a chain. Each event carries prev (the hash of the event before it), alg and hash (over the rest of the event, with the same BLAKE2b / RFC 8785 hashing as every other chain), placed by the store under its write lock so concurrent writers chain in one line. GET /api/access-log returns chain beside the events - verified, the first problem, how many events the log holds and its head hash - and the panel says whether the log verifies. An edited or deleted event breaks the chain. The chain carries no key, so it does not show an event appended at the end by someone who can write the store, or the whole log rolled back to an earlier head: keep the head and events an earlier read reported, and a later read that does not extend them shows the rollback. Moving a store (bitexact db-transfer) carries the log only when it verifies, and only onto an empty log or its own prefix.

The recorder writes it. A viewer or command line that names a recorder (BITEXACT_RECORDER) writes no access log itself: each access event goes to the recorder as an access act, and a dead-letter retry (events retry, Settings ▸ Events) as an events-retry act, which moves the rows and writes their events. The recorder stamps each event with its own clock and its own reading of role_head, and adds submitted_by, the peer certificate that handed it in. Grant the inspector both acts on run * in the recorder's policy ("acts": [..., "access", "events-retry"], in a rule whose runs is "*"), and the command line events-retry. A view or listing the recorder will not log - it refused, or it could not be reached - is answered 503 and not shown: the evidence is not served unlogged.

A request the viewer refuses on the caller's role or on the license's grants is recorded too, with decision: "denied", the request path as the target, and a reason: no-action-header (a state-changing request that did not carry X-BitExact-Action), role (the role may not perform the action), license or entitlement. That covers the administrator's reads - the access log itself, the roles, the capture expectations, event delivery - as much as the acts, and a read that needs a capability the license does not grant, such as comparing two fork matrices without fork. A refusal stands whether or not it could be recorded: an access log that cannot be written is reported in the viewer's own log (access_log_write_failed) and never turns the refusal into an error.

Who a run acted as, and for whom#

Everything above is about who uses the inspector. A recorded run carries a second, separate identity: the agent's own statement, in its identity entries, of who it acted as and for whom. BitExact records that statement as asserted and authenticates none of it; a reader sees it marked as the agent's own word.

The identities relate as below. The inspector's help has the same table (press Ctrl+K, then Who acted, for whom, under what authority); the sample values are the sample run's.

FieldWhat it saysSample valueQuestion it answers
principalThe identity the agent acted as: its service account or workload identity.svc:loan-adjudicatorAs whom did the agent act? (RFC 8693 actor)
delegation_fromThe agent, service or app that handed this agent its authority.app:retail-lending-portalWho handed it authority?
on_behalf_of / _typeThe party whose request or authority the run served: the subject of a delegation (RFC 8693's sub, beside the actor). Its type is human, organization, service or agent, and needs on_behalf_of in the same entry.org:retail-bank / organization, or user:7f3a9c / human for a consumer-facing assistantFor whom?
authority_refThe artifact that authorized acting for the party: a consent id, a token's jti, a mandate reference. Needs on_behalf_of in the same entry.consent:2026-10-30/88412Under what authorization?
workflow_instanceThe case or business key the decision is about.application-...About what case or person was the decision? (relevant to the EU AI Act Art. 86 affected person)
interaction_modeWhether a person was in the session: interactive or autonomous.autonomousWas a person in the session?

The party fields are one statement. A new on_behalf_of replaces its type and authority with what that entry says, so a service never inherits a person's consent; every change after the run opened is listed on the run's page and in the evidence pack, with the step it happened at.

interaction_mode records only whether a person was in the session. It is not a finding that a decision was, or was not, solely automated in the sense of GDPR Article 22: a person can be present and have no say in the outcome. Record a human review as a human_decision entry.

Use a pseudonymous or role id for the party, never a name or an email. A chain keeps what it is given for as long as the run is retained, and an identity value is evidence a redaction can withhold but never rewrite. Resolve user:7f3a9c to a person in your own systems, under your own access controls. bitexact list --on-behalf-of user:7f3a9c and the inspector's Find a run box list the runs whose identity names them now, as the run's latest statement of its party says it, each one confirmed against the run's own chain. A run rebound to another party is listed by the party it moved to; the party it left stays on its chain as a change, shown on the run's page and in its evidence pack. For a subject-access request, build the evidence pack (bitexact evidence-pack --on-behalf-of user:7f3a9c): it decides which runs belong by reading every run's chain in the window, never the run index, which is a cache, and it includes any run the index does not hold yet. A listing or search cannot search a run the index does not hold yet, and says how many there are (bitexact reindex adds them), rather than answer short in silence.