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 withbitexact 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 believeauditor-readis 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_ISSUER | The OpenID Connect issuer, exactly as its ID tokens' iss. |
BITEXACT_SSO_AUDIENCE | The client id the tokens must be issued for (aud; with several audiences, also azp). |
BITEXACT_SSO_JWKS_FILE | A 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_URL | Or the issuer's JWKS endpoint, https:// (plain http:// only on loopback). Exactly one of the two. |
BITEXACT_SSO_PRINCIPAL_CLAIM | sub (default) or email, which the issuer must have verified (email_verified: true). |
BITEXACT_SSO_LEEWAY_S | Clock skew allowed on exp, nbf and iat: default 60 s, at most 300. |
BITEXACT_SSO_JWKS_CACHE_S | How long fetched keys are used before they are fetched again (default 900). |
BITEXACT_SSO_JWKS_REFRESH_S | The 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 withBITEXACT_VIEWER_COOKIE_SECURE=1. State-changing requests still need theX-BitExact-Actionheader 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,replayandpurge-previeware gated by scope rather than by role, so granting one would read as authority and confer nothing.release-holdandredactare the destructive halves of their pairs - lifting a hold re-exposes a run to purge, andredactis the single-operator break-glass primitive. Their delegable halves are grantable; these are not.settingsadministers 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.
| Field | What it says | Sample value | Question it answers |
|---|---|---|---|
principal | The identity the agent acted as: its service account or workload identity. | svc:loan-adjudicator | As whom did the agent act? (RFC 8693 actor) |
delegation_from | The agent, service or app that handed this agent its authority. | app:retail-lending-portal | Who handed it authority? |
on_behalf_of / _type | The 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 assistant | For whom? |
authority_ref | The 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/88412 | Under what authorization? |
workflow_instance | The 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_mode | Whether a person was in the session: interactive or autonomous. | autonomous | Was 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.