Skip to content
Not released yet: nothing on these pages installs until 0.1, the first release. Run it on the sample tenant today.

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.

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
  • The operator who runs the host and holds the deployment’s .env and 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. CODEOWNERS makes 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.

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

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 operator

Crossing: people’s browsers, MCP clients, the CLI and service accounts.

  • Only api publishes 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/v1 path 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. isPublic matches a :param as exactly one non-empty segment of the decoded path, for GET only.
  • The SPA’s shell is public, GET only: GET / and GET /assets/:file (index.html and the build’s hashed assets), and each screen’s path in SPA_PATHS (http/static.ts). That list includes the admin pages, /review/metrics/:name and 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/config is 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 of powerbi.authority is localhost, *.localhost, 127.0.0.1 or [::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_link records 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 its setting row, then makes the admin, writes the deployment_tenant row 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 asks deployment_has_admin(), a security definer that answers one bit; it gets no read of role_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 and powerbi.authority is 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 on setting keeps powerbi.tenant_id at the bound tenant for every writer, a second refuses to rename any row, and both functions pin their search_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.ts proves 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 by requirePrincipal, which every /api/v1 route 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 its iss, aud and tid name 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’s email claim, or preferred_username when 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. email comes from the directory’s mail, which Entra does not check against the tenant’s domains and which on-premises directory staff can write in a hybrid tenant; Microsoft’s xms_edov claim 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 whose hd is 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/auth answers 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 needs NODE_ENV development or test and ALLOW_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 scope dev-sign-in. With the dev sign-in shut, the api refuses to start while any such row or any person_sign_in row with provider dev exists, 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 last X-Forwarded-For entry, with any port dropped, then names the client; no other request picks the address its attempts count against. CLIENT_ADDRESS_HEADER=x-real-ip is a second, opt-in mode for a platform edge that sets X-Real-IP and publishes no proxy range (Railway). The api then counts the one address in that header, ignores X-Forwarded-For, and refuses to start with TRUSTED_PROXY also set. It holds only if every request comes through an edge that replaces a client-sent X-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/me with a spoofed X-Real-IP must show their real address as client_address. Caddy and Traefik as configured here pass a client’s X-Real-IP through, so the mode is never safe behind them, and compose.yml does 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, one connector.whoami row in audit_event (recordConnectorReached), which the setup’s agents check reads as proof that an admin’s own client reached the connector. Its tools reach the services on the list in connector_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 jwks table (signature, typ at+jwt, iss, expiry by the api’s clock). aud is the one resource the token was issued for, /api/mcp or /api/v1; at the other it is 401 invalid_token. Scope mcp covers /api/mcp, api covers /api/v1; a token without it is 403 insufficient_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 through revokeConnection (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_grant because 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_type compared 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-cli to /api/v1. RFC 9207 iss; no client_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 refuses backchannel_logout_uri, backchannel_logout_session_required, jwks_uri and post_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, so oauth_client cannot 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_token values, then reads the presented refresh tokens in one query.
    • Consent is answered only from a browser session, over GET /api/v1/oauth/consent?<query> and POST /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 show claimed_name against vouched_host, never the name alone, and inside <bdi>. Registration refuses a client_name over 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 but mcp and offline_access is refused, since the lists describe only those. The sign-in page shows its own sentence for each error the api sends it, never error_description, which anyone can put in a link.
    • The device flow is for pbmb-cli alone (api offline_access, /api/v1). Against phishing: no user_id may pre-bind a code; no verification_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 told slow_down. The SPA’s /device must never prefill the code from ?user_code=, and must show started_at and started_from.
    • Device and user codes are stored in clear, and the JWT signing key’s private half in jwks.private_key is encrypted with AUTH_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/approve and /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.
  • 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 from created_at, which api_rw may 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’s max_key_age_days as that stands now, so lowering it stops older keys at once. A key works on /api/v1 only, 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_cidrs is matched against the client address as sign-in reads it: X-Forwarded-For counts only from TRUSTED_PROXY, and an account gets no source_cidrs while that is unset or CLIENT_ADDRESS_HEADER=x-real-ip is set, since a key limited by address must not rest on a header nobody verified. Each account makes at most service_accounts.rate_per_minute requests 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-key in .gitleaks.toml).
    • A service account never administers: a CHECK keeps admin out of its roles, and createAccess refuses it book:edit_conventions and every admin action except admin:read_status in 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 gave read_status reads 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_owner and thread_author derived roles compare the owner’s kind as well as its id.
    • Only admin:manage_service_accounts makes, 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 own admin action and audit every change. admin:read_status (the status page and pbmb admin status) is the one a deployment may also give a read-only role; every other admin action stays admin’s. A caller who also passes admin:manage_settings sees 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.
    • admin is assigned to a person or a group only, never to everyone in the tenant or an email domain (a CHECK on role_assignment), and the last admin assignment 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.
  • 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. A ServiceError reaches 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_test follows 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_verified is 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 a book.yml import by one is refused. setConventions checks 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 in export.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.

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 connected and 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, under powerbi.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: under powerbi an 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_dataset as 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. The dataset, workspace and app filters 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 as upstream with a fixed sentence), and bad input (a query over 500 characters, a limit outside 1 to 50, more than 16 custom fields) answers invalid.
  • One database role per container: api_rw, worker_rw, and audit_writer, which may only insert into audit_event and query_log. migrator owns 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_ids and measure_ids the 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 as visibleMetric shows it now, so a name written while the metric was hidden does not surface when it later becomes visible. For a caller without pii_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 recorded measure_ids is 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_id and measure_id it recorded. A caller without pii_reader sees it only when the measure is not PII;
    • a thread event as findThread shows the thread, by its element;
    • term and conventions events to everyone, as they are readable by all;
    • sync.* events only with admin:read_sync_events, which a service account is refused in code, and their error only with admin: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.

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 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.

Crossing: jobs, through Postgres only.

  • The two never call each other. A job is a row in job_request, taken with for update skip locked by the container its runner names.
  • 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 (check does: 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 embed job 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 it for share and holds the lock until it commits, so the worker, which takes with for update skip locked, cannot take and finish it before the change is visible. The job reads book_change from a cursor in search_state, which only worker_rw writes.
  • The search tables: search_document is written by worker_rw and read by api_rw; search_state is worker_rw’s alone (select, insert, update); embedding_usage is 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.

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 fetch anywhere else.
  • Fixed hosts (fetchFixed): https to the configured Microsoft identity, Power BI, Graph and embedding hosts only, no redirects, with size and time limits. The mock’s http URL is allowed only when NODE_ENV is development or test. 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 be https outside development and test (authority_security_test), and an OpenID Connect provider’s token, keys and userinfo requests. The provider’s discovery goes through fetchUntrusted; its authorization, token and keys endpoints must be on the issuer’s own origin, over https outside 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 BI Dataset.Read.All, Graph User.Read) and names any other, Tenant.Read.All and Tenant.ReadWrite.All with their own sentences. It refuses a registration whose client-credentials token has a roles claim (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.error and embedding_usage never 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 its Retry-After is 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: none turns that off. A query’s embed waits 5 s at most and never on Retry-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 set prompts 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-identity makes 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 queues rekey_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_dump goes 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.

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 of assignRole.
  • 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 with save_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 one import_id; a rolled-back import writes one line saying so.

Tests: apps/api/src/services/admin/export.test.ts.

Crossing: a person’s delegated grant, from Microsoft into the api and out to Power BI.

  • The connection uses PKCE S256 and a state bound to the session, single use, 10 minutes, kept in Postgres as its SHA-256 only. The callback refuses a tid that 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 to person_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. Under powerbi.connection_requires_matching_email the strict rules hold: a person with a Microsoft account on record in person_identity (from signing in with it, or an earlier connection) must connect that oid, however the session signed in, a passkey included; only a person with none may connect an account with their verified email. That oid goes into person_identity in 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 (next is an allowlisted relative path), never to another origin. Connects, refused connects and disconnects are in audit_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_connections renews 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 is connected (a CHECK constraint). The worker has no grant on powerbi_connection and never holds the api’s identity, so rekey_tokens and refresh_idle_connections run 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 sets needs_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_queries is 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).

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_link principal 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 its source_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-For only from TRUSTED_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.by set to csv_link (and the address) or credential. A refusal is a deny row with the error kind as reason and, 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 a deny row as the presenter, with detail.link_owner naming 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).

Crossing: the first admin, before anyone can sign in.

  • With no admin yet, the api logs at warn only 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-link prints 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_link keeps 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 /setup with the token in the fragment (/setup#token=...), which no request, log line or Referer carries. The screen posts it to /api/setup/session to spend it, then clears it from the address bar. /setup and /api/setup answer with Referrer-Policy: no-referrer.
  • Spending the link opens a setup session for 12 hours at most: an HttpOnly, Secure, SameSite=Strict cookie, __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, every setup_link row is deleted, so no link or session outlives the first admin. The paths are outside /api/mcp and /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=Lax to 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 OAuth state that 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.

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:all runs gitleaks, osv-scanner, trivy (the image and compose.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.

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.

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).

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

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.