Threat model
This file says what Power BI Metrics Book protects, from whom, where trust changes hands, which control holds each boundary, which test proves it, and which risks are left. The spec’s Security section is its summary. A change that moves a boundary updates this file in the same pull request (the last section says when).
Each control lands in the same change as the thing it guards: no route ships before its credential check and its authorization test, no container before its hardening, no secret before its readers. A control that is listed here but not yet built guards nothing, so nothing it would guard may exist yet either.
Assets
Section titled “Assets”| Asset | Where it lives | The harm if it is lost |
|---|---|---|
| People’s Power BI tokens | powerbi_connection: the access token and the refresh token, encrypted for the api’s identity |
an attacker queries Power BI as them, with their row-level security, for up to 90 days |
| The scanner’s client secret | secret_ciphertext, encrypted for the worker’s identity only |
every workspace’s metadata, measures’ DAX and access lists in the tenant |
| The app’s client secret, the sign-in providers’ secrets, the CSV link key | secret_ciphertext, encrypted for the api’s identity only |
an attacker poses as this product to Microsoft or the provider, or signs CSV links |
| The embedding key | secret_ciphertext, for the api and the worker |
spend on the company’s account; the company’s metadata sent elsewhere |
| The containers’ age identities | the api_identity and worker_identity volumes (mode 0400), or API_AGE_IDENTITY and WORKER_AGE_IDENTITY |
every secret and token that container reads |
| Bootstrap values | the deployment’s .env (mode 0600): AUTH_SECRET, the database URLs with their passwords |
sessions forged; the database opened under a role |
| Stored results | query_result, query_result_page, until they expire |
data read under one person’s row-level security seen by another |
| Credentials | Better Auth’s tables (sessions, OAuth grants); service_account_key (API key digests); the CLI’s ~/.config/pbmb/credentials.json (mode 0600) |
an attacker gets a person’s or a service account’s access |
| The mirror | workspaces, datasets, tables, columns, measures with their DAX, powerbi_access |
names, DAX and access lists of a dataset seen by someone Power BI does not let reach it |
| The book, the conventions and their history | metrics, variants, terms, categories, the conventions, book_change |
a metric redefined or verified without a person seeing it |
| The records | audit_event, query_log, book_change, event |
who saw or changed what can no longer be shown |
| The setup link, its setup session and sign-in marker | the output of server setup-link, never the log; setup_link keeps only SHA-256 digests, until the first admin exists |
whoever holds it becomes the first admin |
| Availability | the host, Postgres, the Power BI capacity | agents get no numbers, or the wrong ones |
Who is trusted
Section titled “Who is trusted”- The operator who runs the host and holds the deployment’s
.envand the identity volumes. They can read everything, by design. - Microsoft: Entra for who a person is, Power BI’s access lists for what they may reach, and Power BI’s own rights and row-level security on every query.
- The company’s admins, and the sign-in providers they add, inside
their deployment. Admins can replace a secret but never read one back,
nor send one elsewhere: the settings that say where a secret goes
(
powerbi.authority,powerbi.api_url,powerbi.graph_url,search.base_url) are set only in the environment, by the operator, and a value saved for them in the database is never read. A sign-in provider’s secret goes to the issuer an admin sets, so moving a provider to another issuer or kind, or removing it, deletes its secret in the same transaction, in the admin section and in a book.yml import alike (settings/secret_destination_security_test.ts). - The people who review and merge pull requests.
CODEOWNERSmakes Martin the required reviewer on the sensitive paths (agents.md, rule 3).
Everyone and everything else is an attacker below, or sits behind a boundary.
Attackers
Section titled “Attackers”| Attacker | Wants | Main controls |
|---|---|---|
| Anyone on the internet | in without signing in; a token; the setup; the server to fetch something for them | only api exposed; every route checks its credential; PKCE and audience-bound tokens; the guarded fetcher; the setup link that only the server’s log holds |
| A signed-in person or service account | metadata or data beyond their Power BI rights; another person’s results; admin | Cerbos on every request; roles resolved per request; visibility that follows Power BI; results bound to their owner; Power BI’s own rights on every query; rate limits and a daily row budget; audit |
| A hostile MCP client | a grant by deception; to steer the agent through data; to rewrite definitions | the consent screen names the client and the redirect host; dynamic registration grants nothing; the connector cannot change the book; approval only from a browser session |
| The api, if broken | the tenant-wide credential | it cannot read the scanner’s secret; it holds no secret of the worker’s; the scanner registration holds no API permission |
| The worker, if broken | people’s Power BI tokens; a way in from outside | it holds no token key; it is on no network the internet reaches |
| Someone with a copy of the database | secrets and tokens | every secret and token in it is encrypted for a container identity that is not in the database; credentials are digests |
| The supply chain | code or instructions inside the stack | pinned versions and lockfiles, images by digest, skills and Actions by SHA, scanners, signed images with an SBOM |
| The maintaining agent | nothing, but by accident a leaked secret or a weakened control | the agent rules (agents.md), the security tests, the scanners, a security review on sensitive paths |
Trust boundaries
Section titled “Trust boundaries”Every container is in stack/compose.yml. app is an internal network
with no route out; edge and egress are not.
internet | B1 ===========================|========================================= | 443, TLS at the reverse proxy; edge +--------------+--------------+ | api | B4, egress: fixed hosts | B7: Power BI tokens |----> Microsoft identity, Power BI, | B8: results, CSV links | Graph, embedding provider | B9: the setup link | B4, egress: client-chosen | |----> CIMD documents, OIDC issuers +------+---------------+------+ app | | app B2 ===================|===============|================================= +--------+ +----------+ | cerbos | | postgres | B6: secrets, tokens and +--------+ +----------+ identities at rest | app B3 ===================================|================================= +----------+ B5, egress | worker |----> Microsoft identity, +----------+ Power BI Admin Scanner API, embedding provider
B10: images, dependencies, skills and Actions, into every container B11: tool output, Power BI metadata, results and third-party skills, into the agent B12: the host: Docker, the identity volumes, the operatorB1: the internet and the api
Section titled “B1: the internet and the api”Crossing: people’s browsers, MCP clients, the CLI and service accounts.
- Only
apipublishes a port. TLS at the proxy with HSTS. - Every route checks its own credential. The public-paths list names what is public, never what is protected, so a route added tomorrow is behind the gate because nobody listed it.
- One
/api/v1path is public:GET /api/v1/results/{id}/csv. A signed link (expires,signature) is its credential, so a request with no credential reaches it. The route judges its own credential: the link, or the caller’s own credential when no link is sent. A request with both is served only when the credential names the link’s owner, and otherwise answers as a missing result. A bearer token the api did not take, sent with no link, answers 401 with its challenge, as on every other path.isPublicmatches a:paramas exactly one non-empty segment of the decoded path, for GET only. - The SPA’s shell is public, GET only:
GET /andGET /assets/:file(index.html and the build’s hashed assets), and each screen’s path inSPA_PATHS(http/static.ts). That list includes the admin pages,/review/metrics/:nameand the catalog screens./catalog/categories/*is its one wildcard: it opens any depth below/catalog/categories/for GET, serving only the shell. The shell holds no data; every datum comes from/api/v1, except what the sign-in screen offers, which comes from/api/auth/config(below). GET /api/auth/configis public: the sign-in screen needs it before there is a session. It answers the providers that can sign a person in now (id,kind,name), whether passkeys are on, whether the dev sign-in route is open, and whether the stack runs on the mock tenant (sample_data, for the “Sample data” band). That is true only when the host ofpowerbi.authorityis localhost,*.localhost,127.0.0.1or[::1], so a real deployment always answers false. Its schema has no client id, issuer, secret or redirect URI, and the answer is parsed by it, so none can leak by accident (routes/auth-config_security_test.ts).- A database belongs to one tenant, for life. When the setup’s Microsoft
sign-in is spent,
setup_linkrecords the tenant id and whether the authority was the mock’s, both taken from the token’s own check, not from settings read later. The setup’s confirm (firstAdmin) refuses unless the stack still has both, compares the tenant id again inside the transaction under a lock on itssettingrow, then makes the admin, writes thedeployment_tenantrow from the recorded values and ends the links in that one transaction: no admin without a binding. The api and the worker refuse to start, and refuse every job, on a database with an admin and no binding. The worker asksdeployment_has_admin(), a security definer that answers one bit; it gets no read ofrole_assignment. Both also refuse to start against another tenant id or the other side of the mock, and check again before every job of any kind, the setup’s check job included. A sample database over real Microsoft would keep the sample’s admin and data, and a real tenant’s database under the mock would mix them, so both are refused. The dev sign-in runs only on the mock: while it is open andpowerbi.authorityis not the mock’s, the api and the worker refuse to start, bound or not, and the confirm refuses before it binds, so it never admits anyone over a real tenant. A trigger onsettingkeepspowerbi.tenant_idat the bound tenant for every writer, a second refuses to rename any row, and both functions pin theirsearch_path. No role of the stack updates or deletes the row. Moving to another tenant is a new database.services/setup/deployment_tenant_security_test.tsproves it. - Cookies
Secure,HttpOnly,SameSite=Lax; the origin check on every state-changing request except the token, registration and device endpoints and a bearer request with no cookie (below); a strict CSP (scripts from self only);X-Frame-Options: DENY; rate limits per principal and per IP on sign-in, setup, token, query and CSV routes; a 1 MiB body limit. - People sign in through Microsoft (single tenant), Google, an OpenID
Connect provider or a passkey; who may sign in is decided at the
callback, before an account exists (Better Auth’s
validateUserInfo), again whenever a session is made, and on every request: a person no role reaches any more is answered 403 byrequirePrincipal, which every/api/v1route and every MCP tool must call. Only the code flow signs in: an ID token a browser posts is refused. A Microsoft token is taken only when itsiss,audandtidname the configured tenant and app. - Only a verified email reaches a person assignment: a Microsoft email
when the token is the tenant’s, another provider’s when it says
email_verified. A Microsoft email is the token’semailclaim, orpreferred_usernamewhen there is none and it is an email address; for a member the tenant’s admins set both (a UPN’s domain is one the tenant verified), a guest’s is their home identity, whose ownership Microsoft checked, and a token with neither is refused.emailcomes from the directory’smail, which Entra does not check against the tenant’s domains and which on-premises directory staff can write in a hybrid tenant; Microsoft’sxms_edovclaim would tell when its domain is verified (accepted for now, below). An email domain assignment also needs a sign-in in the last 7 days that vouched for the domain: Microsoft’s tenant, an OpenID Connect provider’s verified email, or a Google Workspace token whosehdis that domain (a personal Google account vouches for none). A group reaches an assignment only at the provider that named it, so another provider cannot claim an Entra group’s id. A sign-in’s groups count for 7 days. A second provider’s sign-in never joins the account another made for the same email. - A session lasts 12 hours from sign-in, with no sliding refresh. A passkey signs in only within 7 days of the person’s last sign-in through a provider, and only when the device verified the person: the passkey plugin’s own hooks refuse a registration or an assertion whose verified authenticator data lacks the UV flag. Sign-in stores no provider token, and no JSON answer repeats the session token.
- Better Auth answers only the paths sign-in needs: before a session,
starting a sign-in, the Microsoft, Google and shared OpenID Connect
callbacks, passkey sign-in and the dev sign-in; with a session, sign-out,
the session, and registering, listing and deleting passkeys; for OAuth
clients, authorize, token, registration and device authorization. Every
other path under
/api/authanswers 404: introspection, revocation, the JWKS, Better Auth’s own consent and device pages and its client and consent CRUD are not served, and the metadata advertises none of them. Nobody registers an OpenID Connect provider through Better Auth: providers come from settings, set by an admin. The dev sign-in needsNODE_ENVdevelopmentortestandALLOW_DEV_SIGN_IN=1, each lock tested shut on its own. Given an Entra object id, it also makes the Microsoft account a Microsoft sign-in would (account id = the object id), so a later sign-in through the mock finds that account; linking by email stays refused. It answers 409 to an object id another user’s account holds (in any letter case) and to a user who already holds a different Microsoft account. Every account row it makes carries scopedev-sign-in. With the dev sign-in shut, the api refuses to start while any such row or anyperson_sign_inrow with providerdevexists, and names the rows and the statements that remove them; Microsoft’s callback refuses such an account too (dev_sign_in_account), for a database changed after start. A unique index on account (provider, account id) keeps one provider account to one user, for the dev sign-in and the real OAuth path alike. - Better Auth’s session paths (sign-out, the session, passkey register, list and delete) still answer a session that no role reaches any more. That is harmless: they read or end the person’s own session and passkeys, reach no data, and a new session still needs a role.
- Better Auth counts sign-in attempts per client address in Postgres: 60
starts a minute (a start only redirects to the provider), and its own
limits elsewhere. The address is the connection’s own, unless the
connection comes from
TRUSTED_PROXY(a single address or a range, never/0), whose lastX-Forwarded-Forentry, with any port dropped, then names the client; no other request picks the address its attempts count against.CLIENT_ADDRESS_HEADER=x-real-ipis a second, opt-in mode for a platform edge that setsX-Real-IPand publishes no proxy range (Railway). The api then counts the one address in that header, ignoresX-Forwarded-For, and refuses to start withTRUSTED_PROXYalso set. It holds only if every request comes through an edge that replaces a client-sentX-Real-IP. Railway’s documentation says its edge sets the header and does not say it replaces one the client sent, so an operator checks it before relying on it:GET /api/v1/mewith a spoofedX-Real-IPmust show their real address asclient_address. Caddy and Traefik as configured here pass a client’sX-Real-IPthrough, so the mode is never safe behind them, andcompose.ymldoes not pass the variable on. - The connector never changes the book, and writes only these things,
each as the caller and each through a service that asks Cerbos:
threads, replies, upvotes and views (
threads.ts); the query log and the caller’s own stored results (execute_dax); a CSV link, signed for the caller alone, for a result the caller owns; and, once a day for each client, oneconnector.whoamirow inaudit_event(recordConnectorReached), which the setup’sagentscheck reads as proof that an admin’s own client reached the connector. Its tools reach the services on the list inconnector_surface_security_test, and nothing that writes the book, the conventions or a result for another principal. What a tool answers from the book, Power BI or a thread is data, never instructions: the descriptions say so. - MCP clients and the CLI get OAuth from Better Auth’s OAuth provider
(
services/auth/oauth.ts,device.ts), issuer<PUBLIC_BASE_URL>/api/auth:- Tokens: one-hour EdDSA JWT access tokens, verified in the api against
the
jwkstable (signature,typat+jwt,iss, expiry by the api’s clock).audis the one resource the token was issued for,/api/mcpor/api/v1; at the other it is 401invalid_token. Scopemcpcovers/api/mcp,apicovers/api/v1; a token without it is 403insufficient_scope. A request with a bearer token is decided by the token alone, never by a cookie beside it. Roles are read per request. A 401 there names the resource’s RFC 9728 metadata. - A grant is a person’s consent to one client (
oauth_consent). A token works only while a consent to its client, made no later than the token, stands, so ending a grant stops every token of it on the next request: the person from/api/v1/me/grants, an admin throughrevokeConnection(revoke_connection). - Refresh tokens rotate and work once. A used one, or one of a grant
that ended, revokes the grant before Better Auth sees the request,
whichever field carries it. Better Auth alone would leave the JWTs it
signed working (it does not store them), and a rotation racing the
reuse could leave a successor (its issue #11317). A refresh it refuses
invalid_grantbecause a concurrent request rotated the token first is reuse too: the api reads the token again afterwards and revokes. - The token and device authorization endpoints take only a form body
with each field once (RFC 6749 section 3.2),
grant_typecompared trimmed, so the api and Better Auth (which keeps a repeated field’s last) never read different values from one request. - Authorization needs PKCE S256 and a non-empty
resource(an empty one reads to Better Auth as none, signing a token with no audience) that the client is linked to: registered and metadata-document clients to/api/mcp,pbmb-clito/api/v1. RFC 9207iss; noclient_credentials. A replayed authorization code is refused and its stored tokens deleted, but the JWTs it signed live out their hour; an exception awaiting Martin’s sign-off (docs/decisions/access.md). - Client ID Metadata Documents are fetched through
fetchUntrusted(public addresses, 5 s, 64 KiB, no redirects), must name themselves, list redirect URIs and set no server-only field, and are fetched again after a day. One that cannot be read answers one fixed sentence; the cause goes to the log with the request id, so no answer says what a host resolves to inside the network. - Dynamic registration needs no Origin (
docs/decisions/access.md) and grants nothing until Allow. It refusesbackchannel_logout_uri,backchannel_logout_session_required,jwks_uriandpost_logout_redirect_uris, which would make the api fetch outside the guarded fetcher. Better Auth’s register endpoint reads a session cookie when one comes and records that person as the creator. That is harmless: the guard takes only a JSON body, which no form can send and no page can send without a CORS preflight, and a client grants nothing until Allow. Creating a client deletes clients unchanged for a day that no person consented to and none asked to authorize in the last 20 minutes, sooauth_clientcannot grow without bound and an Allow in progress still lands. - The token endpoint refuses, before any database work, a form of more
than 12 fields or more than 2
refresh_tokenvalues, then reads the presented refresh tokens in one query. - Consent is answered only from a browser session, over
GET /api/v1/oauth/consent?<query>andPOST /api/v1/oauth/consent; the request Better Auth signed travels in the query or the body, never the logged path. It shows the person, the client’s id,vouched_host(the host serving a metadata document: the only checked fact), the name the client claims, who registered it and when, the redirect’s scheme and host (a private-use scheme as one), and what the client will and will not be able to do. The SPA must showclaimed_nameagainstvouched_host, never the name alone, and inside<bdi>. Registration refuses aclient_nameover 100 characters or holding a control or formatting character (U+202E would turn the words beside it around), and a consent request for any scope butmcpandoffline_accessis refused, since the lists describe only those. The sign-in page shows its own sentence for eacherrorthe api sends it, nevererror_description, which anyone can put in a link. - The device flow is for
pbmb-clialone (api offline_access,/api/v1). Against phishing: nouser_idmay pre-bind a code; noverification_uri_complete, so the person types the code; looking it up claims nothing, answering claims it for whoever answers, and only they may allow or deny; the screen shows when and from which address (device_request) it started. Every failure to answer gives one sentence. Allowing records consent before the CLI’s next poll. Codes last 10 minutes; polling within 5 seconds is toldslow_down. The SPA’s/devicemust never prefill the code from?user_code=, and must showstarted_atandstarted_from. - Device and user codes are stored in clear, and the JWT signing key’s
private half in
jwks.private_keyis encrypted withAUTH_SECRET, not age: two exceptions to rule 10 awaiting Martin’s sign-off (docs/decisions/access.md). - Rate limits per client address, in Postgres: token 60 a minute,
registration and device authorization 10 a minute, Better Auth’s 5
per 10 minutes on
/device, and the same on/device/approveand/device/deny. The api passes the person’s own address on when it asks Better Auth for them. - The origin check exempts the token and device authorization endpoints, which read no cookie, registration (above), and a request with a bearer token and no cookie at all: such a request is judged by its token, and a bearer never rides with a cookie through the check.
- Tokens: one-hour EdDSA JWT access tokens, verified in the api against
the
- Service accounts: an API key (
pbmb_sk_and 32 random bytes) is shown once and stored as its SHA-256 digest, found by that digest alone. The database refuses a key that outlives 90 days: a CHECK constraint counts fromcreated_at, whichapi_rwmay not write, so the database’s clock dates every key. The api refuses, on each request and by the database’s clock, a key past its expiry or older than its account’smax_key_age_daysas that stands now, so lowering it stops older keys at once. A key works on/api/v1only, never/api/mcp, told from a token by its prefix, and stops on the next request once revoked, or its account disabled or deleted.source_cidrsis matched against the client address as sign-in reads it:X-Forwarded-Forcounts only fromTRUSTED_PROXY, and an account gets nosource_cidrswhile that is unset orCLIENT_ADDRESS_HEADER=x-real-ipis set, since a key limited by address must not rest on a header nobody verified. Each account makes at mostservice_accounts.rate_per_minuterequests a minute (60 by default, read on each request), counted in Postgres (request_rate). The worker may only read and delete those counts, and deletes the ones a day old; no window is longer (a longer rule throws). A digest in a log or an error names no key, and the secret scan finds a key in a file (pbmb-api-keyin.gitleaks.toml).- A service account never administers: a CHECK keeps
adminout of its roles, andcreateAccessrefuses itbook:edit_conventionsand everyadminaction exceptadmin:read_statusin code, whatever a policy grants. A leaked key can therefore never make itself keys or widen its own grants; at most a key whose role a deployment gaveread_statusreads the status page (pbmb admin status), which holds no secret value. - A service account never passes for a person: the grants and Power BI
connection routes refuse it, and the
result_ownerandthread_authorderived roles compare the owner’s kind as well as its id. - Only
admin:manage_service_accountsmakes, changes, disables, enables or ends an account or a key, picking the fields it sets by name, and each change is audited without the key. - The admin services (
services/admin/) each ask Cerbos for their ownadminaction and audit every change.admin:read_status(the status page andpbmb admin status) is the one a deployment may also give a read-only role; every other admin action staysadmin’s. A caller who also passesadmin:manage_settingssees the whole status; any other sees the last sync’s state and time, the names of failing checks, the names and dates of expiring secrets and a count of connections to remake, never a message, an error, an id or an email. A sync’s stored error holds Postgres’s own message, never a query’s parameters. adminis assigned to a person or a group only, never to everyone in the tenant or an email domain (a CHECK onrole_assignment), and the lastadminassignment to a person cannot be revoked (hasAdmin).- A check answers a fixed sentence for each known failure, at most 300 characters (a CHECK on both tables that keep it). Where the caller holds the value, a message repeating it or any 8 characters of it is replaced by a fixed sentence before it is kept or shown.
- A service account never administers: a CHECK keeps
- The connector,
POST /api/mcp, takes only an OAuth access token issued for it: a session cookie or an API key is refused 401. One POST carries one JSON-RPC message or a batch of at most 20, in at most 1 MiB, and keeps no session; any other method answers 405. A tool’s arguments are parsed before it runs and its answer is cut at 16 KiB. The conventions guide’s opening, which an admin writes and every client reads at initialize, is cut at 1,000 characters. AServiceErrorreaches the client as its sentence; any other error as a fixed sentence with the request id. - An unexpected error, from a route, a tool or Better Auth
(
onAPIError.throw), is logged by one function,http/error-log.ts: its name, message and stack, except a failed query, which is logged as Postgres’s own error and never with the query’s parameters, which can carry ciphertext, a PKCE verifier or a key digest. - The connector has no tool that changes the book: its tools are on the
spec’s list, and
connector_surface_security_testfollows every import and re-export of its modules with the type checker to the declaration it names, which must be on its list; from the book and conventions services only their reads.human_verifiedis set only from a browser session under the default conventions. - The rules that decide who may verify,
trust.*in the conventions, change only from a person’s browser session: a CLI or MCP token, a service account, or abook.ymlimport by one is refused.setConventionschecks it against the version read under the lock the save writes under, so a save that lands in between cannot make a stale check pass (approval_browser_only_security_test,store.test.ts, which holds the lock from a second connection, and the import test inexport.test.ts).
Tests: public-paths_security_test over the route table,
authorization_matrix_security_test (a session, an API key, the CLI’s
token and an MCP client’s token each answered only on the paths that
take it, and 401 everywhere else, the CSV path included; every route of
the app’s table, middleware aside, has a row), oauth_security_test
(token audience, forgery and expiry, scope, refresh-token reuse, behind a
decoy, concurrent and its racing successor,
strict token forms, PKCE, resource, CIMD at a private address answered
without the address, registration granting nothing before Allow and
refusing fields that fetch, the 401’s resource_metadata),
grants_security_test (own grants only, revoke_connection, idle
clients pruned, clients being authorized spared),
device_security_test (phishing defences), consent_security_test, the
origin check and its bearer rule, the CSP header, the dev
sign-in locks (dev_sign_in_security_test), api_key_expiry_security_test,
api_key_security_test, service_account_bound_security_test,
connector_surface_security_test, approval_browser_only_security_test,
sign_in_security_test, roles_security_test,
rate_limit_security_test,
apps/api/src/services/auth/sign-in-providers.test.ts.
B2: the api, Cerbos and Postgres (app)
Section titled “B2: the api, Cerbos and Postgres (app)”Crossing: authorization questions, the api’s reads and writes.
- Cerbos decides every request that reads or changes data, before it
reaches Power BI or the book, through one service layer for the SPA, the
CLI, MCP and service accounts. Roles are resolved on every request from
role_assignment, never kept in a token or a session. - Visibility follows Power BI (
access.metadata_visibility: powerbi): workspaces, app audiences and direct shares, groups included. Lists and search apply it inside their SQL, from Cerbos’s query plan; an operator the translator does not know fails the request rather than show too much. - A person’s Power BI lists are read on every request from their connected
Power BI account’s object id and its groups, as read through the
connection: the object id and groups count only while
connectedand within 7 days of the connection’s groups being read. With no connection, the Microsoft account they signed in with counts, within 7 days of that sign-in. An ended connection clears the read, so after that window nothing listed for the account reaches them. - Roles never come from a connected account that is not the person’s own.
Groups and tenant membership come from
person_identity: the account the person signed in with, or, underpowerbi.connection_requires_matching_email, the account they connected with their verified email. A connection of a shared account fills only the connection’s own groups, which decide visibility and nothing else. - An access-list entry reaches its principal only with a right the sync
read that is not
None; an entry with no right is counted in the sync run instead. An email, or a group another sign-in provider names, never reaches anything. Apps follow the mode as datasets do: underpowerbian app is listed only to its audience and the members of its workspace. - Cerbos takes a request of at most 4 MB, so a person whose lists name more than about 100,000 objects fails closed: each request that asks Cerbos about them fails.
- A dataset the principal may not describe answers exactly like a missing one. A result is readable only by the principal who ran its query, in code, whatever a policy says.
- Search filters inside the WHERE of each of its orders, before either is
cut to its pool, from the plan over
powerbi_datasetas it is now, never from the dataset ids a document copied. A metric’s document is also checked by the rule every read of a metric uses, so a variant filed or deleted since the documents were built counts at once. Documents in no dataset (categories, metrics without variants) and every term are visible to everyone signed in, as the book shows them: a term’s document holds only the term’s own words, never its columns’, tables’ or datasets’ names. Thedataset,workspaceandappfilters answer for a hidden one exactly as for a missing one; an app counts only when it is live and the caller sees it. Search takes at most 60 queries a minute per principal, its SQL stops after 5 s (statement_timeout, answered asupstreamwith a fixed sentence), and bad input (a query over 500 characters, a limit outside 1 to 50, more than 16 custom fields) answersinvalid. - One database role per container:
api_rw,worker_rw, andaudit_writer, which may only insert intoaudit_eventandquery_log.migratorowns every table and runs only the api’s migrations at start. Each table’s grants are declared beside it. - SQL takes parameters; semgrep refuses SQL built from strings.
- The change feed (
GET /api/v1/events) shows an event by what the reader may see of its element, decided in its SQL before the page is cut:- a metric event by the
dataset_idsandmeasure_idsthe event recorded when it was written: an event that recorded none is hidden, an empty list means everyone (as a metric with no variants is visible to everyone), and otherwise the reader must be able to describe one of the datasets. While the metric still exists it must also be visible asvisibleMetricshows it now, so a name written while the metric was hidden does not surface when it later becomes visible. For a caller withoutpii_reader, the event is also hidden unless both recorded lists are empty or at least one recorded measure is in a dataset the caller can reach and is not PII now. An event without recordedmeasure_idsis hidden. Removed measures keep their names in the mirror and remain checked. Moving a variant out records the datasets and measures the metric held before and after; - a variant event by the
dataset_idandmeasure_idit recorded. A caller withoutpii_readersees it only when the measure is not PII; - a thread event as
findThreadshows the thread, by its element; - term and conventions events to everyone, as they are readable by all;
sync.*events only withadmin:read_sync_events, which a service account is refused in code, and theirerroronly withadmin:manage_settings;- an event with no element is shown only if its kind starts with
sync.and the reader may read sync events; any other element-less kind is hidden; - any other element is hidden. An event’s payload carries ids, never the name of another element: a reader may see one metric of a merge or a move and not the other.
- a metric event by the
Tests: authorization_matrix_security_test, over the table in
apps/api/src/routes/authorization-matrix.ts: every route and every tool,
for anonymous, an outsider, a viewer, a curator, an admin, a curator who
cannot see the Sales dataset, a service account with and without a grant,
and the curator’s tokens, each allowed or refused by a real request on
the sample tenant. A Sales element hidden from the caller, curation
included, and a result the caller did not run answer exactly as a missing
one; an answer the outsider, the service account with no grant or the
curator elsewhere is allowed holds no id or key of a Sales element (the
query log aside, a residual risk below); a route or tool the table does
not name fails it. It checks one dataset and one shape of reach per
caller, not every filter’s every branch; events_visibility_security_test
for the feed; metadata_visibility_security_test,
search_visibility_security_test, result_ownership_security_test,
audit_append_only_security_test, grants_security_test.
PII visibility
Section titled “PII visibility”PII behaves as if it does not exist for a caller without pii_reader. The
rule is enforced in metadata reads, book reads and writes, threads, search,
and the event feed. Event visibility checks the recorded dataset_ids and
measure_ids; current measure rules also protect events after a metric is
gone. Queries containing a PII column or measure are refused before Power
BI. A result header check before storage is the backstop. Only persons can have
pii_reader; service accounts never do. The hidden_suggested_merges and
hidden_variants counts, and live_base_variants in checks, count PII as
they count datasets the caller cannot reach. A measure’s DAX can name a PII
column, but never shows its values.
The admin dataset list exposes dataset names and counts beyond the admin’s
Power BI rights. This is the names-only exception in spec decision 8; it
does not grant access to data. Any admin allowed to manage PII sees every
rule’s table, column and measure names, whatever their Power BI reach. PII
suggestions and rule counts stay within
the caller’s Power BI reach, including off datasets, so counts depend on
who asks. A PII rule restore is conditional on its version, checked atomically with the write.
manage_pii and manage_datasets are separate Cerbos actions.
An off dataset stays hidden from ordinary reads and queries, even for an
admin or under a deployment policy that allows every Certified dataset.
Tests: apps/api/src/services/book/pii_visibility_security_test.ts,
apps/api/src/routes/v1/events_visibility_security_test.ts,
apps/api/src/routes/v1/pii_security_test.ts and
apps/api/src/routes/v1/dataset_switch_security_test.ts.
B3: the api and the worker
Section titled “B3: the api and the worker”Crossing: jobs, through Postgres only.
- The two never call each other. A job is a row in
job_request, taken withfor update skip lockedby the container itsrunnernames. - The worker holds the scanner’s secret and no token key; the api holds
the token key and cannot read the scanner’s secret, so the jobs that need
the token key (
rekey_tokens,refresh_idle_connections) run in the api. - The job registry is keyed by name and runner, so one name may have a
handler in each container (
checkdoes: the api’s runs every check, the worker’s only those that need the scanner’s secret). A runner runs only jobs queued for its own container, and the lock that keeps a job to one run at a time is per container, so the api’s check job can wait on the worker’s. - The worker stores the results of the checks it runs in
setting_check, and row level security holds it to their rows (scanner,what_to_sync); the api reads only the result. - Every book change queues the
embedjob inside the curation transaction (recordBookChange), with a fixed job name, runner and arguments (embed,worker,changes_only) that no caller chooses, and at most one request queued. A change that finds one queued selects itfor shareand holds the lock until it commits, so the worker, which takes withfor update skip locked, cannot take and finish it before the change is visible. The job readsbook_changefrom a cursor insearch_state, which onlyworker_rwwrites. - The search tables:
search_documentis written byworker_rwand read byapi_rw;search_stateisworker_rw’s alone (select, insert, update);embedding_usageis appended to by both (select and insert only, so neither can change or remove what was spent) and its token and cost columns cannot be negative. - No volume is mounted by both. The mirror tables are written only by
worker_rw, except our own description and custom fields. - Both containers run non-root, with read-only roots and tmpfs where a
process writes,
no-new-privileges, every capability dropped, memory and CPU limits and health checks.
Tests: apps/api/src/services/secrets/scanner_secret_isolation_security_test.ts,
compose_security_test, grants_security_test, runner.test.ts
(handlerHere), the worker’s check results in
setup_link_security_test.ts, and the queuing and the usage grants in
apps/api/src/services/search/embed.test.ts and
embedder_security_test.ts.
B4: the api’s outbound requests
Section titled “B4: the api’s outbound requests”Crossing: sign-in, people’s Power BI queries and groups, query embeddings, and URLs someone else chose.
- Every outbound request goes through the guarded fetcher; semgrep refuses
fetchanywhere else. - Fixed hosts (
fetchFixed):httpsto the configured Microsoft identity, Power BI, Graph and embedding hosts only, no redirects, with size and time limits. The mock’shttpURL is allowed only whenNODE_ENVisdevelopmentortest. Fixed hosts skip the private-address check on purpose: with Power BI Private Link or a private endpoint those hosts resolve to private addresses. The allowlist is the control, and only an admin sets those origins. - Client-chosen URLs (
fetchUntrusted: CIMD documents, an OpenID Connect issuer an admin types in):https, public addresses only after DNS resolution, 5 s, 64 KiB, no redirects. This closes the server-side request forgery a client-chosen URL invites. - Better Auth makes its own sign-in requests, outside the guarded fetcher
and without its address checks: the Microsoft code exchange to
powerbi.authority, which must behttpsoutside development and test (authority_security_test), and an OpenID Connect provider’s token, keys and userinfo requests. The provider’s discovery goes throughfetchUntrusted; its authorization, token and keys endpoints must be on the issuer’s own origin, overhttpsoutside development and test, and a userinfo endpoint elsewhere is not used. The SSO plugin refuses a literal private address and, once before each request, a host that resolves to one, without pinning the address it then connects to. In development and test the configured issuers’ origins are trusted, so the mock on loopback answers; production trusts none (oidc_issuer_trust_security_test). - The app registration holds read-only delegated permissions and nothing
that writes. The setup’s check allows exactly the delegated permissions
of the spec’s registration table (
openid,profile,email,offline_access, Power BIDataset.Read.All, GraphUser.Read) and names any other,Tenant.Read.AllandTenant.ReadWrite.Allwith their own sentences. It refuses a registration whose client-credentials token has arolesclaim (an application permission), and the scanner’s check refuses a scanner token with one: the admin APIs reach the scanner through the tenant setting, not a permission. - Client-credentials tokens are cached by authority, tenant, client and the SHA-256 of the secret, so a changed secret or tenant is checked at once and never answered the old token.
- What leaves: DAX to Power BI, under the person’s own token. The
embedding provider is a third party that sees the book’s names,
descriptions, display folders and term values (a variant in a personal
workspace leaves out the workspace’s name, which is its owner’s), and
never DAX, a query result or a thread’s text. Its key goes only in a
request header, to the configured host, which the fetcher checks against
its list; an error, the log,
job_request.errorandembedding_usagenever hold the key or the provider’s words: the store refuses a secret with a control character, or an API key with a character a header cannot carry, and a request the fetcher cannot build or send fails with a fixed sentence that quotes none of it; a wait on itsRetry-Afteris at most 60 seconds; and its answer is checked (one vector of the stored width for each text, every value in [-1, 1], the indexes exactly 0 to n-1, the token count within an int4) before any of it is kept. When it refuses a batch, a fixed probe sentence is embedded first: if the probe is refused too, the request is at fault (a path, a model, a key), the run stops, and no document keeps a hash; only after the probe passes is a text it still refuses, found by halving the batch, held to be at fault and left without a vector. A run stops after ten minutes with what it has stored and queues its successor. The change feed is a pull, not a webhook. - Every search query’s text goes to the embedding provider, and an agent
can put anything into it, a value it read from a result included;
search.provider: noneturns that off. A query’s embed waits 5 s at most and never onRetry-After. When it fails in any way (a refusal, a timeout, a redirect, an answer too big, a key it cannot send), search answers by words alone with a fixed sentence, and no provider or fetcher text reaches the caller.
Tests: the guarded fetcher’s (CIMD and issuer fetches to private
addresses, other hosts refused by the fixed-host profile, the mock-URL
lock), query_identity_security_test, apps/api/src/services/setup/app-registration-check.test.ts.
B5: the worker’s outbound requests (egress)
Section titled “B5: the worker’s outbound requests (egress)”Crossing: the scanner’s token, the Admin Scanner API, document embeddings.
- The worker is never on
edge: nothing reaches it from outside. - The scanner registration holds no API permission; its reach is the tenant setting for its security group, and its admin APIs are read-only.
- The parser drops Power Query (M) expressions, table sources and dataset parameters before anything is stored: they name servers and can carry connection details. Absence from a failed scan is never a deletion.
Tests: compose_security_test (api the only service on edge), the
scanner client’s tests against the mock.
B6: secrets, tokens and identities at rest
Section titled “B6: secrets, tokens and identities at rest”Crossing: what Postgres, a backup and the identity volumes hold.
- The api and the worker each make an age (X25519) identity on first start,
in a volume only that container mounts, and publish the public half to
container_identity. - A secret is encrypted for each container that reads it: the scanner’s secret for the worker only; the app’s secret, the sign-in providers’ secrets and the CSV link key for the api only; the embedding key for both. The api writes the scanner’s secret and can never read it back.
- Secrets are write-only: no route returns a value. The admin services show
a secret’s readers, last four characters, setter, expiry and check, and
audit its name and expiry only.
pbmb admin secrets setprompts without echo and refuses a value on the command line. - API keys are stored as SHA-256 digests. No secret is in
compose.yml. server rotate-identitymakes a new identity, re-encrypts what the container reads and retires the old one. The api keeps its old key file, since the running api seals Power BI tokens with the old identity until it restarts, and queuesrekey_tokens. Only an api whose identity in memory is the published current one runs it (an api not yet restarted refuses, and the restarted api queues it at start): it seals every token again with the key in memory, never for a recipient a database row names, and removes a retired key file once no row is sealed for it. Entra client secrets warn 30 and 7 days before they expire.- A nightly
pg_dumpgoes off the host, and the identity volumes are backed up apart from it, so neither alone opens a secret.
Tests: apps/api/src/services/secrets/secret_write_only_security_test.ts,
apps/api/src/services/secrets/secret_at_rest_security_test.ts,
apps/api/src/services/secrets/scanner_secret_isolation_security_test.ts, apps/api/src/services/admin/secret-expiry.test.ts.
B6a: book.yml export and import
Section titled “B6a: book.yml export and import”Crossing: a file an admin brings in, and one they take out.
- Export holds settings saved in the database (never
setup.*), the conventions, role assignments and service accounts picked field by field by the schema, so no secret, key or digest leaves. - Import reads plain YAML only: at most 256 KiB, no aliases, merge keys or
unknown tags, any error answered with its line and column alone. The
file then passes
bookSchema(book.schema.json), each setting its own schema and each role the rules ofassignRole. - Each kind a file changes needs its own action beside
admin:import_book:manage_settings,manage_people,manage_service_accounts,book:edit_conventions. A changed setting’s check runs in the preview, and a failing value is applied only withsave_failing. - Applying is one transaction; it never removes what the file leaves out,
and the answer lists that as
kept. Audit lines are written after the commit from the rows written, sharing oneimport_id; a rolled-back import writes one line saying so.
Tests: apps/api/src/services/admin/export.test.ts.
B7: a person’s Power BI tokens
Section titled “B7: a person’s Power BI tokens”Crossing: a person’s delegated grant, from Microsoft into the api and out to Power BI.
- The connection uses PKCE S256 and a
statebound to the session, single use, 10 minutes, kept in Postgres as its SHA-256 only. The callback refuses atidthat is not the tenant. By default any account of the tenant may be connected, and several people may connect one account: the person proved they can sign in as it, and Power BI decides every query by that account’s own rights. Each person’s tokens come from their own consent and are sealed in their own row; no person’s token is ever used for another. Such an account is never written toperson_identity, so it gives no role (B2). The query log records the account each query ran as, because Power BI’s own logs name only the shared account. Underpowerbi.connection_requires_matching_emailthe strict rules hold: a person with a Microsoft account on record inperson_identity(from signing in with it, or an earlier connection) must connect thatoid, however the session signed in, a passkey included; only a person with none may connect an account with their verified email. Thatoidgoes intoperson_identityin the transaction that stores the connection, and one Microsoft account connects to one person, also when two people connect it at once (person_identity’s unique constraint answers as the check does). Either way, a Graph failure after the connection is stored answers that the connection is made and the groups wait. A new connect replaces the session’s pending state. The browser goes back only to a page of the SPA (nextis an allowlisted relative path), never to another origin. Connects, refused connects and disconnects are inaudit_event. - The ID token in the token endpoint’s answer is held to sign-in’s checks: issuer, audience, tenant and expiry.
- Tenant membership comes from a Microsoft sign-in in the last 7 days, or
from a connection of the person’s own account (the one in
person_identity) whose groups were read in the last 7 days; an ended or disconnected connection clears the latter, and a Graph read that answers after a disconnect writes nothing.refresh_idle_connectionsrenews it by reading the groups through Graph, which works only while Microsoft accepts the account, and then refreshes the Power BI token only for a person a role reaches. The Graph read still renews the refresh token of a person no role reaches. - Tokens are sealed by the api’s vault (
AppDeps.vault), built once at start from its identity; no service holds the key. One row per person; tokens exist only while the connection isconnected(a CHECK constraint). The worker has no grant onpowerbi_connectionand never holds the api’s identity, sorekey_tokensandrefresh_idle_connectionsrun in the api. A refresh runs under a row lock and stores the new refresh token in the same transaction; a refresh token Microsoft refuses deletes both tokens and setsneeds_reconnect. - A person’s queries run with that person’s token, never the scanner’s and
never another person’s. Service-account queries run as the app’s service
principal only when
powerbi.service_account_queriesis on. - No token is returned to a client, logged, or sent anywhere but Microsoft. Disconnecting deletes both tokens; an admin’s revoke will too, once the admin section builds it (pending).
Tests: query_identity_security_test,
apps/api/src/services/connection/connection_security_test.ts (no token in
clear, sealed for the api alone, state, tid and oid, the email match,
one account per person, the ID token’s claims, the return path),
apps/api/src/services/connection/rekey_security_test.ts (no token sealed
for a forged recipient), and
apps/api/src/services/connection/tokens.test.ts (one refresh token spent
by two queries at once, a revoked token turning to needs_reconnect).
B8: results
Section titled “B8: results”Crossing: rows Power BI returned under one person’s rights.
- A result is readable only by its owner, whatever the policy says. It
expires after
results.ttl_hours(24 hours by default, 72 at most, because results hold company data) and the worker deletes it within 15 minutes of expiry. The VM backup dumps the result tables without rows; a platform backup (Railway, Azure) keeps them, at most 72 hours old. - A CSV link is an HMAC-SHA256 signature over the result id, the owner and
an expiry 15 minutes ahead, under a key only the api reads, a bearer
credential for one result. A link-only request acts as a synthetic
csv_linkprincipal that reaches only the one result’s read and stream. The owner is re-checked on the link path: a person no role reaches, and a service account that is disabled or off itssource_cidrs, get the same refusal as a link to a result that is gone, so the answer does not say whether the owner stands. Link downloads are limited per address (60 a minute, an IPv6 client counted by its /64, an IPv4-mapped address as its IPv4;X-Forwarded-Foronly fromTRUSTED_PROXY), and every download, by link or by credential, counts against its owner’s 30 a minute. Deleting a service account deletes the results it owns, and a result is saved for a service account only while it exists, so an account created later with the same name inherits none. - Downloads are audited with
detail.byset tocsv_link(and the address) orcredential. A refusal is adenyrow with the error kind asreasonand, where the cause is not the kind,detail.why(no_role,no_such_person,account_unavailable). A credential that presents someone else’s link gets adenyrow as the presenter, withdetail.link_ownernaming the link’s owner. Two refusals write no row, by design: a bad or expired signature without a credential has no principal to record (audit_event.principal_*is not null), and a rate-limited request, so that a burst cannot fill the audit log; the limits bound both. - Pages and previews are bounded (500 rows, 16 KiB, a row over it left out), and a daily row budget per principal stops an agent in a loop from copying a model out. Queries in flight hold 100,000 rows each against it, at most 4 at once, so parallel queries cannot pass it. The query log holds the DAX, never a row, and Power BI’s error sentence reaches only the entry’s own principal.
- A CSV cell that starts like a formula gets a leading
', so a spreadsheet does not run it (docs/decisions/queries.md).
Tests: apps/api/src/services/queries/result_ownership_security_test.ts
(another person, a curator, an admin and a service account named as the
owner), apps/api/src/services/queries/links_security_test.ts (the CSV
link’s signature and expiry),
apps/api/src/routes/v1/csv_link_security_test.ts (the link’s reach, a
refused token with no link answered 401, the owner re-check, the limits
and the audit rows).
B9: the setup link
Section titled “B9: the setup link”Crossing: the first admin, before anyone can sign in.
- With no admin yet, the api logs at
warnonly that a link can be printed and the command that prints it. It never logs a link or a token: on a hosted platform whoever can read the log (an Azure Reader on the resource group, for 30 days) would otherwise open it first and become the first admin.server setup-linkprints a new link, ending every earlier link and setup session. A link works once and for 24 hours; a setup session already open stays, within its 12 hours.setup_linkkeeps the SHA-256 of each value, never the value. Whoever can run a command in the container can print a link, and already holds its database credentials. - The link is the SPA’s
/setupwith the token in the fragment (/setup#token=...), which no request, log line orReferercarries. The screen posts it to/api/setup/sessionto spend it, then clears it from the address bar./setupand/api/setupanswer withReferrer-Policy: no-referrer. - Spending the link opens a setup session for 12 hours at most: an
HttpOnly,Secure,SameSite=Strictcookie,__Secure-pbmb_setup, on/api/setup. It acts with the admin’s rights, as whoever can read the server’s log could change the server anyway, but only over the state and the first two steps (address, app registration). Every later step, which could make an admin (who gets in) or add a sign-in provider that mints one (sign-in), answers 404 to it and needs an admin’s browser session. - The setup API (
/api/setup,/api/setup/session,/api/setup/steps/<step>) is on the public-paths list, because nobody can sign in yet. Each route checks its own credential: the setup session where it may, or an admin’s browser session that passes the admin action. To anyone else each answers 404. Whenever a request finds an admin exists, everysetup_linkrow is deleted, so no link or session outlives the first admin. The paths are outside/api/mcpand/api/v1, so a bearer token is never read there, and the origin check guards them. - Sign-in admits a person no role reaches only for the setup’s own
Microsoft sign-in. The setup session sets a single-use marker for 10
minutes (
__Secure-pbmb_setup_sign_in,SameSite=Laxto ride Microsoft’s redirect, on/api/auth). When the holder’s browser starts the Microsoft sign-in with it, the api keeps the SHA-256 of the OAuthstatethat start sent, and admits only the callback carrying that state, so a planted state cookie from a sibling subdomain (a login CSRF) admits nobody. The callback spends the marker on the person it made and expires its cookie. - The app registration step then shows that person’s email and name, and
makes them the first admin only on a second request naming that email,
from the same browser signed in as them: through
assignRole, audited. Every link and session ends with it. - The checks that need the scanner’s secret run in the worker (B3).
Tests: setup_link_security_test.ts: single use, two spends at once, the
24 hours, the 12-hour session, server setup-link revoking earlier links,
only the latest start’s link working with an open session kept, the setup session’s two steps, every setup path
closed once an admin exists to a stale session and to a viewer; the marker
refused without it, a second time, for a state the setup did not start,
after its 10 minutes and once an admin exists; the confirm step refusing
another email; the worker’s check rows. Also public-paths_security_test.ts
and security-headers_security_test.ts.
B10: the supply chain
Section titled “B10: the supply chain”Crossing: code and instructions written by others.
- Versions pinned exactly and at least a day old, lockfiles committed, images pinned by digest, skills and Actions by SHA. Released images are signed with cosign and shipped with an SBOM.
pnpm check:allruns gitleaks, osv-scanner, trivy (the image andcompose.yml), semgrep (public rule sets plus this repository’s own) and zizmor (workflows).- The mock’s shapes come from Microsoft’s documentation and its OpenAPI file; no answer recorded from a real tenant is committed.
Tests: pnpm check:all (gitleaks, osv-scanner, trivy, semgrep, zizmor), and
the pinned digests in compose_security_test.ts.
B11: the maintaining agent
Section titled “B11: the maintaining agent”Crossing: tool output, Power BI metadata, results, thread bodies and third-party skills, into an agent that can edit the repository or the book.
- The agent rules in
agents.md, and the deny rules in.claude/,.codex/and.cursor/behind them. - No agent holds a production credential that can write; agents check a
deployment read-only with
pbmb admin status. - The connector cannot change the book and an agent cannot set
human_verified, so text injected through data cannot rewrite or sign off a definition.
Tests: no automated test proves an agent’s behaviour, and that is a residual
risk. What proves the boundaries around it: the agents’ settings deny reading
secrets and identity files, every security test fails when its boundary is
loosened, and review on sensitive paths goes through CODEOWNERS.
B12: the host
Section titled “B12: the host”Crossing: the host’s root, Docker, and the operator.
- No container mounts the Docker socket. Each identity volume is mounted into its own container alone, mode 0400. Containers are hardened as in B3.
Tests: compose_security_test (published ports, internal networks, the
hardening, no Docker socket, digests).
Residual risks
Section titled “Residual risks”What the controls above do not close, and why it is accepted for now:
| Risk | Why it remains | What limits it |
|---|---|---|
| A broken api can query Power BI as any connected person | it holds the key to their tokens, which it must to run their queries | tokens are read-only delegated grants; a database copy alone opens none; disconnecting deletes them (an admin’s revoke is pending, with the admin section); refresh tokens lapse after 90 days unused; every query is in query_log |
| A broken worker reads every workspace’s metadata and DAX | it holds the scanner’s secret, which it must to sync | the worker takes no inbound traffic; the scanner reads metadata, never rows; its registration holds no API permission |
| Access lists lag Power BI | visibility is synced, up to one incremental sync for workspaces and one full sync for app audiences | it decides metadata only; Power BI decides every query when it runs |
| Removing someone from an app’s audience leaves their Build permission | Microsoft’s rule | the docs say so; the dataset’s own permissions take it back |
| The query log shows every query to everyone signed in: its DAX, and the dataset’s name and id, the measure names, the metric ids and the purpose | agents reuse DAX that worked; DAX can hold a literal value from a filter; a dataset or measure name shows that someone queried it | never a result row; Power BI’s error sentence only to the one who ran the query; query_log:read is one policy rule a deployment narrows |
| A metric event written while the metric held no variants stays visible to everyone after the metric is merged away or deleted | it was visible to everyone when written | name and ids only, never definitions |
| Gaps in the change feed’s event ids show how much hidden activity there was | ids are one sequence for every reader, and a reader asks for what comes after the last id it saw | a gap says that something happened, never what, where or by whom; the feed filters before it cuts the page, so a hidden event never costs a reader a slot |
| Metadata reaches the embedding provider | search embeds names, descriptions, display folders and term values | the company’s own account; never DAX, a result or a thread’s text; a text is cut at 8,000 bytes; search.provider: none turns it off |
| A search query’s text reaches the embedding provider | an agent can paste anything into query, a result’s values included |
the company’s own account; at most 500 characters; 60 searches a minute per principal; search.provider: none turns it off |
| The ID token from Microsoft’s token endpoint is read without a signature check | OpenID Connect Core 3.1.3.7 allows it over TLS from the token endpoint | https to a fixed host only, outside development and test; at sign-in iss, aud and tid checked against the settings, at the connection tid and oid |
| Service-account queries run as the app’s service principal, not a person | Microsoft gives a service principal no person’s row-level security, and refuses it models that have it | off by default; only the workspaces an admin adds it to; Cerbos per service account; the api refuses a service account a model with row-level security before it fetches any token, so the rule does not rest on Power BI alone (query_identity_security_test) |
A Microsoft email claim names a domain the tenant does not own |
Entra fills it from mail, which it does not check, and hybrid tenants let on-premises staff write it |
single tenant only; a person assignment matches only an email nobody has signed in with yet; xms_edov hardening is a follow-up |
| A sign-in provider vouches for an email it should not | an admin trusts the provider they add | Microsoft sign-in is single tenant; a provider is added with a test sign-in; a company that wants the Power BI connection tied to the verified email turns on powerbi.connection_requires_matching_email |
| A person connects a Power BI account that is not their own, such as a shared BI account | companies run DAX under shared accounts, and the person proved they can sign in as it | Power BI decides every query by that account’s rights; the account gives no role and no tenant membership; each person’s tokens are their own; the query log names the account; powerbi.connection_requires_matching_email forbids it |
| A leaked CSV link opens one result to anyone who holds it | the link is a bearer credential, so that an agent can download straight to a file | it names one result, expires after 15 minutes, is GET only, is limited per address, and its download is audited with the address; the owner must still hold a role; revoking the minting key or session leaves links working for up to 15 minutes, while disabling the account or removing the person’s roles ends them at once |
Without TRUSTED_PROXY, 60 link downloads a minute are shared by everyone behind the proxy |
the proxy’s address differs per deployment target | set TRUSTED_PROXY so the client’s address is read; until then a busy minute can refuse other people’s link downloads for up to a minute |
Without TRUSTED_PROXY, every sign-in behind the proxy counts against one address |
the proxy’s address differs per deployment target, so the shipped .env leaves it empty |
stack/env.example says what to set and compose.yml pins the edge range for it; until it is set, 60 starts a minute is shared by the whole company, so a busy morning or one person retrying in a loop can refuse everyone’s sign-in for up to a minute; and no service account gets source_cidrs, since every request would seem to come from the proxy |
Under CLIENT_ADDRESS_HEADER=x-real-ip, anyone with a route to the api past the platform edge names any client address |
the mode trusts one header and Railway does not say its edge replaces a client-sent one; the private network from other services and a TCP Proxy reach the api without the edge | the mode is opt-in and off by default; GET /api/v1/me shows client_address so an operator checks it first; it cannot be combined with TRUSTED_PROXY, and source_cidrs is refused under it; an attacker with such a route can vary the address to escape per-address limits, spend a victim’s limits so they are refused, forge the address in audit rows and session records, and set a device code’s started_from to the victim’s address |
| Passkey user verification is checked after Better Auth verifies the response, not by the WebAuthn library | Better Auth’s passkey plugin verifies with requireUserVerification: false and has no option to change it |
its afterVerification hooks refuse a registration or a sign-in without the UV flag before a passkey is stored or a session made, from the flags the signature covers |
| Better Auth is a young OAuth server | advisories in 2026, some critical, all fixed | pinned exact, advisories read on every bump, our own flow tests with the refresh-reuse test written first |
| A person presses Allow for a deceptive client | consent cannot read intent | the consent screen names the client and the redirect host; grants are revocable; audit records via and client_id |
A token with edit_conventions turns checks off (checks.off, or a field’s required: false), so agent_verified then means less |
the token’s role is the company’s decision, and the conventions are one document an admin may change | accepted: agent_verified is the agent’s own tier; human_verified still needs a browser session, and trust.* itself changes only from one; every conventions change and every verification is in book_change, with who and through what |
| Text in data steers an agent’s answer | the connector returns what Power BI, the book and threads say | the connector cannot change the book; approval needs a browser; agent rule 5 |
age-encryption is pre-1.0 |
the TypeScript age library has not reached 1.0 | wrapped in one module; the format is age’s, which is stable |
| A secret is readable inside its own container | a container must read what it uses | one process per container, non-root, each holding only its own secrets |
| Root on the host opens everything | the identity volumes are on it | hardened containers, identities per container, backups of dumps and identities kept apart |
| The mock is not Power BI | no public mock exists and no client tenant is used | shapes from Microsoft’s documentation and OpenAPI file; pnpm test:live on a test tenant before release |
When this model must be updated
Section titled “When this model must be updated”In the same pull request as a change that:
- adds, removes or moves a container, a network, a volume or a published port;
- adds a kind of route or credential, a sign-in method, or a public path;
- adds an outbound destination, a Microsoft permission, or a new kind of data leaving the stack;
- changes a database role or its grants, or which container reads a secret or a token;
- changes who may read a result, the query log or the mirror, or what the connector may change;
- adds a third-party component that runs inside the stack or instructs the agent: an image, a skill, an Action.
And when a residual risk changes: Microsoft changes what a scanner or a
service principal may do, Better Auth’s advisories change, age-encryption
reaches 1.0, or a security incident shows a gap. The security review
(agents.md, rule 3) asks every time whether the change moved a boundary.