Auth & Security Specification
Status: v3.60 (revised — see Change Log) Owner: datapipelines.co core Depends on: Type System Last updated: 2026-10-25
1. Purpose
This spec defines how users authenticate (OIDC via any provider — Google, Microsoft, Okta, Auth0, Keycloak, etc.), how sessions work (internal JWT after OIDC login), how agents authenticate (API keys), what authenticated principals can do (roles — a member's, or a key's own, §7.5), and the Spring Security wiring that ties it all together.
The product is self-hosted, internal-users-only. Identity is delegated to any OIDC-compliant provider via OpenID Connect by default; deployments without an IdP may additionally enable optional local password accounts (§5A), stored as Argon2id hashes. After any login, the server issues its own JWT for stateless session management.
2. Design Principles
- Identity is delegated by default; local accounts are the narrow exception. The server never sees or stores OIDC user passwords — identity belongs to the external provider. The one exception (§5A): a deployment with no IdP at all would otherwise be unusable — evaluating the product required registering an OAuth client before seeing a single screen, the biggest adoption barrier the product had. For that deployment shape, optional local accounts exist: admin-created only (no self-registration), stored as Argon2id hashes (never plaintext, never in config beyond the one-time first-admin seed), forced to rotate seeded/reset credentials at first login, and off unless explicitly enabled. An OIDC-only deployment behaves exactly as before.
- Generic OIDC, not provider-specific. Any OIDC-compliant provider works — Google, Microsoft, Okta, Auth0, Keycloak, Ping, AWS Cognito, etc. The deployment configures which provider(s) to enable; the code is provider-agnostic.
- OIDC for humans, API keys for agents. Users log in via browser (OIDC redirect flow). Agents (Claude, GLM, Copilot) authenticate via API keys issued from the UI.
- Internal JWT after OIDC. Once OIDC validates the user, the server issues its own JWT (8h TTL). The JWT is the session — any instance can validate it statelessly. OIDC tokens are NOT used for ongoing session management.
- API keys per agent, not global. A user generates one key per agent. Compromised keys are individually revocable. Per-agent usage visible in audit logs.
- Keys hashed at rest. Same protection as passwords would be. A database leak does not expose working credentials.
- Roles decide, and every credential has exactly one. A session holds its membership's role; every key its OWN role (§7.5) — an
mcpkey the member role chosen at creation under the subset rule, an API or server key its transport role. There are no scopes (#215 slice (b)). - Stateless server, near-live revocation. JWT + API key validation are stateless across instances; both paths re-check the principal's liveness (user
is_active, key revocation) through a short-TTL cache backed by Postgres (§6.3, §7.3). Deactivating a user or revoking a key takes effect within ~1 minute — never the full JWT lifetime.
3. Authentication Architecture
┌──────────────────────────────────────────────────────────────┐
│ Browser (Human User) │
│ │
│ 1. User visits /login │
│ 2. Sees buttons for each configured OIDC provider │
│ 3. Clicks one (e.g., "Sign in with Google", "Sign in with │
│ Okta", "Sign in with Company SSO") │
│ 4. Redirected to OIDC provider │
│ 5. Authenticates at provider │
│ 6. Provider redirects back with authorization code │
│ 7. Server exchanges code for OIDC tokens │
│ 8. Server validates ID token, extracts user identity │
│ 9. Server creates/updates user record in Postgres │
│ 10. Server issues internal JWT (8h TTL) │
│ 11. Server sets HttpOnly cookie: dp_session=<JWT> │
│ 12. Redirect to / │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Agent (Claude / GLM / Copilot) │
│ │
│ 1. User has previously logged in and generated an API key │
│ 2. Agent sends: DP-API-Key: dpk_<id>.<secret> │
│ (or, on /mcp: Authorization: Bearer dpk_<id>.<secret>) │
│ 3. Server validates key hash (Argon2id) against Postgres │
│ 4. Server resolves the user the key acts as + its role │
│ 5. Request authenticated │
└──────────────────────────────────────────────────────────────┘
Both paths resolve to the same internal principal:
data class AuthenticatedPrincipal(
val userId: UUID,
val email: String,
val displayName: String,
val authMethod: AuthMethod, // OIDC, API_KEY or PROMOTION
val keyId: String?, // present when a key authenticated
val workspace: WorkspaceContext?, // the resolved role in the active / pinned workspace
val keyRole: KeyRole?, // every key's own role (keys v2 A13; §7.5)
)
4. User Identity
4.1 User entity
{
"id": "uuid",
"email": "alice@company.com",
"display_name": "Alice Wang",
"profile_picture_url": "https://lh3.googleusercontent.com/...",
"provider": "google",
"provider_subject": "108214793245678901234",
"is_active": true,
"is_admin": false,
"created_at": "2026-08-01T10:00:00Z",
"last_login_at": "2026-08-05T14:30:00Z"
}
The provider field stores the OIDC registration name as configured by the deployment (e.g., google, microsoft, okta, company-sso). It's free text — any provider name the deployment configures. Not constrained to a fixed enum.
4.2 User provisioning
First login: When a user logs in via OIDC for the first time:
-
Server extracts
email,name,sub,picturefrom the OIDC ID token — for aprotocol: oauth2provider, from its user-info and emails endpoints instead (§5.5, #655). The email is normalized to lowercase before every lookup and store — provider case differences must not fork one human into two rows (or mint a second bootstrap admin, §4.4). If the ID token carriesemail_verified: false, the login is rejected (auth.login.oidc_error, audited) — an unverified self-registered account at a provider must never reach the email-keyed linking step below, or it takes over the existing account with that email. A provider omitting the claim is now also treated as NOT vouching for the address (2026-09-21, #187 — fail closed): the login is rejected the same way. An IdP that never emits the claim must be trusted explicitly, per provider, withdatapipelines.auth.oidc.providers[].trust-email-without-verified-claim: true(§5.1); each acceptance under the knob is audited (auth.login.email_verified_assumed). A claim that is present andfalseis refused whatever the knob says. -
Checks if a user with that
emailalready exists.- Yes: the stored identity is compared with the incoming one (2026-09-21, #187 — an identity is linked ONCE, never silently re-linked):
- the row is the
provider = 'bootstrap'placeholder — this login completes it (the §4.4 pre-provisioned admin's first sign-in);provider,provider_subject,last_login_at,profile_picture_urlanddisplay_nameupdate (thedisplay_namerefresh is owner-ratified 2026-08-28: there is no profile-edit feature, so a stored name has no user-chosen referent to protect, and freezing it would leave an IdP rename unrepresentable; revisit if a profile surface ever ships); - the stored
(provider, provider_subject)equals the incoming pair — the same person signing in again; the same refresh runs; - anything else — a different provider, a different subject under the same provider, or a
local/systemrow — the login is refused: nothing is updated, the event isauth.login.identity_mismatch(naming the two providers, never the subjects), and the redirect is/login?error=identity_mismatch("This email is already linked to a different sign-in identity. Ask an administrator to reset it."). Re-linking an account is the super admin's explicit identity reset on the admin users screen (user.identity_reset, §7.6, auditedauth.user.identity_reset), which returns the row to the bootstrap placeholder so the NEXT sign-in with that email claims it. Grant-wise this step changes nothing:is_adminis decided at row creation and nowhere else (§4.4).
- the row is the
- No: Creates a new user record. Default
is_active: true,is_admin: false. 2a. Materialises every pending invitation for that email (§4.6) into real memberships, with their roles, in the same act — and DELETES the invitations. The materialise runs inWorkspaceService.workspaceForLogin(step 4), the one resolution both credential paths reach, so it happens before any workspace is stamped: the first login lands an invited user in the invited workspace with the invited role, and thedemojoin below never fires for them (D-R11 — the invited workspace REPLACES the default, it is not added beside it). Each materialisation is audited (workspace.invitation_materialised) with the inviter.
- Yes: the stored identity is compared with the incoming one (2026-09-21, #187 — an identity is linked ONCE, never silently re-linked):
-
Checks the email domain allowlist (if configured — see §4.3).
- Domain not allowlisted: reject login with
auth.login.domain_not_allowed.
- Domain not allowlisted: reject login with
-
Workspace resolution (§5.1, §12): determines the active workspace the JWT stamps — the user's last-used workspace when it still resolves to a live, ACTIVE membership, else their first active membership, else — this is the D-R11 rule — a fresh viewer membership of
demo, the one workspace the product ships (created byDemoWorkspaceSeederat first boot, with the example content, and never recreated once deactivated — O-3). The demo join fires only for a user with NO membership at all, so somebody removed fromdemoon purpose is not re-added by their next login. It lives inWorkspaceService.workspaceForLogin, shared by both credential paths: the owner's rule is about logging in, not about which provider did it. All three stamps — last-used, first membership, the demo join — are built by the one super-admin-aware helperresolveForSessionuses (#262, #271): a super admin's login response keeps the instance authority whichever fallback stamped it.A user still with nothing selectable —
demodeactivated, or never seeded — stamps nothing. They authenticate fine and every workspace-scoped operation is refused; round 2 draws the "no workspace" page.The provisioning modes are gone (D-R11).
auto-per-user(a personal workspace per first login),self-serve(anyone creates) andclosed(admin only) were removed withopen-joinin RBAC round 1, and both config keys are refused BY NAME at startup (Configuration §3.17). Workspaces are created by super admins. Existing personal workspaces stay as ordinary workspaces, with their sole member as the workspace admin (D-R14). -
Issues internal JWT (stamping
active_workspacewhen step 4 resolved one), sets cookie, redirects to/.
Subsequent logins: Same flow — user record updated, JWT reissued.
Account deactivation: Admin marks user is_active: false via UI. Subsequent OIDC logins are rejected with auth.login.user_inactive. Existing sessions and API keys stop working within the liveness-cache TTL (~60s): every authenticated request re-checks is_active through the cache (PrincipalLiveness, §11A.3), so a deactivated user's JWT and keys are dead within a minute, not at the 8h JWT expiry — refused with auth.principal_deactivated (401; a page navigation lands on /login?error=inactive). Nothing is revoked: reactivation restores them. API keys additionally remain individually revocable.
4.3 Email domain allowlist
Configurable per deployment:
DATAPIPELINES_AUTH_ALLOWLIST_DOMAINS=company.com,subsidiary.com- If set, only users with emails from these domains can log in.
- If empty (default), any Google/Microsoft user can log in (open provisioning).
For internal-only deployments, always set the allowlist to prevent random Google accounts from accessing the instance.
4.4 Bootstrap admin
A fresh deployment has zero admins (users.is_admin DEFAULT FALSE, no seed rows) — so without a bootstrap path, nobody can ever grant admin. The mechanism (added 2026-08-07, security review LOW-11; extended 2026-08-29 to seed the first admin's local credential, §5A.2):
- The operator sets
datapipelines.auth.bootstrap-admin-email(Configuration §3.4) before first start. - When a user row is created (first provisioning, §4.2) and its lowercase-normalized email matches the configured value (compared case-insensitively),
is_adminis set true. The grant fires only at row creation: a later login changes nothing, the flag is never revoked by this path, and after an admin deliberately revokes admin (auth.user.admin_revoked, §10.1) this path never re-grants it — re-instating admin is an explicit §16.3 operation. - Audit-logged as
auth.user.admin_grantedwith actorbootstrap(§10.1). - If the key is unset, no bootstrap occurs — the deployment simply has no admin until the key is set and that user logs in.
Pre-provisioning (design 2026-08-16-sample-data §6.1). When datapipelines.bootstrap.datasources-file is set, startup needs this user to exist before anyone has logged in: datasources.created_by is NOT NULL REFERENCES users(id), and bootstrap datasource registration runs before the server accepts traffic. So, when no row exists for the configured address (lowercase-normalized, §4.2), one is created with placeholder identity fields — provider = 'bootstrap', provider_subject = the email (together satisfying the NOT NULL columns and the (provider, provider_subject) uniqueness), display_name from the email local-part, is_active = TRUE.
This is not a second grant path. It is the rule above firing earlier in time: the row is created, so the grant fires, once, with the auth.user.admin_granted / actor bootstrap audit event. Everything the rule already promised still holds, and each clause is load-bearing here:
- A restart finds the row and touches nothing on it — no re-grant, no identity rewrite, not even
updated_at. An admin who deliberately revoked admin stays revoked. - The admin's first real OIDC login completes the placeholder through §4.2's linking step:
provider,provider_subject,profile_picture_urland — this case only —display_namefrom thenameclaim. It grants nothing. - If that admin logs in before registration ever runs, §4.2 creates the row and grants there instead; the later pre-provisioning finds it and adds nothing. Either order, exactly one grant and exactly one audit event.
Startup refuses when datasources-file is set and this key is not (Configuration §3.18) — a missing actor would otherwise surface as a foreign-key error mid-startup naming neither key.
Explicitly rejected: "first user to log in becomes admin." Combined with the open-provisioning default (§4.3), that rule is a land-grab race on any reachable instance — whoever hits /login first owns every datasource credential. Do not reintroduce it as a convenience.
4.5 The system service account (R7)
pipeline_versions.created_by and pipeline_executions.triggered_by are NOT NULL REFERENCES users(id). Some writes the system makes belong to no human: a version promoted from another deployment (authenticated by a shared server key, not a principal — Versioning §10.6), a retention sweep, a stale-execution reap. Those writes still need an actor row.
One row serves all of them, provisioned at first boot through the same createUser path §4.2 and §4.4 use, and read back through the one well-known lookup UserService.systemActor() — so promotion, scheduled jobs and any future automated write stamp the SAME actor and nobody mints a second.
| Column | Value | Why |
|---|---|---|
provider |
system |
Reserved at startup validation (Configuration §7) exactly as bootstrap and local are: an OIDC provider named system could link an external identity onto this row through §4.2's linking step |
provider_subject |
system |
A fixed sentinel, never a real subject claim |
email |
system@system.invalid |
RFC 2606 reserves the .invalid TLD as permanently unresolvable — no mail-based flow can reach it. Deliberately not under a datapipelines. domain: that would read as a configuration key to the docs audit, and an address is not one |
display_name |
System |
What history and the audit trail render |
is_admin |
FALSE |
It is an actor, not an authority. Every path that stamps it already knows the workspace it is writing into; nothing here needs a membership bypass |
Login is disabled by construction, not by a flag. Three independent facts each make it impossible: no OIDC provider may be named system, so no external identity can link to the row; the address cannot resolve; and the local-password paths refuse it — createLocalUser at the reserved address answers EmailTaken (the row already holds it), and resetPassword on a non-human row answers "no such user". Since #215 its row also carries users.kind = 'system', and every login, linking and user-administration path refuses a non-human row by that ONE predicate (§4.7). There is nothing to disable because there is no credential to hold.
Idempotent. A restart returns the existing row untouched — no re-grant, no identity rewrite, no updated_at bump, the same contract §4.4's pre-provisioning states. Two replicas racing a fresh database settle it by catch-and-reread, exactly as the bootstrap actor does (ARCH-AUDIT M5).
It is deliberately not a credential. It holds no API key and no password, and nothing authenticates as it; it is the name history writes down when the writer was the system itself. The one exception is the promotion receiver's DEPRECATED config-value credential (Versioning §10.6): a shared secret with no row to attach an identity to, so a push it opens acts as this account (#215 B6). A STORED server key acts as its own identity instead (§4.7, §7.7).
It is the identity schedules fire under (#9, R2). A scheduled launch is built IN-PROCESS as an AuthenticatedPrincipal for this row — AuthMethod.SYSTEM, a workspace context for the schedule's workspace marked as the system actor's (SystemActorPrincipals.forWorkspace) — never by a filter, and no request can carry it (WorkspaceResolutionFilter refuses one that did). Its permission questions go to PermissionResolver's system arm (holdsAsSystemActor), which answers a FIXED set in any workspace — pipeline.execute, pipeline.read, execution.read (the launch, the pinned version's read, the reconciler's execution read) — and nothing else: no instance permission, no role, no key. The set is code (RolePermissions.SYSTEM_ACTOR), not a §7.6 column, so the role walk never sees it; SystemActorPrincipalTest pins it against a literal and proves a widened resolver is caught. A scheduled execution records executed_by = this row and triggered_via = SCHEDULE; the schedule and a Run now's person are on the scheduler's own schedule_runs row (scheduler design revision §4).
4.6 Invitations
The owner's scenario (2026-09-10): "Admin created a workspace and wants to add employees to
that… They will have two options: 1. create user/password, 2. company SSO." Today a member is
added through POST /workspaces/{name}/members (§17.7), which requires the users row to
exist — and an SSO user's row is created only at their FIRST login (§4.2). So a workspace
admin could not add bob@company.com before Bob had ever signed in. The bridge is an
invitation keyed by email (metadata-db §4.17) that the login path materialises.
The rules, each load-bearing:
- An invitation exists only while no
usersrow holds that email. Inviting an email that has a row makes the person a member AT ONCE (the ordinary member-added path). There is no invitation "pending for an existing user", and nothing pretends a person exists before they do: the invitation references an EMAIL;workspace_members.user_idstaysNOT NULL REFERENCES users(id). - Invite-time refusals. The email is normalized lowercase before every lookup and store
(the §4.2 rule —
Bob@Company.comandbob@company.comare one invitee). An email the §4.3 domain allowlist would refuse AT LOGIN is refused at INVITE time with the SAME code the login would use (auth.login.domain_not_allowed, at a 400 here — the caller's request is the defect): an invitation that could never be honoured is a trap. Inviting into a DEACTIVATED workspace is refused (workspace.inactive, 404) — a super admin can see the greyed workspace, and this is the verb telling them the same thing every other member verb would. - The latest admin decision wins. Re-inviting the same email UPSERTS the row: the role
(and inviter, and date) is replaced, and the upsert is audited (
workspace.member_invited) every time it fires — the row keeps no history; the audit trail does. The role is one of the fourWorkspaceRolevalues, enforced by the same kind of database CHECK the members table states (D20). - Materialisation at login. When a user row comes into existence (§4.2's first login, or
the admin's local-account creation — the two creation paths), every pending invitation for
that email becomes a membership with its invited role, and the invitations are deleted,
in one atomic statement. The
demo-viewer default (§4.2 step 4) is applied ONLY when no invitation materialised — the invited workspace replaces the demo default, it is not added beside it (D-R11) — andactive_workspacestamps the FIRST-INVITED workspace: the materialised memberships enter the membership ordering at their invitation dates (joined_at = invited_at), so "first membership" is the admin's first decision, not an alphabetical accident of same-transaction timestamps. - Deactivated workspaces wait. An invitation into a deactivated workspace does not materialise at login — the materialise predicate requires the workspace to be active — and is not lost either: reactivation makes it live at the next login. The row can be revoked meanwhile by a super admin, which is exactly the tidying a super admin inside a greyed workspace is for.
- Revocation and visibility.
DELETE /workspaces/{name}/invitations/{email}revokes (workspace admin or super admin,workspace.members.manage); a missing invitation isworkspace.invitation.not_found(404). The members listing (§17.6) returns pending invitations in a SEPARATEinvitations[]array, never mixed intomembers[]— a client that counts members must not count ghosts.
No expiry in v1, and no email is ever sent — the product has no SMTP (§5A); an invitation
is a row, not a message, and it is revocable like any other membership. There is no
self-service join: invitations are created by a workspace admin or a super admin, through the
same permission gate as every other member verb (workspace.members.manage).
Local accounts need no invitation (§5A.1): the Admin → Users form's optional "also add to workspace, with a role" writes the user row and the membership in one act, because the row exists by the time the membership is written — rule 1 doing the work directly.
4.7 Key identities
Since #215 (the permissions and keys record §3.3, PK5, ratified 2026-09-23) an endpoint key and a server key each act as their own non-login identity — a users row with kind = 'service' — never as the person who created them. The MCP (user) key is the one exception: it acts as its member (§7.5).
users.kind is human | service | system (V34; UserKind). V34 set system on the System row (§4.5) and human on every other; one identity per existing endpoint/server key was created in the same migration (PK9).
The identity is built exactly like the System account, so login is impossible by construction:
| Column | Value | Why |
|---|---|---|
kind |
service |
The ONE predicate every login, linking and administration path refuses (below) |
provider |
key |
Reserved at startup (Configuration §7) exactly as system, local and bootstrap are: an OIDC provider named key could otherwise link an external identity onto the row |
provider_subject |
the key id | A key's identity is its key's, one-to-one |
email |
<key id>@keys.invalid |
RFC 2606's unresolvable .invalid — no mail-based flow can reach it, and createLocalUser refuses the domain |
display_name |
the key's name | Keys have no rename (record A6), so the name history shows is the name the key was created with |
is_admin |
FALSE |
No key is ever a super admin (B1, §7.5) |
| password | none | Hashless, and the local-credential read returns PEOPLE only |
- Created with its key, in one transaction (B4):
ApiKeyService.issueprovisions the identity and inserts the key under@Transactional("metadataTransactionManager")— a half-created pair cannot exist. The key row'suser_idis the identity;api_keys.created_byrecords the person who created it (the Keys page's "Created by"). - Revoking the key deactivates the identity (
is_active = false, in the same transaction). History keeps the row, so runs and received versions still name it: history renders "<key name> (API key)". - Lifecycle is derived, never cascaded (record A2). Deactivating or deleting a workspace revokes no key and deactivates no identity; the ONE liveness predicate (§11A.3) reads the identity, the key and the pinned workspace on every request, so reactivating the workspace restores exactly what was live before.
- Managed only through its key (record A3). The user-administration routes — REST (
/api/v1/auth/users/**) and the admin screen's list, row actions, identity reset, password reset and mail status — look the row up BEFORE any mutation and answer an unknown-user 404 for a non-human row; the admin users page, members lists and invitations showhumanrows only; adding a non-human row to a workspace is refused as "no such person". - Attribution (record C5, C4):
pipeline_executions.executed_by,audit_log.user_idand everycreated_bywritten on the key's behalf name the identity. The per-user concurrency limit (ExecutionSlots, keyed onexecuted_by) is therefore a per-KEY limit. Runs a key served before V34 keep their original attribution (the key's creator); the serve audit still pairs those with the key (§7.7).
One predicate: a person is kind = 'human'. It guards OIDC linking (a service or system row is never claimable, whatever its provider says — the kind is judged before the provider), the identity reset (which refuses a non-human row, so the bootstrap flip can never make one claimable — this closed a pre-existing hole for the System row), the local-credential read, the password reset, membership and invitation materialisation, the session filter (a token naming a non-human row is auth.session.invalid) and every user-administration route.
5. OIDC Login Flow
5.1 Provider configuration (generic)
OIDC providers are configured as a list in the deployment config. Each provider requires only three values — client-id, client-secret, and issuer-uri. The issuer URI triggers OIDC discovery (/.well-known/openid-configuration), which auto-configures all authorization, token, userinfo, and JWKS endpoints.
Two protocols (#655). An entry's protocol is oidc (the default — everything above) or oauth2, for a provider that speaks OAuth 2.0 without OpenID Connect: no discovery document, no ID token, no openid scope. GitHub is the shipped one. Discovery cannot build such an entry — the entry GitHub shipped as until #655 (issuer-uri: https://github.com/login/oauth) failed at startup the moment a client-id was set — so an oauth2 entry names its endpoints instead, and needs no HTTP at startup:
Key (oauth2 only) |
Required | Meaning |
|---|---|---|
authorization-uri |
yes | Where the browser is sent to consent |
token-uri |
yes | The code-for-token exchange (client_secret_basic, Accept: application/json) |
user-info-uri |
yes | The profile read, with the access token as bearer |
user-name-attribute |
yes | The user-info attribute that is the stable subject (users.provider_subject); GitHub: id |
scopes |
yes | The scopes requested — never openid, which would route the login to the OIDC user service |
emails-uri |
no | A list of {email, primary, verified} rows; when set, it is the ONLY source of the address (§5.5) |
issuer-uri is refused on an oauth2 entry, and the oauth2-only keys are refused on an oidc entry (discovery would silently ignore them). Every endpoint is https://; plain http:// is accepted only for localhost / 127.0.0.1 (a test stub). Configuration §7 enforces all of it at startup, naming the provider and the key.
Each provider takes one optional trust knob — trust-email-without-verified-claim (default false, #187): set it true ONLY for an IdP that never emits the email_verified claim, so a missing claim is accepted as verified (audited auth.login.email_verified_assumed). A claim that is present and false is refused regardless.
datapipelines:
auth:
oidc:
providers:
- name: google # registration ID (used in URLs, stored in users.provider)
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
issuer-uri: https://accounts.google.com
display-name: "Sign in with Google" # shown on login button
# trust-email-without-verified-claim: true # ONLY for an IdP that never emits the claim (§4.2, #187)
- name: github # OAuth 2.0, not OIDC (#655)
protocol: oauth2
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
authorization-uri: https://github.com/login/oauth/authorize
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
user-name-attribute: id
scopes: [read:user, user:email]
emails-uri: https://api.github.com/user/emails
display-name: "Sign in with GitHub"
- name: microsoft
client-id: ${MS_CLIENT_ID}
client-secret: ${MS_CLIENT_SECRET}
issuer-uri: https://login.microsoftonline.com/common/v2.0
display-name: "Sign in with Microsoft"
# Any other OIDC provider:
# - name: okta
# client-id: ${OKTA_CLIENT_ID}
# client-secret: ${OKTA_CLIENT_SECRET}
# issuer-uri: https://company.okta.com
# display-name: "Sign in with Okta"
#
# - name: keycloak
# client-id: ${KEYCLOAK_CLIENT_ID}
# client-secret: ${KEYCLOAK_CLIENT_SECRET}
# issuer-uri: https://sso.company.com/realms/main
# display-name: "Company SSO"
Scopes requested for every oidc provider: openid, profile, email. These are the standard OIDC scopes that give us the user's identity. An oauth2 provider requests exactly its configured scopes (GitHub: read:user for the profile, user:email for the emails endpoint).
5.2 ClientRegistration bean (built at startup)
The provider list is converted into Spring Security ClientRegistration objects at startup. OIDC discovery fetches each provider's .well-known/openid-configuration to auto-detect all endpoints.
@Configuration
class OidcConfig {
@Bean
fun clientRegistrationRepository(
authConfig: AuthConfig
): ClientRegistrationRepository {
val registrations = authConfig.oidc.providers.map { p ->
ClientRegistration.withRegistrationId(p.name)
.clientId(p.clientId)
.clientSecret(p.clientSecret)
.scope("openid", "profile", "email")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// ABSOLUTE, from datapipelines.auth.base-url — NEVER the request-derived
// {baseUrl} template: an attacker-controlled Host / X-Forwarded-Host would
// otherwise choose the redirect_uri sent to the IdP, and the only backstop
// is the IdP's own allowlist, a control this deployment does not own.
.redirectUri("${authConfig.baseUrl}/login/oauth2/code/${p.name}")
.issuerUri(p.issuerUri) // triggers OIDC discovery
.clientName(p.displayName)
.build()
}
if (registrations.isEmpty()) {
error("No OIDC providers configured. Set datapipelines.auth.oidc.providers in config.")
}
return InMemoryClientRegistrationRepository(registrations)
}
}
The branch (#655). The sample above is the oidc arm. OidcConfig dispatches on protocol: oidc builds through discovery (ClientRegistrations.fromIssuerLocation) with the three OIDC scopes; oauth2 builds from the configured endpoints with the authorization-code grant, client_secret_basic, the configured scopes and user-name-attribute, and makes no HTTP call. Both arms share the registration id, client credentials, display name and the absolute redirect URI below. An unknown protocol, a missing oauth2 key or an openid scope on an oauth2 entry throws at construction naming the provider — the second line behind Configuration §7.
The redirect URI is built from datapipelines.auth.base-url (Configuration §3.4) — the deployment's exact external origin (e.g. https://dp.example.com, no trailing slash). Startup fails when it is unset while any OIDC provider is configured. This is what §13's "OIDC redirect URI locked to the deployment's exact domain" means mechanically; the earlier {baseUrl} placeholder resolved from the incoming request and let a hostile Host/X-Forwarded-Host header pick the redirect target (v2.4).
5.3 Login page (dynamic — renders buttons for each configured provider)
@Controller
class LoginController(
private val clientRegistrationRepository: ClientRegistrationRepository
) {
@GetMapping("/login")
fun login(model: Model): String {
val providers = clientRegistrationRepository.toList().map { reg ->
mapOf(
"name" to reg.registrationId,
"displayName" to reg.clientName
)
}
model.addAttribute("providers", providers)
return "login"
}
private fun ClientRegistrationRepository.toList(): List<ClientRegistration> {
// Spring's Iterable → Kotlin List
return this.asIterable().toList()
}
}
<!-- login.html (Thymeleaf) — renders one button per configured provider -->
<div class="ds-card login-card">
<h1 class="ds-h2">Sign in to datapipelines.co</h1>
<div th:each="p : ${providers}" class="login-buttons">
<a th:href="@{'/oauth2/authorization/' + ${p.name}}"
class="ds-button ds-button--secondary"
th:text="${p.displayName}">
Sign in
</a>
</div>
</div>
The login page shows only the providers the deployment configured — one button, two buttons, or five buttons. No hardcoded provider names.
When local accounts are enabled (§5A), the page gains a username/password form before the provider buttons, separated by a simple divider — one form, then the divider, then the buttons, never tabs (tabs hide half the methods behind a click and add state to a page that should have none). Only the methods actually enabled render: an OIDC-only deployment shows exactly the page above (no form, no divider), a local-only deployment shows the form with no divider and no buttons. Failure states arrive as ?error= banners (credentials, locked, inactive — same idiom as the OIDC banners).
5.4 OIDC redirect flow (Spring Security handles automatically)
1. User clicks a provider button (e.g., "Sign in with Okta")
→ GET /oauth2/authorization/okta
→ Spring Security redirects to Okta's authorization endpoint
2. User authenticates at Okta
→ Okta redirects to: GET /login/oauth2/code/okta?code={auth_code}&state={state}
3. Spring Security exchanges code for OIDC tokens (server-side)
→ Calls Okta's token endpoint with client_id + client_secret + code
→ Receives: id_token, access_token, refresh_token
4. Spring Security validates ID token (signature, audience, issuer, expiry)
→ Extracts claims: sub, email, name, picture
5. OidcSuccessHandler (our custom code):
→ user = userService.findOrCreateByEmail(claims, registrationId)
→ jwt = jwtService.issue(user)
→ response.setCookie("dp_session", jwt, httpOnly=true, secure=base-url-scheme, sameSite="Lax")
(T33: `secure` is keyed off datapipelines.auth.base-url's scheme — https (or unset) keeps
the flag; an explicit http:// base-url drops it so local login works. Production MUST run
https — see §8.4. The same rule applies to the dp_csrf and dp_oauth2_authz cookies.)
→ response.sendRedirect("/")
5.5 OIDC success handler
@Component
class OidcSuccessHandler(
private val userService: UserService,
private val jwtService: JwtService,
private val auditLogger: AuditLogger,
private val authConfig: AuthConfig
) : SimpleUrlAuthenticationSuccessHandler() {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val oidcUser = authentication.principal as OidcUser
val claims = oidcUser.idToken.claims
val clientRegistration = (authentication as OAuth2AuthenticationToken)
.authorizedClientRegistrationId // "google", "okta", "company-sso", etc.
val email = claims["email"] as String
val displayName = claims["name"] as String? ?: email
val pictureUrl = claims["picture"] as String?
val providerSubject = claims["sub"] as String
// Check allowlist
if (!authConfig.isDomainAllowed(email)) {
auditLogger.log("auth.login.domain_not_allowed", email = email)
redirectStrategy.sendRedirect(request, response, "/login?error=domain_not_allowed")
return
}
// Find or create user
val user = userService.findOrCreateByEmail(
email = email,
displayName = displayName,
pictureUrl = pictureUrl,
provider = clientRegistration, // whatever registration name was configured
providerSubject = providerSubject
)
if (!user.isActive) {
auditLogger.log("auth.login.user_inactive", userId = user.id)
redirectStrategy.sendRedirect(request, response, "/login?error=inactive")
return
}
// Issue internal JWT
val jwt = jwtService.issue(user)
val cookie = Cookie("dp_session", jwt).apply {
isHttpOnly = true
secure = true
// Lax, not Strict: Strict withholds the cookie on the cross-site redirect
// chain back from the IdP (and on reloads of that landing), so every login
// would end on a 401. CSRF protection covers state-changing requests.
setAttribute("SameSite", "Lax")
maxAge = authConfig.jwt.ttlHours * 3600
path = "/"
}
response.addCookie(cookie)
userService.updateLastLogin(user.id)
auditLogger.log("auth.login.success", userId = user.id,
details = mapOf("provider" to clientRegistration))
redirectStrategy.sendRedirect(request, response, "/")
}
}
SameSite=Lax, and it stays Lax (T33, 096 §F). Strict is the instinctive choice here and
it does not work: the post-login landing arrives over the cross-site redirect chain from the
identity provider, where a Strict cookie is withheld — and a browser RELOAD of that landing
re-uses the cross-site initiator, so the user never becomes logged in and the failure looks
like "login is broken", not like a cookie attribute. Observed live 2026-08-28. Lax still
withholds the cookie on cross-site POSTs; the control for state change is the dp_csrf
double-submit token (§8.4), which does not depend on SameSite at all. This paragraph exists
because the KDoc on OidcSuccessHandler and §8.4 both said Strict for three rounds while the
code said Lax, and a text that leans on a defence the code does not provide invites someone
to "fix" the code to match it.
The create branch tells sys-ops (137, §5A.8). UserService.findOrCreateByEmail answers Provisioned(user, created), and when created is true — this callback's insert won the row — the handler hands a "New user" notice to MailNotices after the workspace resolution (so the notice can name where the account landed): created by: self-service via <registration id>, never a password. A returning user's login sends nothing; the loser of a two-replica first-login race reports created = false and sends nothing either — the winner's callback already did. With mail unconfigured the notice is one INFO line.
The success handler is fully provider-agnostic. It reads authorizedClientRegistrationId from the authentication token — that's whatever provider name the deployment configured. It doesn't know or care whether it's Google, Okta, or Keycloak.
Identity per protocol (#655). An oidc login's identity is the ID token's claims (above). An oauth2 login has no ID token: Spring routes a registration without the openid scope to the plain OAuth 2.0 user service, and ours (OAuth2IdentityUserService) reads the user-info endpoint and returns a principal carrying the same keys — so the handler reads claims from either principal and everything after it (lowercasing, the §4.2 gates, the link-once rule, the allowlist, the session) is ONE code path:
| Key | oauth2 source |
|---|---|
sub |
the user-name-attribute value, as a string (GitHub: the numeric id) |
name |
user-info name, else login (GitHub's name is nullable) |
picture |
user-info picture, else avatar_url |
email, email_verified |
with emails-uri: the row with primary: true and its verified flag; without it, user-info's own attributes |
The verified-primary rule. With emails-uri set, the user-info email is discarded — GitHub's is the public profile address, neither verified nor necessarily the account's, and provisioning is keyed on email (§4.2), so trusting it would let anyone who types a victim's address into a public profile take the victim's row. No primary row → no address → refused (missing_email); a primary row whose verified is not the JSON true → refused (email_not_verified). The trust knob does not apply to an entry with emails-uri: the endpoint states verification itself, so trust-email-without-verified-claim cannot stand in for it; an oauth2 entry without emails-uri is judged by the user-info attributes exactly as an OIDC token's claims, knob included. A failed, non-2xx or malformed emails read (not a list, a non-object row, two primaries, a primary without an address) refuses as invalid_emails_response; a user-info answer without the subject attribute as invalid_user_info_response.
Provider read timeouts (#682). datapipelines.auth.oidc.provider-read-timeout-seconds (default 10, integer in 1–2147483; Configuration §3.4) sets both connect and read timeouts for the code-for-token exchange (both protocols), OAuth2 user-info, the emails read and OIDC user-info. A stalled token exchange fails as invalid_token_response; either user-info path fails as invalid_user_info_response; the emails path fails as invalid_emails_response with emails endpoint read failed (ResourceAccessException). These reach the failure path below. This is a per-connect/read bound, not a total login deadline. The ID-token decoder's JWKS fetch is bounded by the same key through a JwtDecoderFactory bean (#686): the bean re-creates Spring's ID-token decoder with the bounded HTTP client and keeps everything else Spring's — the same algorithm resolution, the same validators (timestamp + OIDC issuer/audience), the same claim-type conversion, one decoder per registration — so a stalled JWKS read fails the ID-token decode as invalid_id_token and reaches the failure path below. Startup discovery keeps Spring's separate 30 s bound.
The failure path. Every provider-side login failure — a refused consent, a lost authorization request, a failed token exchange, the refusals above — redirects to /login?error=oidc_error (the existing banner; no new page) and is audited auth.login.oidc_error with details.reason = the OAuth 2.0 error code (authentication_failed when there is none) and details.provider = the callback's registration id when it names a CONFIGURED provider (null otherwise — an unknown path segment is never written). The login limiter (§11.5; /login and /oauth2/ prefixes, per client IP) meters the callback path, which bounds these rows.
The access token is not retained. It lives only inside the user-info request: the bearer of the user-info and emails reads, never stored, logged or put in a cookie. Spring Boot's default authorized-client repository would have kept every login's token in an in-memory map keyed by principal; OidcConfig replaces it with a repository that stores nothing, so a login leaves no provider token in the process — for either protocol.
5.6 Workspace resolution & the DP-Workspace header
Every authenticated request resolves exactly one active workspace (design §5); all authored-content operations (pipelines, templates, executions) are scoped to it.
- Session (JWT) principals: the active workspace comes from the JWT's
active_workspaceclaim (stamped at login, §4.2 step 4), membership re-checked per request through the same 60s liveness cache asusers.is_active(§6.3) — workspace revocation takes effect within the identical ~1-minute window. A claim whose membership is gone falls back to the first live membership. DP-Workspace: <workspace name>switches the active workspace for that request. A name the principal is not a member of is refused403 workspace.membership_required— indistinguishable from an unknown name, so the header cannot probe workspace existence. A successful switch is remembered as the user's last-used workspace (drives the next login's stamp). Globalis_adminbypasses the membership check (design D4).- API-key / MCP principals: the key's pinned
workspace_id(chosen at issuance, §7.4) IS the context — no override exists.DP-Workspaceon an API-key request is rejected (400 workspace.header_forbidden), never silently ignored: a header- switchable agent key would make every leaked key a skeleton key across the user's workspaces (design D3), and a quietly dropped header would train agents on a lie. - The pin also governs workspace management. The workspace-management endpoints
(§7.6 "Update a workspace / manage its members") address their target by path NAME, so
for a key principal the service refuses any target other than the key's pinned
workspace — the same no-oracle
403 workspace.membership_requireda non-member gets, so "pinned elsewhere" and "not a member" stay indistinguishable. Without this, a key pinned to workspace A could rename, delete, or edit the membership of workspace B whenever its owner belongs to both — the D3 skeleton key by another door. Sessions are untouched (their active workspace is switchable by design). Two exemptions, both because no EXISTING workspace is overreached: create (there is no target workspace yet, creation grants the caller ownership of a NEW workspace only, and theauthorfloor plus the per-mode provisioning refusal are its gates) and theopen-joinself-join (adding the caller's OWN email to a workspace the deployment declared open touches only the caller's own membership — the joiner enters asmember, and the key's active workspace stays pinned). - Zero memberships (possible under
closedprovisioning): the request proceeds with no workspace; every workspace-scoped operation then answers403 workspace.membership_required.
5A. Local password accounts (optional)
Local username/password authentication is the optional second sign-in method for deployments without an IdP (§2 Principle 1). It is disabled by default (datapipelines.auth.local.enabled, Configuration §3.4); a deployment with only OIDC configured behaves exactly as before this section existed. With no OIDC providers and local accounts enabled, the deployment starts and serves the whole product with zero external setup — the zero-setup path.
5A.1 Accounts
A local account is an ordinary users row whose password_hash holds an Argon2id encoded hash (metadata-db §4.1) produced by the same SecretHasher API keys use (§7.2) — no second hashing library, no hand-rolled parameters. password_hash IS NULL means OIDC-only: that account can never authenticate locally. There is no self-registration — a local account exists because an admin created it on the user-administration screen, or because it is the config-seeded bootstrap admin (§5A.2). The email domain allowlist (§4.3) governs OIDC exactly as today; admin creation is itself the gate for local accounts, so the allowlist does not re-apply there.
A local account's provider is the placeholder 'local' (like 'bootstrap', §4.4 — a value with meaning, not an OIDC registration name): it marks "no OIDC identity is linked". If the person later signs in via OIDC with the same email, §4.2's linking step replaces the placeholder exactly as it replaces 'bootstrap'; the local password stays valid alongside the OIDC identity unless an admin disables local access.
Admin operations on the user-administration screen (§7.6 user.manage): create a local user (a random one-time password, must_change_password = TRUE, audited auth.user.created — emailed to the user when mail is configured, shown to the admin exactly once when it is not (§5A.8); there is no self-service "forgot password" flow, so an admin-driven reset is the whole account-recovery story), reset a password (new one-time credential, forces the change, clears the lockout — the unlock path for a sprayed-out account; mailed the same way), disable local access (the hash is cleared and the account is OIDC-only again), and unlock (clears the lockout without touching the credential).
5A.2 Seeding the first admin
A fresh local-accounts deployment has the chicken-and-egg §4.4 solves for OIDC: no admin exists to create the first account. Config seeds the first admin only, never ordinary users — passwords are not a config medium (config is plaintext in docker compose config, docker inspect, image layers and pasted issue reports, and offers no rotation, no revocation and no self-service). Every other account's credential lives hashed in the database, created by an admin from the user-administration screen.
datapipelines.auth.local.bootstrap-password-hash— the preferred form: a pre-computed Argon2id hash, produced by./gradlew :modules:auth:hashPassword(prompts, or readsDATAPIPELINES_SEED_PASSWORD; never take the password as a shell argument — it would leak into shell history and the process table). The plaintext never has to sit in a config file.datapipelines.auth.local.bootstrap-password— the plaintext alternative, accepted because a zero-setup demo should be one command. It is never logged and never audited.
Both forms land on the bootstrap-admin-email account (created through the single §4.4 creation path, so the admin grant and its audit event fire exactly as for OIDC) and always seed with must_change_password = TRUE: the app refuses to proceed to any other screen until the seeded credential is replaced (§5A.4). Seeding is create-if-absent and idempotent — a restart never resets a changed password, and an account that already exists is left untouched. And the seeded credential cannot survive silently: every startup where the bootstrap-admin account still has must_change_password = TRUE logs a WARN (event=auth.local.one_time_credential_pending). Startup refuses the ambiguous shapes (Configuration §7): both forms set, a seed without local.enabled, or a seed without bootstrap-admin-email — each naming both keys.
The seed fires ONCE, at row creation, so bootstrap-admin-email is a before-first-boot decision. Changing it afterwards creates no second administrator and moves no credential — the account that exists is the account that works. Every subsequent startup says so, as a WARN, and changes nothing:
event=auth.local.bootstrap_mismatch configured=you@example.com seeded=admin@local.test
Sign in as the seeded address and add or reset the other from Admin → Users, or start again from an empty database. ./app.sh --scaffold exists for exactly this: it writes deploy/secrets.env and stops, so the address can be set before the first --start. Both ./app.sh --start and ./app.sh --status read the seeded row out of the database and print the login that exists rather than the one the file names.
5A.3 Lockout
Distinct from the per-IP login rate limit (§9, rate_limit.exceeded): that damper is IP/route-scoped and cannot stop a slow spray against one account from many addresses. After datapipelines.auth.local.lockout.max-failures (default 5) consecutive failed local logins, the account locks for datapipelines.auth.local.lockout.duration-minutes (default 15):
- While locked, an attempt does no Argon2 work and does not touch the failure count — a spray cannot keep the lock warm or spend the server's native hashing memory. Even the correct password is refused until the lock expires.
- An expired lock starts a fresh failure budget (the count resets on the next failure rather than re-locking instantly forever).
- A successful login clears the counters. The lock engagement is audited as
auth.login.locked(§10.1), and an admin can clear it early (unlock or password reset on the user-administration screen).
5A.4 Forced password change
Every seeded (§5A.2) and admin-reset credential is one-time: users.must_change_password is TRUE, and the app refuses to proceed to any other screen until it is changed. The mechanism is an MVC interceptor (ForcedPasswordChangeInterceptor) registered once for all paths — a filter/interceptor concern precisely so a future controller cannot forget it (a test adds a brand-new route and proves it is still gated). While the flag is set:
- Browsers are redirected
302to/settings/password; htmx requests getHX-Redirect(a 302 would swap a full page into a fragment target); API and MCP paths get the403 auth.password.change_requiredenvelope (§9) — a redirect is meaningless to a JSON client. - The allowlist is exactly: the change-password page and its endpoint,
/logout,/health,/ready, and the public/static paths the change page needs to render. - API-key principals are not gated — an API key is a separate credential the user created deliberately; the forced change is about the human proving control of the interactive account.
- The flag is read through the same ~60s liveness cache (D13); every password mutation evicts it immediately.
Only a password-opened session is gated. The session JWT carries an amr claim (pwd | oidc, RFC 8176) saying which credential opened it, and ForcedPasswordChangeInterceptor applies the gate to pwd sessions only: a user whose row was seeded with a local one-time password but who signs in through an identity provider presented no password and is not asked to change one. A session token minted before the claim existed is treated as pwd until it expires. (2026-09-06: the owner's Google sign-in was bounced to the change screen by the seeded local credential.)
On a successful change through the gate (the user's must_change_password was TRUE), the response carries HX-Redirect: /dashboard so the browser leaves the change screen; a voluntary change from Settings stays on the page with a toast (fixed 2026-09-05 — the forced flow used to leave the form on screen with a toast saying "you can continue"). The change itself (self-service, POST /partials/account/password) verifies the current password first — a hijacked session must not be able to rotate the credential — enforces the §5A.5 floor, clears must_change_password, and audits auth.password.changed.
5A.5 Enumeration resistance and the password policy
Unknown email, an OIDC-only account, and a wrong password are the same outcome: the same Invalid email or password. banner, the same redirect, the same HTTP status, and — just as important — the same cost. The no-row path still runs one Argon2 verification against a dummy hash, so response timing cannot tell "no such account" from "wrong password". Failure audit events carry no credential material.
Passwords are compared only through SecretHasher.verify (constant-time-ish at the hash layer). The policy floor for user-chosen passwords (self-service change) is 12 characters minimum and 128 maximum: length is the factor that resists offline cracking (NIST 800-63B), composition rules push users to predictable patterns, and the maximum bounds Argon2 input cost. Seeded/admin-generated one-time passwords are random and always force a change at first login, so the floor does not apply to them.
5A.6 The login flow
POST /login (form fields email, password, plus the _csrf double-submit token — Spring's field name; the COOKIE is dp_csrf and the header DP-CSRF-Token, §8.4) is public (§8.3) and metered by the same per-IP LoginRateLimitFilter as the OIDC paths (/login prefix). It is deliberately not under a scope-governed path: it is the authentication ceremony itself, so no principal exists for the ScopeInterceptor to check yet — exactly like the OIDC callback it mirrors.
On success the flow converges with OIDC: the same JwtService.issue mints the same dp_session cookie, the same §4.2-step-4 workspace resolution runs (workspaceForLogin: last-used, else first active membership, else the D-R11 demo join — the pre-113 auto-per-user clause no longer exists), the same auth.login.success audit event is written (with provider: "local" in the details), and is_active is re-checked exactly as on the OIDC path (a deactivated account with the correct password gets the inactive banner — safe to reveal because the caller just proved the password). The must_change_password flag does not block the login; it engages the §5A.4 gate after it.
5A.7 Credential minting is session-only
Any operation that mints or rotates a usable interactive credential refuses an API-key principal with auth.session.required (§13.7), regardless of role. The set is exactly:
| Operation | Surface |
|---|---|
| Create local user | POST /partials/admin/users |
| Reset a user's password | PATCH /partials/admin/users/{id}/reset-password |
| Disable local access | PATCH /partials/admin/users/{id}/disable-local |
| Unlock an account | PATCH /partials/admin/users/{id}/unlock |
| Change own password | POST /partials/account/password |
Why the role matrix cannot express this. A permission test sees a role, not a credential, so it sees a dpk_ key and a browser session with the same role as one principal. (Since #215 B2 no key reaches these partials at all — the MCP key is confined to /mcp — so this rule is now the second line.) ApiKeyFilter applies no path test and ApiKeyCredentialMatcher makes key requests CSRF-exempt, so an admin-scoped key reaches these partials with a single header. It could then create a local admin, read the one-time password out of the response body, and sign in — trading a revocable, workspace-pinned, non-interactive credential for a dp_session that is not pinned and that outlives revocation of the key that created it. That defeats the ~60s revocation contract in §8, which is the entire answer to a leaked agent key (§2, principle 5).
The self-service change is the same shape one floor down: profile.password is every role's, so without this rule a leaked key could guess its owner's password and, on a hit, rotate it into a full takeover.
Deliberately excluded: activate, deactivate, promote, demote. These administer users without ever emitting a credential, and are already ratified for keys through the documented /api/v1/auth/users REST surface (§7.6 user.manage). The line this draws is credential-minting, not privilege.
Second layer. Because a hijacked session is still a threat, LocalPasswordService.changeOwn also consults the §5A.3 lockout before any Argon2 work, counts failures on the same counter as POST /login, and audits them (auth.password.change_failed, auth.password.change_locked). Before this it had none of the three, making it an unmetered, unaudited guessing oracle beside a fully-defended login path.
5A.8 Mail: the welcome mail and the new-user notice
Two notices, owned here because they are outbound adapters of this module's own flows (137, the owner's 2026-09-14 rulings; keys in Configuration §3.27):
| Kind | To | When | Carries |
|---|---|---|---|
welcome |
the user | an admin creates a local account (§5A.1) | subject Your datapipelines account: the login URL (built from datapipelines.auth.base-url, never a request's origin — §5.2), the login (the email), the one-time password, one sentence that the first login asks for a new password (§5A.4), and the reply-to as the human to write to |
password_reset |
the user | an admin resets a password (§5A.1) | the same family, subject Your datapipelines password was reset — an admin resetting a password has the same "must message the user" problem as creating one, so it is mailed the same way |
new_user |
datapipelines.mail.ops-to (the sys-ops sink, a comma list) |
a local account is created, OR a social login happens for the first time (the OIDC callback's create branch, §5.5) | subject New user: <email> (<provider>): email, display name if one was given, provider (local or the registration id), who created it (the admin's email, or self-service via <provider>), the workspace it landed in (the one the admin asked for, or the one the first login resolved), the time, a link to the admin users screen. Never a password |
Mail is enabled exactly when it is configured — host and from both set; there is no flag (Configuration §3.27). Any SMTP server (Postmark's SMTP endpoint is one); STARTTLS is required-not-opportunistic and the hardened posture refuses it off; the Postmark stream header is optional and rides every message when set. With mail off, every notice is one INFO line (event=mail.skipped, kind and recipient domain only — Observability §3.4B) and nothing is claimed or audited.
The mechanics (MailNotifier, MailSender, MailSendRepository — mail_sends, Metadata DB §4.19):
- Rendered once, Thymeleaf text + html parts from
mail-templates/; the password is a template variable and nothing else in the product ever sees it again. It exists in exactly one place: the message body handed to the transport. Not in the audit row, not in a log line, not in the claim row — pinned byMailNotifierIntegrationTest(a positive control proves the password DID reach the transport, then audit, log capture androw_to_json(mail_sends)are grepped for it: zero hits). - Claimed before it is sent, on the request thread: one
mail_sendsrow per message identity(user, kind, act)—actis the user's own id forwelcomeandnew_user(once per user, ever) and a fresh id perpassword_reset(two resets are two credentials and two mails).ON CONFLICT DO NOTHING: a retry, a double-submit or a second instance finds the row and does not send. The welcome mail carrying a password never goes twice. The claim rides the caller's transaction when there is one, so a rolled-back creation claims nothing; it exists —PENDING— by the time the admin screen renders. The row proves an ATTEMPT, not a delivery, and is never cleaned up. - Sent after commit, off the request thread: registered as an
afterCommitsynchronization when a metadata transaction is open (today neither hook point runs in one — the create paths catch-and-reread a duplicate insert, which an aborted Postgres transaction would forbid), else handed to a bounded pool at once (two threads, a queue of 100, caller-runs on overflow — not discard, because a discarded welcome mail is a password nobody has). A failed send NEVER fails the request. The transport's timeouts are bounded (connect 10 s, read/write 30 s). A connect failure — the one class that proves nothing was delivered — is retried in place, bounded (3 attempts, 250 ms / 1 s backoff, each retry loggedmail.send_retry; 158, #121); anything past connect (a read timeout after DATA, a 5xx, an authentication refusal) is terminal at once, because the server may hold the message and a password mail never risks a second copy. - Recorded:
sent_at+ theMessage-IDon the claim row andmail.sentin the audit log; orerror(the exception's class and message) on the row andmail.failed—kind,to,act_idand the id or the error indetails, never the body (Enums §15). A row stillPENDINGlong after its claim is a send the process never got to (it died between commit and send): the admin screen shows it, and the admin's answer is a reset — never a silent retry of a password mail.
What the admin screen shows follows from the derivation (UI §4.12): with mail on, create and reset show "Emailed to <address>" with the claim's outcome — sending (polled until terminal), sent, or failed with the error — and never the password; an admin should not have to message anyone, and two copies of a credential are one too many. With mail off they show the password as before.
6. Session Tokens (Internal JWT)
6.1 JWT format
After OIDC login, the server issues its own JWT:
- Algorithm: HS256 (HMAC-SHA256, symmetric).
- TTL: 8 hours (configurable via
DATAPIPELINES_AUTH_JWT_TTL_HOURS). - Claims:
{ "sub": "user-uuid", "email": "alice@company.com", "name": "Alice Wang", "active_workspace": "acme", "iat": 1691234567, "exp": 1691263367, "iss": "datapipelines" }active_workspaceis present only when login resolved one (§4.2 step 4); it is the workspace name, the same valueDP-Workspacecarries (§5.6). - Signing secret:
DATAPIPELINES_JWT_SECRETenv var (≥ 32 bytes random, base64). Required at startup.
No authority rides the token. What a session may do is its membership's role in the active workspace, resolved per request (§11A), plus the user row's is_admin re-read through the §11.4 cache — never a claim, so a demotion or a revoked super admin takes effect within one TTL rather than at token expiry. A scopes claim a pre-round-1 token may still carry is ignored (scopes were removed entirely in #215).
A browser session is therefore the broadest credential in the product — switchable across
every workspace the user belongs to (§5.6), where a key's workspace is pinned at issue and
immutable. There is no read-only session: the §7.6 matrix cannot express
"this principal is a browser" or "this principal is a key", so the four gates that need that
distinction are written outside it, by hand — requireSessionAdmin on the credential-minting
admin actions (§5A.7), changeOwnPassword refusing keys (§5A.4), POST /workspace/switch
(§5.6), and the published-endpoint surface refusing sessions (§7.7). Anything that must distinguish the two is a
fifth one, written outside the matrix too. (Since #215 B2 the MCP key reaches none of the four
surfaces, so each is also a second line behind the kind confinement, §7.7.)
6.2 Why not use OIDC tokens directly?
- Decoupled TTL. Our JWT has its own TTL (8h). OIDC access tokens have provider-specific TTLs (Google = 1h). Using OIDC tokens would require refreshing mid-session.
- Our own authorization model. What a session may do is our roles (§11A), resolved per request. OIDC tokens carry provider scopes which don't map to it.
- Stateless validation. Our JWT is validated with a local HMAC secret. OIDC token validation requires fetching the provider's JWKS (network call).
- No vendor lock-in. Whichever provider authenticated the user — an OIDC one, or GitHub over OAuth 2.0 (shipped, #655) — the internal JWT is identical.
6.3 JWT validation (on every request)
class JwtAuthenticationFilter(
private val jwtService: JwtService
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// Extract JWT from cookie
val cookie = request.cookies?.firstOrNull { it.name == "dp_session" }
val jwt = cookie?.value
if (jwt != null) {
try {
val claims = jwtService.validate(jwt)
val userId = UUID.fromString(claims.subject)
// Liveness re-check through the ONE predicate (§11A.3): cached (60s TTL,
// same cache infra as §7.3) lookup of users.is_active. Deactivation kills
// the session within ~1 min — `auth.principal_deactivated`.
principalLiveness.require(userId, pin = null)
val principal = AuthenticatedPrincipal(
userId = userId,
email = claims["email"] as String,
displayName = claims["name"] as String,
authMethod = AuthMethod.OIDC,
keyId = null
)
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(principal, null, emptyList())
} catch (e: Exception) {
// Invalid/expired JWT or deactivated user — clear cookie, proceed unauthenticated
response.addCookie(Cookie("dp_session", "").apply { maxAge = 0 })
}
}
filterChain.doFilter(request, response)
}
}
6.4 Session expiration
JWT expires after 8h (configurable). On expiry:
- API calls return
401 auth.session.expired. - UI redirects to
/login. - No refresh token flow. User re-authenticates via OIDC.
6.5 Logout
POST /logout
Cookie: dp_session=<jwt>
Server clears dp_session cookie. The JWT itself is stateless and not revoked server-side — but with the cookie cleared and the JWT gone from the client, the session is effectively dead. The OIDC provider's session is NOT terminated (user may need to sign out separately at Google/Microsoft if they want full logout).
7. API Keys
Unchanged from the previous auth spec — API keys are independent of the OIDC login mechanism. A user logs in via OIDC, generates API keys from the UI, and those keys are used by agents.
7.1 Anatomy
dpk_<key_id>.<random_secret>
dpk_— literal prefix (secret-scanner detectable).key_id— 12-char base32, unique per key.random_secret— 48-char base32.- Total length: ~64 chars.
7.2 Storage
Argon2id hash (same as before). Schema in Metadata DB spec.
7.3 Validation flow
1. Client sends DP-API-Key: dpk_<id>.<secret>
(on /mcp, Authorization: Bearer dpk_<id>.<secret> is equivalent)
2. Parse id and secret.
3. Look up api_keys WHERE id = 'dpk_<id>' AND is_revoked = false.
4. Not found → 401 auth.api_key.invalid.
5. expires_at < now → 401 auth.api_key.expired.
6. Argon2id.verify(key_hash, full_key).
7. Verify fails → 401 auth.api_key.invalid.
8. PrincipalLiveness (§11A.3), cached, 60s TTL: the users.is_active of the
user the key ACTS AS — its own identity (§4.7) for every kind — and,
for an MCP key only, of its CREATOR besides (A20: a deactivated
person's MCP keys are refused until reactivation; endpoint and server
keys stay creator-independent, PK2)
→ inactive is 401 auth.principal_deactivated; the pinned workspace's
liveness → deactivated is 404 auth.key_workspace_inactive. A server key
on the promotion route folds both into 401 auth.promotion.key_invalid.
9. Update last_used_at, last_used_ip (async).
10. Build the principal: userId = the user the key acts as — its own
identity (§4.7) for every kind. Every key carries its KEY ROLE (§7.5):
the MCP key the role CHOSEN at creation (A13), an endpoint or server
key its transport role (`api_caller` / `promotion_receiver`). Nothing is
read from any membership. No key principal is ever a super admin (B1).
In-memory cache (datapipelines.auth.api-keys.cache-ttl-seconds, default 60s) for recently-validated keys, user liveness and memberships, invalidated on revocation/deactivation/role change on the local instance and by TTL elsewhere — so a role change, a member removal, a key revocation or a deactivation takes effect within the auth cache TTL, 60 s by default (#215 B5, record amendment A9).
7.4 Issuance
Keys v2 (#233, A13–A15) — every key is a robot member of one workspace, and the Keys page is the ONE creation path for every kind. No key is minted at sign-in any more: the login mint (D16), the top-bar chip and minted_at_login are retired (V37). What each kind is and who may create it:
- An
mcpkey is created by anyone holdingmcp_key.create(author, promoter, workspace admin; a super admin) — on the Keys page (POST /partials/api-keys) or over REST (POST /api/v1/auth/api-keys), naming the ROLE it should carry. The subset rule (§7.5, A14) decides which roles the creator may offer; a request for a role outside that set is refused withauth.role_required,details.required= the role,details.held= the creator's.vieweris never a key role (A15) andsuper_adminis not a valueapi_keys.roleaccepts (B1). A first login creates NO key (A15). - An
endpointkey (shown in the UI as an "API key", D17) is created by a workspace admin or super admin (api_key.create), with its endpoint associations in the same request or added later from the page. It acts as its own identity (§4.7) with theapi_callerrole. - A
serverkey is minted — and revoked — by a super admin (server_key.create, D18;server_key.revokefor the revocation). It acts as its own identity with thepromotion_receiverrole.
Revocation (A14/A17): a creator may revoke a key THEY created (mcp_key.revoke_own, author and above) — DELETE /api/v1/auth/api-keys/{keyId} or the page's Delete; a caller holding api_key.revoke revokes any key of the workspace through the same routes, a server key additionally needing server_key.revoke. Removing a member revokes EVERY live key they created in that workspace, of every kind, each audited with reason member_removed (A17/B6 — the created-by generalisation of #200); the members-row lever (reason admin_revoked) does the same without removing them. One edge, stated: a key whose creator no longer holds ANY membership of the key's workspace — an ex-super-admin's key, say — has no creator revoke-own to answer to (their permission is judged where they are a member); in that workspace it is revocable by the workspace admin's api_key.revoke alone. Revocation is the end state — keys and identities are never hard-deleted by the application; the retention sweep's purge (§11C) deletes a revoked key and its identity only once nothing references them (A17/B5).
Names are unique per workspace among live keys (A18): (workspace_id, name) is a partial unique index, so a replacement key needs a fresh name until the old one is revoked.
The show-once copy survives only as the V37-migrated login keys' sealed plaintext (§4.7): a key created on the page returns its plaintext in the create response exactly once (§7.4's original rule) and stores nothing copyable; a migrated key's unread copy is opened ONCE by its creator from the Keys page (GET /partials/mcp-key/secret — the open and the clear are ONE creator-scoped statement, ApiKeyRepository.openAndClearSealedSecret), and the fetch-metadata guard still refuses anything a browser marks cross-site or as a navigation (the §7.4 Sec-Fetch rule of #213). No NEW key is ever minted with a sealed copy (A15).
For every key created on demand, the guards run in the service, before anything is written: the creator must hold the KIND's create permission in the pinned workspace (mcp_key.create for mcp — the route's floor, since it is the LOWEST of the three; api_key.create for endpoint; server_key.create for server; gate 7); and the subset rule (A14, record O3 ruled) — a key's role must not hold a permission its creator lacks in that workspace. The identity and the key are then created in ONE transaction (§4.7). A request that still sends scopes is refused by name (scopes were removed, PK8); one that names NO kind is refused with auth.key_kind_not_mintable (keys v2: there is no default kind to fall back to). The workspace pin is unchanged: a key is pinned to a workspace (design §5.2) and the pin is the key's request context for its entire lifetime (§5.6). The HTTP surface for key management is defined in REST API §16.
7.5 Key roles
Scopes are gone (#215 slice (b), record PK8), and since keys v2 (#233, A13) every key carries a role of its own — chosen at creation, held by its identity, judged by one column of §7.6. Nothing is derived from a membership at request time: no cap (PK4 retired), no freshness rule (C2 retired) — the member's later role changes do not flow to the key, and the key's authority changes only when someone creates a new key. What freshness remains is revocation's (§7.4): a creator's revoke-own (mcp_key.revoke_own), the workspace admin's api_key.revoke, member removal, and the members-row lever.
| Credential | Its role | Where it is judged |
|---|---|---|
| Session | Its membership's role in the active workspace, or a super admin's authority (§11A) | The §7.6 member columns |
mcp key |
A MEMBER role chosen at creation: author, promoter or workspace_admin — never viewer (A15), never a super-admin role (B1). The key's identity holds that role in the key's workspace exactly as a member does (A13); a promoter-role key is lensed like the member it mirrors (§11A.1) |
The §7.6 mcp:<role> columns (the member columns), answered through the same PermissionResolver seam a session's decisions ask (#239) — the production seam reads the member column, so no answer changed |
endpoint key |
api_caller — endpoint.serve on the paths bound to it, and the result paging (§7.7, A16) of the executions it started |
The §7.6 api_caller column |
server key (stored, or the deprecated config value) |
promotion_receiver — promotion.inventory.read, promotion.push, for any workspace (B6) |
The §7.6 promotion_receiver column |
dashboard key (L5) |
dashboard_viewer — dashboard.read (its bindings ARE the lens: a dashboard whose name has no bound folder on any ancestor is served to no one) and dashboard.execute, on the runtime routes only (§7.7) |
The §7.6 dashboard_viewer column |
The subset rule (A14, record O3 ruled): a creator may give a key any role whose permission set — the one RolePermissions table, read off the §7.6 columns — is a subset of the creator's own permissions in that workspace. No ordinal ladder exists: promoter and author are not comparable and neither may mint the other; a workspace admin's column contains both, so an admin may mint any of the three; a super admin holds every permission, so any role. RolePermissions.offerable is the one predicate, consulted by the Keys page dialog, the REST route and the service.
Key roles are snake_case everywhere — storage, wire, the CHECK — and the UI shows a human label ("api caller"). A key's role is stored on its row (api_keys.role), and a database CHECK (V37, widened V45) makes the kind/role families a fact: mcp ⇒ one of the three member roles (role IS NULL tolerated only on a revoked pre-v2 row), endpoint ⇒ api_caller, server ⇒ promotion_receiver, dashboard ⇒ dashboard_viewer — the transport arms spelled with role IS NOT NULL, because under SQL's three-valued logic the bare comparison would admit a NULL role.
No key is ever a super admin (B1). superAdmin is false on every key principal, and super_admin is not a value api_keys.role accepts, so no key resolves an instance permission, whoever minted it.
7.6 Operation Matrix — the permission catalog (authoritative)
This matrix is the ONLY place authorization requirements are defined. REST API, MCP Server, and UI Screens reference it; they never assert anything locally. Since #215 slice (a) it is the permission catalog of the permissions and keys record §2 (ratified 2026-09-23, amended 2026-09-24): one row per <functionality>.<permission> — 100 on this base — and every REST handler, UI route and MCP tool declares exactly ONE of them (@RequiredScope(Permission.X) on a handler; the tool's catalog entry, McpToolCatalog.Entry.permission, since slice (b)). The thirty coarse operations of v2 (READ_RESOURCES … MANAGE_DATASOURCE_GRANTS) are retired; each maps onto the permissions its surfaces were spread over.
Since slice (b) there is one axis, the ROLE (§7.5, §11A):
- The member roles — the five columns
viewer…super_admin; in code the ONE role tableRolePermissions. For a session that is its own membership's role. For the MCP key it is the key's OWN role — chosen at creation, held by its identity (A13): no member's CURRENT role flows to it, so no demotion reaches it — nothing is re-read (PK4 and C2 are retired). A key whose role must change is revoked and replaced, and the freshness levers are all revocation's (§7.4): revoke-own,api_key.revoke, member removal, the members-row lever. Super admin is a property of the USER, held in every workspace (D7): ✓ on every row except the two fenced ones — and never on a key (B1). - The key roles — the three columns
api_caller,promotion_receiveranddashboard_viewer(record §3.2;dashboard_viewersince L5): anendpoint,serverordashboardkey is judged by its key role's column and nothing else.
Cell alphabet. ✓ = the role holds the permission; ✗ = refused with auth.role_required (the MCP key is judged as its OWN key role — details.held names it; auth.key_issuer_role_lost remains only on the context-judged path, §9); own = held, and the read or cancel path additionally limits the caller to their OWN runs (D11: executed_by = self) unless they also hold execution.read_all / execution.cancel_all; all = held, every row of the workspace; lens = held, and the promoter LENS (178, §11A.1) narrows WHAT is returned to released objects newer than the promotion target's — a hidden object answers exactly as an absent one, and an unreadable target answers NOTHING (fail closed); fenced = no MEMBER role holds it, super admin included — only the promotion_receiver key role does, through the promotion server-key route family (§7.7). In the key-role columns, own = the executions the key started (its identity's runs, §4.7), bound = what the key's bindings admit — for api_caller the published paths bound to the key (§7.7), for dashboard_viewer the dashboard folders bound to it (§7.7; an unbound key serves NOTHING), and lens the promoter lens (the mcp:promoter column mirrors the promoter member cell, and an mcp promoter key IS lensed — A13); fenced refuses (the promotion receiving rows, §7.7). Every ✓ / own / all / lens / bound cell is allowed to the role walk; ✗ and fenced are refused.
ScopeMatrix.allowed(principal, permission, workspace) answers it and is the only function ScopeInterceptor and the MCP dispatcher call; a handler or tool that declares no permission is refused with auth.permission.undeclared. A refusal's details name the catalog permission (required) and the ROLE it was judged as (held: viewer … workspace_admin, super_admin, a key role — informative only). Each row's Surfaces cell names every route in code font as the handler maps it (VERB /path, a path variable's regex dropped) and, after MCP:, every tool by wire name — the drift test, the reachability gate and the role walk read the placement from here, so a route or tool the table does not place, or places on a row other than the one it declares, fails the build. Four rows have no surface of their own — execution.read_all, execution.cancel_all, server_key.create, server_key.revoke — because a service asks for them inside another row's route (the own-only execution filters, the cancel paths, the key issuance and revocation services); the reachability gate counts that service check as the row's claim.
The catalog — permissions and roles: (record §2, #215; keys v2 #233 — 67 permissions; the scheduler #9 — 73; the parameter engine #194 — 82; the dashboards #10 L1b — 96; the dashboard runtime #10 L2 — 97; the transfer #10 L1c — 99; the dashboard key kind #10 L5 — 100)
| Permission | Surfaces | viewer | author | promoter | ws_admin | super_admin | api_caller | promotion_receiver | mcp:author | mcp:promoter | mcp:ws_admin | dashboard_viewer |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
pipeline.read |
Read pipelines, their versions, exports and checks; the pipeline pages and partials; the dashboard and the search partial (every role's landing reads). REST/UI: GET /api/v1/pipelines/tree, GET /api/v1/pipelines/tree/search (session-only navigation metadata, existing read permission), GET /api/v1/pipelines, GET /api/v1/pipelines/{id}, GET /api/v1/pipelines/{id}/export, GET /api/v1/pipelines/{id}/versions, GET /api/v1/pipelines/{id}/versions/{version}, GET /api/v1/pipelines/{id}/versions/{version}/checks, GET /dashboard, GET /partials/dashboard-stats, GET /partials/pipelines, GET /partials/pipelines/{id}/nodes/{nodeId}/sql, GET /partials/pipelines/{id}/runs, GET /partials/pipelines/{id}/usage, GET /partials/pipelines/{id}/versions/{version}/checks, GET /partials/search, GET /pipelines (since #556 an optional lifecycle filter — a filter on the existing read, decided over the versions the lens admits; no new route or permission), GET /pipelines/{id}, GET /pipelines/{id}/editor, GET /overview/pipelines (#556 the family overview: the inventory counts and chart through the caller's lens, no new permission; it also lists Recently updated through the same lens). MCP: pipelines_list, pipelines_get |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
pipeline.create |
Create a pipeline. REST/UI: POST /api/v1/pipelines. MCP: pipelines_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.update |
Write a pipeline's draft. REST/UI: PUT /api/v1/pipelines/{id}. MCP: pipelines_update |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.version.manage |
Discard a draft, discard / restore / purge a version, and the lifecycle dialogs for them. REST/UI: POST /api/v1/pipelines/{id}/draft/discard, DELETE /api/v1/pipelines/{id}/versions/{version}, POST /api/v1/pipelines/{id}/versions/{version}/discard, POST /api/v1/pipelines/{id}/versions/{version}/restore, GET /partials/pipelines/{id}/lifecycle/discard, POST /partials/pipelines/{id}/lifecycle/discard, GET /partials/pipelines/{id}/lifecycle/purge, POST /partials/pipelines/{id}/lifecycle/purge, GET /partials/pipelines/{id}/lifecycle/restore, POST /partials/pipelines/{id}/lifecycle/restore |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.delete |
Delete a pipeline (the editor's purge-entity dialog). REST/UI: DELETE /api/v1/pipelines/{id}, GET /partials/pipelines/{id}/lifecycle/purge-entity, POST /partials/pipelines/{id}/lifecycle/purge-entity |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.import |
Import a pipeline. REST/UI: POST /api/v1/pipelines/import |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.release |
Release a version (the release dialog; since 7e the response's warnings and the dialog's needs-review rows — a field on this release, no new route) — and every dependency the plan flips, under that family's *.release (#517: a release plan's selected drafts, each re-checked against the caller's role for its own family; the legacy release_pinned_templates cascade likewise needs template.release). REST/UI: POST /api/v1/pipelines/{id}/release, GET /partials/pipelines/{id}/lifecycle/release, POST /partials/pipelines/{id}/lifecycle/release |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.switch_version |
Switch the served version — the rollback lever. REST/UI: POST /api/v1/pipelines/{id}/current, GET /partials/pipelines/{id}/lifecycle/switch, POST /partials/pipelines/{id}/lifecycle/switch |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.execute |
Execute a pipeline. REST/UI: POST /api/v1/pipelines/{id}/execute. MCP: pipelines_execute |
✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.run_checks |
Run a version's release checks. REST/UI: POST /api/v1/pipelines/{id}/versions/{version}/checks/run, POST /partials/pipelines/{id}/versions/{version}/checks/run. MCP: pipelines_run_checks |
✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
pipeline.execute_node |
Execute one node against live data (returns rows). MCP: pipelines_execute_node |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.read |
Read templates, their versions and runs; the template pages, the editor's source (since 7e: needs_review on every read and the list's implements filter — fields on these reads). REST/UI: GET /api/v1/templates/tree, GET /api/v1/templates/tree/search (session-only navigation metadata, existing read permission), GET /api/v1/templates, GET /api/v1/templates/versions, GET /partials/templates, GET /partials/templates/editor/source, GET /partials/templates/runs, GET /partials/templates/script-face, GET /templates, GET /templates/{*name}, GET /templates/editor (the #398 compatibility redirect onto the workspace), GET /overview/templates (#556 the family overview: the inventory counts and chart through the caller's lens, no new permission). MCP: templates_list, templates_get, templates_used_by |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
template.create |
Create a template (the create modal; a script's implements since 7e — a field). REST/UI: POST /api/v1/templates, POST /partials/templates. MCP: templates_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.update |
Write a template's draft (the editor's Edit; the script face's Save draft, 7d; a script's implements, on a draft or a released version, 7e — a field). REST/UI: PUT /api/v1/templates, POST /partials/templates/editor/edit, POST /partials/templates/script-face/save. MCP: templates_update |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.version.manage |
Discard a draft, discard / restore / purge a version, the lifecycle dialogs; purge a never-released draft over MCP. REST/UI: POST /api/v1/templates/draft/discard, DELETE /api/v1/templates/version, POST /api/v1/templates/version/discard, POST /api/v1/templates/version/restore, GET /partials/templates/lifecycle/discard, POST /partials/templates/lifecycle/discard, GET /partials/templates/lifecycle/purge, POST /partials/templates/lifecycle/purge, GET /partials/templates/lifecycle/restore, POST /partials/templates/lifecycle/restore. MCP: templates_purge_draft |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.delete |
Delete a template (the purge-entity dialog). REST/UI: DELETE /api/v1/templates, GET /partials/templates/lifecycle/purge-entity, POST /partials/templates/lifecycle/purge-entity |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.import |
Import a template. REST/UI: POST /api/v1/templates/import |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.render |
Render a template (the editor preview). REST/UI: POST /api/v1/templates/render, POST /partials/templates/render. MCP: templates_render |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.evaluate |
Evaluate a script template over a caller-supplied input (7b, the render row's twin); the script face's Run suite over its unsaved panes (7d). REST/UI: POST /api/v1/templates/evaluate, POST /partials/templates/script-face/run-suite. MCP: templates_evaluate |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.release |
Release a template version (the release dialog). REST/UI: POST /api/v1/templates/release, GET /partials/templates/lifecycle/release, POST /partials/templates/lifecycle/release |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
template.switch_version |
Switch a template's served version. REST/UI: POST /api/v1/templates/current |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.read |
Read parameter sets, their versions and exports — the §3 document, the working-version read (versioning §7.1), the ?prefix= browse and, since #419, the flat listing's optional q name search (the REST listing and the MCP list tool alike: a filter on the existing read, lensed in the service; no new route, tool or permission), and since #374 the first-party pages: the flat catalog (its swap root keeps the historical /partials/parameter-sets/tree path and, since #466, renders the catalog list alone — the sidebar's branch reads the REST tree routes below) and the version-explicit workspace page — no new permission: the pages are reads, and the live form on the workspace page is the evaluate row's act, role-hidden below. A set the caller's lens hides — or a version it does not admit — is the family's one 404 on every page route. Since #376 the row also reads a set's EVALUATION HISTORY — the workspace page's History tab (?tab=history) and its two partials, the paged list and one record's detail (the workspace spec §6.4; the owner's §11.2 ruling: no new permission — a record carries no value, SQL or result, so whoever may read the set may read what ran against it); a set the lens hides is the same 404 on both partials, and a narrowing lens lists only the records of versions it admits. A promoter reads through the LENS — released sets newer than the promotion target's. REST/UI: GET /api/v1/parameter-sets/tree, GET /api/v1/parameter-sets/tree/search (session-only navigation metadata, existing read permission), GET /parameter-sets, GET /parameter-sets/{id}, GET /partials/parameter-sets/tree, GET /partials/parameter-sets/{id}/evaluations, GET /partials/parameter-sets/{id}/evaluations/{evaluationId}, GET /api/v1/parameter-sets, GET /api/v1/parameter-sets/{id}, GET /api/v1/parameter-sets/{id}/versions, GET /api/v1/parameter-sets/{id}/versions/{version}, GET /api/v1/parameter-sets/{id}/export, GET /overview/parameter-sets (#556 the family overview: the inventory counts and chart through the caller's lens, no new permission). MCP: parameter_sets_list, parameter_sets_get |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
parameter_set.create |
Create a parameter set (the server assigns the id, P24; version 1 DRAFT, D55). REST/UI: POST /api/v1/parameter-sets. MCP: parameter_sets_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.update |
Write a parameter set's draft (the hash-preconditioned If-Match write). REST/UI: PUT /api/v1/parameter-sets/{id}. MCP: parameter_sets_update |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.version.manage |
Purge a parameter set's draft, discard / restore / purge a version. REST/UI: POST /api/v1/parameter-sets/{id}/draft/discard, POST /api/v1/parameter-sets/{id}/versions/{version}/discard, POST /api/v1/parameter-sets/{id}/versions/{version}/restore, DELETE /api/v1/parameter-sets/{id}/versions/{version}. MCP: parameter_sets_purge_draft |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.delete |
Purge a parameter set (only when its only version is a DRAFT — versioning §3.2). REST/UI: DELETE /api/v1/parameter-sets/{id} |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.import |
Import a parameter set (the promotion path: templates first, the exported id KEPT — P24; an id taken by another workspace's set refuses parameter.version.conflict/id_taken, C29). REST/UI: POST /api/v1/parameter-sets/import |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.release |
Release a parameter set's draft (every pinned template version RELEASED, or the 142 cascade with consent; §4 steps 4–6 re-run, record §8.2). REST/UI: POST /api/v1/parameter-sets/{id}/release |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.switch_version |
Switch a parameter set's served version (the receiver's rollout lever). REST/UI: POST /api/v1/parameter-sets/{id}/current |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
parameter_set.evaluate |
Evaluate a parameter set (the record's §5: the whole set re-rendered against the submitted selections — a viewer row, because the evaluate fan-out's callers include every viewer, C24; it reads the datasources the set's selectors name, which a viewer can already read through). REST/UI: POST /api/v1/parameter-sets/{id}/evaluate (also the workspace page's live form since #374, through the initParameters entry of the dashboard runtime — the page renders the form only for a role this row admits, so a promoter's page issues no evaluate), POST /api/v1/parameter-sets/{id}/evaluations (the OBSERVED evaluation — the same act streamed as Server-Sent Events for the Parameter Sets page, rest-api §21.5; session-only, every write re-judged against this row). MCP: parameter_sets_evaluate |
✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.read |
Read visualizations and their versions — the §3.1 document, the working-version read (versioning §7.1), the flat listing and the ?prefix= browse (#10), and since #399 the first-party pages: the flat catalog and the version-explicit WORKSPACE (?version=&tab=; one URL names one visualization, one viewed version and one tab — a DISCARDED version, or one the caller's lens hides, is the family's 404) whose Preview tab renders the version's test-case FIXTURES only — live data runs inside a dashboard (dashboard.execute), so the page reads and never executes. A promoter reads through the LENS — the visualizations a dashboard her lens admits pins. REST/UI: GET /api/v1/visualizations/tree, GET /api/v1/visualizations/tree/search (session-only navigation metadata, existing read permission), GET /api/v1/visualizations, GET /api/v1/visualizations/{id}, GET /api/v1/visualizations/{id}/versions, GET /api/v1/visualizations/{id}/versions/{version}, GET /api/v1/visualizations/{id}/export (the transfer, L1c — lensed BEFORE the export); the test evidence reads and the on-demand mechanical check (#353 — a run is visible only when its VERSION is, so a promoter never reads a draft's evidence; the check is a read with no write, spec §6.1): GET /api/v1/visualizations/{id}/tests/runs, GET /api/v1/visualizations/{id}/tests/runs/{runId}, GET /api/v1/visualizations/{id}/tests/runs/{runId}/screenshot, POST /api/v1/visualizations/{id}/versions/{version}/check, GET /visualizations (#399 the flat CATALOG), GET /visualizations/{id} (#399 the workspace — ?version=&tab=), GET /partials/visualizations (the catalog's swap root), GET /partials/visualizations/{id}/preview, GET /partials/visualizations/{id}/overview, GET /partials/visualizations/{id}/evidence, GET /partials/visualizations/{id}/evidence/{runId}, GET /partials/visualizations/{id}/used-by (the pinning dashboards through the caller's DASHBOARD lens), GET /partials/visualizations/{id}/versions (the workspace's lazy tabs; the evidence reads keep the run-visible-iff-its-version-is rule above), GET /overview/visualizations (#556 the family overview: the inventory counts and chart through the caller's lens, no new permission). MCP: visualizations_list, visualizations_get |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
visualization.create |
Create a visualization (the server assigns the id, P24; version 1 DRAFT, D55). REST/UI: POST /api/v1/visualizations. MCP: visualizations_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.update |
Write a visualization's draft (the hash-preconditioned If-Match write), and test it (#353, spec §11.2): open a test session on the working version and submit its per-case verdicts — the SAME principal starts and submits, and each call is judged by the role held NOW. The screenshot upload is not on this row: it carries no permission, its one credential is the single-use upload capability (§8.3, §8.6.2), re-checked against the starter's current authority. REST/UI: PUT /api/v1/visualizations/{id}, POST /api/v1/visualizations/{id}/tests/sessions, POST /api/v1/visualizations/{id}/tests/sessions/{sessionId}/results. MCP: visualizations_update, visualizations_test_start, visualizations_test_submit |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.version.manage |
Purge a visualization's draft, discard / restore / purge a version — never one a live dashboard version pins (visualization.version.pinned). REST/UI: POST /api/v1/visualizations/{id}/draft/discard, POST /api/v1/visualizations/{id}/versions/{version}/discard, POST /api/v1/visualizations/{id}/versions/{version}/restore, DELETE /api/v1/visualizations/{id}/versions/{version}; since #399 the workspace's dialogs: GET /partials/visualizations/{id}/lifecycle/purge-draft, POST /partials/visualizations/{id}/lifecycle/purge-draft (the draft, typed confirm), GET /partials/visualizations/{id}/lifecycle/purge-version, POST /partials/visualizations/{id}/lifecycle/purge-version, GET /partials/visualizations/{id}/lifecycle/discard, POST /partials/visualizations/{id}/lifecycle/discard, GET /partials/visualizations/{id}/lifecycle/restore, POST /partials/visualizations/{id}/lifecycle/restore (session-only; the POST calls the same service and answers HX-Redirect). MCP: visualizations_purge_draft |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.delete |
Purge a visualization (only when its only version is a DRAFT and no live dashboard version pins any of its versions — versioning §3.2). REST/UI: DELETE /api/v1/visualizations/{id}; since #399 the workspace's dialog: GET /partials/visualizations/{id}/lifecycle/purge-entity, POST /partials/visualizations/{id}/lifecycle/purge-entity (the typed confirm names the visualization; session-only) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.import |
Import a visualization (the transfer, L1c: templates first, the exported id KEPT — P24; an id another workspace's artifact holds refuses visualization.import.id_taken, C29). The envelope lands RELEASED — the D56 evidence promise traveled WITH the exported release, and an authoring deployment never re-runs it — so this row is the api_key.bind cells by the owner's ruling (2026-09-29): never an author, never an MCP author; the promotion receiver lands promoted artifacts through the WIRE, never through this verb. REST/UI: POST /api/v1/visualizations/import |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
visualization.release |
Release a visualization's draft (a test case, the transform pin RELEASED or released with it under release_pinned_templates — D61 — and the evidence gate (#352): the exact draft under a row lock, THE LATEST run of the version GREEN for the exact body hash, and the mechanical check re-run now — refusals visualization.release.tests_missing / tests_stale / tests_red / mechanical_failed). REST/UI: POST /api/v1/visualizations/{id}/release; since #399 the workspace's dialog: GET /partials/visualizations/{id}/lifecycle/release, POST /partials/visualizations/{id}/lifecycle/release (the one D61 consent checkbox rides the dialog and the refusals are listed before the button; session-only; the POST answers HX-Redirect onto the Versions tab) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
visualization.switch_version |
Switch a visualization's served version (the receiver's rollout lever). REST/UI: POST /api/v1/visualizations/{id}/current; since #399 the workspace's dialog: GET /partials/visualizations/{id}/lifecycle/switch, POST /partials/visualizations/{id}/lifecycle/switch (session-only) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.read |
Read dashboards and their versions — the §3.2 document, the working-version read (versioning §7.1), the flat listing and the ?prefix= browse, and since L3b the first-party pages: since #400 the flat catalog (the sidebar's branch reads the REST tree routes below) and the board page, which resolves the runtime configuration server-side to declare its one Plotly bundle — no new permission: D50 makes every reader an executor, and the page must not declare dashboard.execute to simplify. Since #473 the board page also renders the caller's own refresh history at load in the activity dock's History pane, filled server-side from the same runtime read as the Refreshes tab's fragment (own rows unless execution.read_all applies); the page stays on this read row and the fragment's route stays on the execute row. Since #369 the DRAFT PREVIEW page — since #400 a 303 redirect onto the WORKSPACE (the canonical GET /dashboards/{id} carrying the version and the tab as query parameters: one URL names one dashboard, one viewed version and one tab; ?version= names a DRAFT or RELEASED version the same rule admits, and the runtime it mounts is the execute rows', the view itself a read with no audit event; the named version must resolve for the caller, so a promoter's lens hides a draft as the family's 404). No served release and none named is the choose-a-version state (#409) — the record is the caller's, the heading names it, the draft is offered. A promoter reads through the LENS — released dashboards whose every source pipeline her pipeline lens admits; a hidden dashboard answers as an absent id (the board page is the family's 404). Since L5 a dashboard key reads through ITS lens — the dashboard folders bound to it (§7.7; the lifecycle routes named here are OFF its surface, the cell bound names the runtime reads only). REST/UI: GET /api/v1/dashboards/tree, GET /api/v1/dashboards/tree/search (session-only navigation metadata, existing read permission), GET /api/v1/dashboards, GET /api/v1/dashboards/{id}, GET /api/v1/dashboards/{id}/versions, GET /api/v1/dashboards/{id}/versions/{version}, GET /api/v1/dashboards/{id}/export (the transfer, L1c — lensed BEFORE the export), GET /dashboards (since #400 the flat CATALOG), GET /dashboards/{id} (since #400 the workspace — ?version=&tab=), GET /dashboards/{id}/preview (#369, a 303 redirect since #400), GET /partials/dashboards (the catalog's swap root), GET /partials/dashboards/{id}/overview, GET /partials/dashboards/{id}/versions (the workspace's lazy tabs), GET /overview/dashboards (#556 the family overview: the inventory counts and chart through the caller's lens, no new permission). MCP: dashboards_list, dashboards_get |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | bound |
dashboard.create |
Create a dashboard (the server assigns the id, P24; version 1 DRAFT, D55). REST/UI: POST /api/v1/dashboards. MCP: dashboards_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.update |
Write a dashboard's draft (the hash-preconditioned If-Match write), and VALIDATE its working version against the dependencies' current state — no write, but an AUTHOR verb: the validator reads the pinned pipelines', set's and visualizations' statuses unlensed, so neither a viewer nor a promoter reaches it (owner ruling 2026-09-29, the L1a security pass's O5). REST/UI: PUT /api/v1/dashboards/{id}, POST /api/v1/dashboards/{id}/validate. MCP: dashboards_update, dashboards_validate |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.version.manage |
Purge a dashboard's draft, discard / restore / purge a version. REST/UI: POST /api/v1/dashboards/{id}/draft/discard, POST /api/v1/dashboards/{id}/versions/{version}/discard, POST /api/v1/dashboards/{id}/versions/{version}/restore, DELETE /api/v1/dashboards/{id}/versions/{version}; since #400 the workspace's dialogs: GET /partials/dashboards/{id}/lifecycle/purge, POST /partials/dashboards/{id}/lifecycle/purge (the draft, typed confirm), GET /partials/dashboards/{id}/lifecycle/purge-version, POST /partials/dashboards/{id}/lifecycle/purge-version, GET /partials/dashboards/{id}/lifecycle/discard, POST /partials/dashboards/{id}/lifecycle/discard, GET /partials/dashboards/{id}/lifecycle/restore, POST /partials/dashboards/{id}/lifecycle/restore (session-only; the POST calls the same service and answers HX-Redirect). MCP: dashboards_purge_draft |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.delete |
Purge a dashboard (only when its only version is a DRAFT — versioning §3.2). REST/UI: DELETE /api/v1/dashboards/{id}; since #400 the workspace's dialog: GET /partials/dashboards/{id}/lifecycle/purge-entity, POST /partials/dashboards/{id}/lifecycle/purge-entity (the typed confirm names the dashboard; session-only) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.import |
Import a dashboard (the transfer, L1c: the bundled visualizations land first — D61 — then the dashboard; the exported id KEPT, C29). Lands RELEASED with no evidence re-run — the api_key.bind cells by the owner's ruling (2026-09-29), exactly visualization.import's row. REST/UI: POST /api/v1/dashboards/import |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
dashboard.release |
Release a dashboard's draft (§3.2 re-run against current state; the set release RELEASED; every pinned visualization RELEASED or released through its OWN gate under release_pinned_visualizations — D61; every source release RELEASED and read-only). REST/UI: POST /api/v1/dashboards/{id}/release; since #400 the workspace's dialog: GET /partials/dashboards/{id}/lifecycle/release, POST /partials/dashboards/{id}/lifecycle/release (the one D61 consent checkbox rides the dialog; session-only; the POST answers HX-Redirect onto the Versions tab) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.switch_version |
Switch a dashboard's served version (the receiver's rollout lever). REST/UI: POST /api/v1/dashboards/{id}/current; since #400 the workspace's dialog: GET /partials/dashboards/{id}/lifecycle/switch, POST /partials/dashboards/{id}/lifecycle/switch (session-only) |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
dashboard.execute |
Run a dashboard — read its runtime configuration, evaluate its parameters, refresh it (the SSE stream), abort a refresh and list or read its own refreshes (#10 L2, D50: ONE row authorizes the delegated act — the refresh's sources, the set evaluation and the transform run WITHOUT consulting the caller's pipeline.execute, parameter_set.evaluate or template.evaluate; executed_by is the caller, triggered_via DASHBOARD). A promoter is LENSED: a dashboard is served iff every pinned pipeline is admitted by her lens, else the family's 404. Aborting is dashboard.execute plus OWN (the refresh's principal and instance) or execution.cancel_all; listing the refreshes of others is execution.read_all. No MCP tool executes a dashboard; since L5 a dashboard key reaches the runtime and refreshes routes through this row, its dashboard_viewer cell bound — the bindings are its lens (§7.7), and every other route family is off the kind's surface. REST/UI: GET /api/v1/dashboards/{id}/runtime/config, POST /api/v1/dashboards/{id}/runtime/parameters, POST /api/v1/dashboards/{id}/runtime/visualizations, POST /api/v1/dashboards/{id}/runtime/refreshes/{refresh_id}/abort, GET /api/v1/dashboards/{id}/refreshes, GET /api/v1/dashboards/{id}/refreshes/{refresh_id}, GET /partials/dashboards/{id}/refreshes (L3b: the board page's events pane — the caller's own refreshes through the same runtime read, so it floors here with the list route it mirrors; the page that embeds it stays on dashboard.read). Since #369 the FOUR runtime routes take an optional version query parameter naming a DRAFT or RELEASED version (the draft preview's, the dashboard.read row's page) — a value must resolve for the caller and a dashboard key never names one: the version routes are the session page's, refused dashboard.key.kind_refused. |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | bound |
dashboard.key.bind |
Bind a dashboard key to dashboard folders (L5) — and unbind it: the REST pair and the Keys page's per-key binding editor (the api_key.bind twin, same cells by the owner's ruling: never an author, never an MCP author). The binding is the credential's whole reach statement — an unbound key serves nothing (§7.7). REST/UI: POST /api/v1/dashboards/bindings, DELETE /api/v1/dashboards/bindings, POST /partials/api-keys/{keyId}/dashboard-bindings; since #400 the workspace's read-only KEYS tab: GET /partials/dashboards/{id}/keys — the lowest row whose cells match the tab's visibility (this row's five cells), so the tab is a workspace admin's surface and the page never fetches it for anyone lower |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
execution.read |
Execution metadata, the executions screens and the SSE replay, and — since #9 — the durable event record as JSON (?format=json on the events route) — own runs unless execution.read_all (D11), and every SCHEDULED run of the workspace (#9 R3: a scheduled run is its schedule's, visible to every role that reaches this row; on every surface — the REST listing and the single-run reads, the UI's lists (250) and the MCP tools (250, the key's own role deciding)). REST/UI: GET /api/v1/executions, GET /api/v1/executions/{id}, GET /api/v1/executions/{id}/events, GET /executions, GET /executions/{id}, GET /partials/executions, GET /partials/recent-executions. MCP: executions_list, executions_get |
own | own | ✗ | ✓ | ✓ | own | ✗ | own | ✗ | ✓ | ✗ |
execution.result.read |
The result cursor — the same visibility as execution.read: own runs unless execution.read_all, and every SCHEDULED run of the workspace (#9 R3), the scheduled arm asking THIS row (#293: the MCP tool had asked execution.read). REST/UI: GET /api/v1/executions/{id}/result, GET /partials/executions/{id}/result. MCP: executions_get_result |
own | own | ✗ | ✓ | ✓ | own | ✗ | own | ✗ | ✓ | ✗ |
execution.read_all |
Lifts "own" on the two rows above — no surface of its own; the read paths ask for it | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
execution.cancel |
Cancel a running execution — own runs unless execution.cancel_all. REST/UI: DELETE /api/v1/executions/{id}, DELETE /partials/executions/{id}/cancel. MCP: executions_cancel |
own | own | ✗ | ✓ | ✓ | ✗ | ✗ | own | ✗ | ✓ | ✗ |
execution.cancel_all |
Lifts "own" on cancel — no surface of its own; the cancel paths ask for it | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
schedule.read |
Read schedules (#9, R8), recipient addresses excepted (see schedule.update): the list, one schedule, a pattern's preview, a schedule's upcoming occurrences, its runs, one run with its trail; the Schedules page (UI Screens §4.20, slice 2 — a shell whose every read and write is a call to these §20 routes, so the page adds no partial); the Schedules home (UI Screens §4.20a, #584 — its counts, charts and lists are one server read of the SAME admitted list §20.1 returns). A promoter reads through the LENS — the schedules whose target pipeline it admits. REST/UI: GET /api/v1/schedules, GET /api/v1/schedules/preview, GET /api/v1/schedules/{id}, GET /api/v1/schedules/{id}/runs, GET /api/v1/schedules/{id}/runs/{runId}, GET /api/v1/schedules/{id}/upcoming, GET /schedules, GET /overview/schedules |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
schedule.create |
Create a schedule (#9, R8). REST/UI: POST /api/v1/schedules |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
schedule.update |
Edit a schedule — timing, job, parameters, rename (#9, R8). REST/UI: PUT /api/v1/schedules/{id}, POST /api/v1/schedules/{id}/runs/{runId}/deliveries/{deliveryId}/resend (#442b — resend a run's failed or uncertain notification: the permission that edits the recipients sends to them); recipients and notification events (#442); reading a schedule's recipient addresses, and a run's delivery addresses, needs this permission |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
schedule.pause |
Pause, resume and unblock a schedule — the operational controls (#9, R8). REST/UI: POST /api/v1/schedules/{id}/pause, POST /api/v1/schedules/{id}/resume, POST /api/v1/schedules/{id}/unblock |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
schedule.delete |
Delete a schedule — soft; its runs stay (#9, R8). REST/UI: DELETE /api/v1/schedules/{id} |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
schedule.run |
Run now — a manual run fired by the system identity, the person recorded (#9, L2). REST/UI: POST /api/v1/schedules/{id}/run |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
datasource.read |
Datasource metadata, the datasource pages, the registered lake tables, the browse partials, the register form's pool fields. REST/UI: GET /api/v1/datasources, GET /api/v1/datasources/{name}, GET /api/v1/datasources/{name}/lake-tables, GET /datasources, GET /datasources/{name}, GET /overview/datasources (#583 the family overview: the visible-set inventory — counts, connection health, by dialect — and Recently added to this workspace, grants-ordered; a datasource has no lens, so every member reads the same granted set, no new permission), GET /partials/datasources, GET /partials/datasources/pool-fields, GET /partials/datasources/{name}/lake-tables, GET /partials/datasources/{name}/tables, GET /partials/datasources/{name}/tables/{table}/columns. MCP: datasources_list, datasources_get, datasources_get_table_stats |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
datasource.introspect |
Schema introspection over a live connection — introspection is reading (ratified). REST/UI: GET /api/v1/datasources/{name}/schemas, GET /api/v1/datasources/{name}/tables, GET /api/v1/datasources/{name}/tables/{table}/columns. MCP: datasources_get_schemas, datasources_get_tables, datasources_get_columns |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
datasource.test |
Test a datasource connection — follows execute (ratified). REST/UI: POST /api/v1/datasources/{name}/test, POST /partials/datasources/{name}/test. MCP: datasources_test |
✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
datasource.preview_rows |
Preview a table's rows (row data — author and above). MCP: datasources_preview_rows |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
datasource.sql_probe |
Run a bounded read-only SQL probe (row data — author and above). MCP: sql_probe |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
datasource.manage |
Register / update / delete datasources and their dialogs. The member-datasources-enabled gate, and the super-admin rule for an INSTANCE or in-process datasource (#186), stay in the service (DatasourceWorkspaceRules). REST/UI: POST /api/v1/datasources, PUT /api/v1/datasources/{name}, DELETE /api/v1/datasources/{name}, POST /partials/datasources, POST /partials/datasources/{name}, GET /partials/datasources/{name}/delete, POST /partials/datasources/{name}/delete, GET /partials/datasources/{name}/edit |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
datasource.grant |
Grant / revoke a datasource to a workspace, and read who holds it (D-R7). REST/UI: GET /api/v1/datasources/{name}/grants, POST /api/v1/datasources/{name}/grants/{workspace}, DELETE /api/v1/datasources/{name}/grants/{workspace}, GET /partials/datasources/{name}/grants, POST /partials/datasources/{name}/grants, POST /partials/datasources/{name}/grants/{workspace}/remove |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
lake_table.manage |
Register / import / unregister lake tables (the dp-lake catalog). REST/UI: POST /api/v1/datasources/{name}/tables, POST /api/v1/datasources/{name}/tables/import, DELETE /api/v1/datasources/{name}/tables/{namespace}/{table}. MCP: lake_tables_register, lake_tables_import, lake_tables_unregister |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
endpoint.read |
Read published endpoints; the API console page and the API home (#585). REST/UI: GET /api-console, GET /overview/api-console, GET /api/v1/endpoints. MCP: endpoints_list, endpoints_get |
✓ | ✓ | lens | ✓ | ✓ | ✗ | ✗ | ✓ | lens | ✓ | ✗ |
endpoint.publish |
Publish a released pipeline at a URL. REST/UI: POST /api/v1/endpoints. MCP: endpoints_create |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
endpoint.unpublish |
Unpublish. REST/UI: DELETE /api/v1/endpoints. MCP: endpoints_delete |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
endpoint.serve |
Serve a published endpoint — the floor only; the path binding is the gate (§7.7), and an endpoint-kind key bypasses both axes. REST/UI: GET /api/{category}/**, POST /api/{category}/**, PUT /api/{category}/**, PATCH /api/{category}/**, DELETE /api/{category}/** |
✓ | ✓ | ✓ | ✓ | ✓ | bound | ✗ | ✓ | ✓ | ✓ | ✗ |
semantic.read |
Read learned facts; the datasource facts dialog (since 7e the read carries each WORKSPACE fact's implemented_by, narrowed by the reader's template.read lens — a field on semantics_list and the datasource listings; the dialog does not render it). REST/UI: GET /partials/datasources/{name}/facts. MCP: semantics_list |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
semantic.record |
Record a learned fact (D-S8); a DATASOURCE-scope record also needs the datasource granted here. MCP: semantics_record |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
semantic.retire |
Retire a learned fact; retiring a DATASOURCE fact another workspace established additionally needs datasource.manage, asked in the service. MCP: semantics_retire |
✗ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ |
calculator.read |
The calculator catalog — a property of the build. MCP: calculators_list, calculators_get |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
docs.read |
The shipped skill docs — a property of the build. MCP: docs_list, docs_get |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
promotion.read |
The promotion page (owner rule 13); the promote verb on it renders by promotion.promote. REST/UI: GET /promotion |
✗ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
promotion.promote |
Promote to the higher environment — the sending side (D5). REST/UI: POST /promotion/promote |
✗ | ✗ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ |
promotion.inventory.read |
The RECEIVING side's inventory — the promotion server-key route family only (§7.7). REST/UI: GET /api/v1/promotion/inventory |
fenced | fenced | fenced | fenced | fenced | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ |
promotion.push |
The RECEIVING side's push — the promotion server-key route family only (§7.7). REST/UI: POST /api/v1/promotion/push |
fenced | fenced | fenced | fenced | fenced | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ |
mcp_key.own |
See the keys YOU created and read one's sealed copy (once, #213 — the V37-migrated login keys); the Keys page and the key settings page (keys v2 A15: the login mint is retired, creation is mcp_key.create). REST/UI: GET /api/v1/auth/api-keys, GET /api-keys, GET /partials/mcp-key/secret, GET /settings/api-keys |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
mcp_key.create |
Create an mcp key — a robot member with the role the SUBSET RULE allows the creator (keys v2 A13/A14); the Keys page dialog and REST, one creation path for every kind (A15). REST/UI: POST /api/v1/auth/api-keys, POST /partials/api-keys |
✗ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
mcp_key.revoke_own |
Revoke a key YOU created — the service checks created_by; any key of the workspace additionally needs api_key.revoke, a server key server_key.revoke (keys v2 A14). REST/UI: DELETE /api/v1/auth/api-keys/{keyId}, DELETE /partials/api-keys/{keyId} |
✗ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
api_key.read |
The Keys page's WHOLE workspace table — endpoint, server and every mcp key — beside mcp_key.own's personal view; asked by the page controller (keys v2). No route of its own |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
api_key.create |
Create an endpoint key — asked by the issuance service on mcp_key.create's routes, whose floor is the LOWEST create permission (keys v2 A14); a server key needs server_key.create instead. No route of its own |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
api_key.revoke |
Revoke ANY key of the workspace — asked by the revocation service on mcp_key.revoke_own's routes; a server key additionally needs server_key.revoke (keys v2 A14). No route of its own |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
api_key.bind |
Associate an endpoint key with published paths (D17). REST/UI: POST /api/v1/endpoints/bindings, DELETE /api/v1/endpoints/bindings, POST /partials/api-keys/{keyId}/bindings |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
server_key.create |
Mint a server key (D18) — asked by the issuance service on api_key.create's routes |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
server_key.revoke |
Revoke a server key — asked by the revocation service on api_key.revoke's route |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
workspace.switch |
Switch the active workspace, and list your own memberships (D13, D14). REST/UI: GET /api/v1/workspaces, POST /workspace/switch |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
workspace.read |
The workspaces page, one workspace, its members (D13). A SESSION with no reachable workspace may still call it (§11A.1). REST/UI: GET /api/v1/workspaces/{name}, GET /api/v1/workspaces/{name}/members, GET /workspaces |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
workspace.update |
Rename a workspace's display name. REST/UI: PUT /api/v1/workspaces/{name}, POST /workspaces/{name}/display-name |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
workspace.members.manage |
Add / remove members, set their role, invite, revoke a member's MCP key (#200) — never your own membership (#208); the last admin stays (workspace.last_admin). REST/UI: DELETE /api/v1/workspaces/{name}/invitations/{email}, POST /api/v1/workspaces/{name}/members, PUT /api/v1/workspaces/{name}/members/{userId}, DELETE /api/v1/workspaces/{name}/members/{userId}, DELETE /api/v1/workspaces/{name}/members/{userId}/key, POST /partials/workspaces/{name}/members/{userId}/role, POST /workspaces/{name}/invitations/revoke, POST /workspaces/{name}/members, POST /workspaces/{name}/members/{userId}/key/revoke, POST /workspaces/{name}/members/{userId}/remove |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
workspace.create |
Create a workspace (D7). REST/UI: POST /api/v1/workspaces, POST /workspaces/create |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
workspace.lifecycle |
Deactivate, reactivate, delete a workspace (D-R10). REST/UI: DELETE /api/v1/workspaces/{name}, POST /api/v1/workspaces/{name}/deactivate, POST /api/v1/workspaces/{name}/reactivate, POST /workspaces/{name}/deactivate, POST /workspaces/{name}/delete, POST /workspaces/{name}/reactivate |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
user.manage |
User administration: list, activate, deactivate, grant / revoke super admin, local accounts. REST/UI: GET /admin/users, GET /api/v1/auth/users, GET /api/v1/auth/users/{userId}, POST /api/v1/auth/users/{userId}/activate, POST /api/v1/auth/users/{userId}/deactivate, POST /api/v1/auth/users/{userId}/grant-admin, POST /api/v1/auth/users/{userId}/revoke-admin, GET /partials/admin/users, POST /partials/admin/users, GET /partials/admin/users/{userId}/mail/{kind}, PATCH /partials/admin/users/{userId}/{action} |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
user.identity_reset |
Release a user's linked sign-in identity (#187). REST/UI: PATCH /partials/admin/users/{userId}/identity-reset |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
profile.read |
The current principal; the settings page; the avatar proxy — the signed-in principal's OWN picture, fetched server-side from an allowlisted host (#197, §11.1). REST/UI: GET /api/v1/auth/me, GET /settings, GET /avatar |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
profile.preference |
Your own theme preference. REST/UI: PATCH /partials/profile/theme |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
profile.password |
Change your own password (current password verified in-handler); the password page. REST/UI: POST /partials/account/password, GET /settings/password |
✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ |
| Read the audit log — reserved (D12) | no surface yet: audit_log has no page and no endpoint (verified 2026-09-20 — the only reads are the serve/MCP existence checks and the tool learnings, none caller-facing). When one lands it adds a permission on this row |
✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
(There is no datasource WRITE on the MCP surface at all (094): registering one means handing over a live database credential, and no credential travels through an agent — creating, editing and deleting a datasource are UI/REST-only. Nor is there a workspace, membership, release or promote tool: those are human verbs (D-R2, O-2), which is the same reason no key is ever a super admin (B1). 38 of the 42 tools operate inside the API key's pinned workspace; the four exceptions are calculators_list / calculators_get (072) and docs_list / docs_get (120), and only because they touch no workspace data at all — the calculator catalog and the shipped skill docs are properties of the BUILD, identical for every caller.)
UI screens reference the same permissions as the routes they call; per-screen minimums are listed in UI Screens and MUST match this matrix. Since round 2 (114) they also RENDER by it: a verb this matrix would refuse is not drawn at all, and the screen-by-screen inventory — every verb, the role boolean that renders it, and the permission it answers to — is UI Screens §4.3e. There is deliberately no "UI" column here: the rendering rule is derived from the role columns, and a second copy of it in this table would be a second thing to keep true. The htmx partials (/partials/**) and the workspace screen actions declare their REST twin's permission with the same @RequiredScope mechanism, and the ScopeInterceptor governs every non-public route with the same default-deny: an unannotated handler is refused (auth.permission.undeclared), and a mutating partial enforces its twin's permission. Three rail items follow a row since 2026-09-20 and are drawn only for the roles that hold it: Executions (execution.read), Promotion (promotion.read), Workspaces (workspace.read; a principal with no workspace keeps the link because the no-workspace page is the one screen that explains their state).
No PAGE route floors above read. The last one did until #348: the pipeline editor (GET /pipelines/{id}/editor) declared pipeline.execute (122) — the permission the screen it USED to serve existed to exercise for its lowest role (D3: viewers execute what they can read). The read workspace made version selection explicit and the editor page a READ page (GET /pipelines/{id}, the canonical route), so the old route is a compatibility REDIRECT at pipeline.read: the promoter walks through it to the released content exactly as the canonical route admits, a viewer reaches the same page, and the lens — not the route — decides what either sees. Execute remains its own POST on its own row, CSRF-checked and role-gated; no page's GET carries a write's floor. The template editor (GET /templates/editor) floors at template.read since 143 (T315, owner ruling): the explorer's Open links render for every reader, the page renders read state (a draft body is read, never written, by a GET), and every write it can make — Edit, Preview, the lifecycle dialogs — is its own route on its own permission, hidden by role in the markup (UI Screens §4.7). 096 §C's authoring floor stays on those writes. Every GET declares the LOWEST permission whose row admits it (ReadFloorTest): a read of executions is execution.read, not pipeline.read, because their role sets differ; a read of the workspaces page is workspace.read; a dialog fetched for a verb floors at that verb's permission; every other read a signed-in person may make is one of the every-role reads (pipeline.read, template.read, datasource.read, endpoint.read, semantic.read, the self rows).
7.7 Key kinds and published-endpoint bindings
Round 074 gives every API key a kind (ApiKeyKind, api_keys.kind, default user); round 091 adds the third. A kind answers WHERE a credential may be presented; its role (§7.5) answers what it may do there.
| Kind | Authenticates where | Authority | Created by |
|---|---|---|---|
mcp (the MCP key; user until keys v2 V37, A19) |
DP-API-Key / Authorization: Bearer on /mcp and nowhere else (#215 B2, owner ruling 2026-09-24: "MCP key should be only MCP"). On every other route — REST, /partials/**, every page, the published tree — the REST key filter refuses it with 403 endpoint.key_kind_refused and details.reason = "mcp_key_off_surface", before any permission is judged; the interceptor states the same confinement again |
Its OWN identity (§4.7) with the member role chosen at creation — author, promoter or workspace_admin under the subset rule (A13/A14, §7.5); never a super-admin role (B1) |
Anyone holding mcp_key.create — author, promoter, workspace admin — on the Keys page or over REST (keys v2 A13/A14); never at sign-in (A15) |
endpoint |
DP-API-Key on the published tree — a GET under /api/ whose first segment is not a reserved category (v[0-9]+ or api, R-EP5) — including the result-paging routes …/executions/{id} and …/executions/{id}/result for executions it started (A16, §19.3). The framework's own GET /api/v1/executions/… reads are OFF its surface (A16): those are session-only, and an api key on them is refused by kind |
Its own identity (§4.7) with the api_caller role; its rows in endpoint_key_bindings decide WHICH published paths it serves |
Workspace admins and super admins, on the Keys page (api_key.create, D17) — the UI calls this kind an "API key" |
server |
DP-Promotion-Key on /api/v1/promotion/**, presented by a SENDING deployment |
Its own identity with the promotion_receiver role — inventory and push, for any workspace (B6) |
Super admins only (server_key.create, D18) |
dashboard (L5) |
DP-API-Key on the dashboard runtime's two prefixes ONLY — /api/v1/dashboards/{id}/runtime/** and /api/v1/dashboards/{id}/refreshes/**. Every other route family — the lifecycle REST routes, the pages, /partials/**, /mcp, the published tree, promotion — refuses it centrally with 403 endpoint.key_kind_refused and details.reason = "dashboard_key_off_surface" before any handler (the interceptor, and McpAuthFilter on the servlet) |
Its own identity (§4.7) with the dashboard_viewer role; its rows in dashboard_key_bindings decide WHICH dashboards it serves — a folder prefix binds every dashboard beneath it, an unbound key serves nothing, a deeper binding replaces an inherited one (R-EP2, below) |
Workspace admins and super admins, on the Keys page (api_key.create — the lane's decision, the endpoint key's floor; the UI calls this kind a "Dashboard key") |
Each kind carries exactly one role (the database CHECK, §7.5), so a request that names a role its kind cannot hold is refused rather than quietly corrected (endpoint.key_kind_refused) — a caller who writes {"kind": "endpoint", "role": "promotion_receiver"} holds a mental model this surface has to correct out loud. A request that still sends scopes is refused by name.
The confinement is central, not per-handler. Every key kind is refused on every route outside its own family, so a new route cannot become reachable to one by someone forgetting a check — the same default-deny reasoning as the unannotated-handler rule in §7.6. The MCP key's family is /mcp; ApiKeyFilter refuses it everywhere else FIRST (the chain stops there — a session cookie on the same request does not rescue it), and ScopeInterceptor states the same reach table for every MVC handler (ScopeInterceptor.reachableBy). Two properties of the interceptor's check are load-bearing:
- It is decided before the handler's
@RequiredScopeis read. A UI page carries no annotation and lives outside the governed prefixes, so an annotation-first order would let every screen in the product answer a credential that authorises none of them (091: a scopeless key could render/settings/api-keysand read its owner's key list). - On the routes a confined kind may reach, what it may DO is its key role (§7.5). Keys v2 A16 narrows the
endpointfamily to the published tree alone — the paging routes ride it, and the framework's execution routes are simply off the surface, so the old "reached by path, refused by role" case onDELETE /api/v1/executions/{id}is gone.
/mcp is a servlet, not an MVC handler, so the interceptor never sees it; McpAuthFilter refuses EVERY kind except mcp there (the endpoint, server and dashboard keys alike — stated as the allowlist "kind = mcp" rather than a denylist of the confined kinds, so the next kind cannot pass to /mcp untouched). Without that refusal such a key could not CALL a tool (its key role holds no tool's permission) but could still read the whole catalogue through tools/list.
The dashboard kind's bindings (L5)
dashboard_key_bindings is endpoint_key_bindings' twin: name_prefix (a folder of the dashboard NAME space — the folder grammar's 1–9 segments, or the root /), api_key_id, workspace_id, created_by, created_at; PK (name_prefix, api_key_id). The two binding routes are dashboard.key.bind's (§7.6), and binding/unbinding is audited dashboard.key_bound / dashboard.key_unbound (Enums §15).
A binding names a folder of the dashboard name space, not a pattern: finance/dashboards binds every dashboard whose name begins finance/dashboards/, and the root / binds the workspace's whole dashboard tree. Per request the dashboard's name ancestors are walked from the most specific (finance/dashboards/private/x → finance/dashboards/private → finance/dashboards → finance → /), and the first node carrying any binding decides — R-EP2 verbatim: a deeper binding therefore replaces an inherited one rather than adding to it, so a key bound at finance/dashboards stops serving finance/dashboards/private/** the moment someone binds another key at finance/dashboards/private. Bind both keys at the deeper node when both should keep working.
The unbound case is where the security lives (the endpoint rule, #215 B3): a dashboard with no binding on any ancestor of its name is served to NO key. An unbound dashboard key, a key bound elsewhere, and a key of another workspace all get the dashboard family's ordinary 404 (dashboard.not_found) on a runtime route — a hidden dashboard is indistinguishable from an absent one (§11A.1). At bind time the prefix must be the root or a folder at or above a dashboard the CALLER'S workspace actually has — the same non-disclosure rule the endpoint bindings' requireInsideWorkspace follows (#191).
The runtime's reads for a dashboard_viewer principal go through the key's lens — the admitted names are what the R-EP2 walk admits, computed per request from the key's workspace bindings. The refresh records of a key principal carry principal_key_id (V43's column; principal_user_id stays NULL — the CHECK wants exactly one), and the executions it delegates carry executed_by = the key's identity with executed_by_key_kind = 'dashboard' (D50).
One key = one budget, deliberately: the SSE stream cap (datapipelines.sse.max-streams-per-user) and the §11A rate limit are keyed on the principal's identity, so every end user behind one key shares that key's budget. Size max-streams-per-user for the host application's concurrency (Configuration §3.6); the reference proxy's README says the same.
Hierarchical bindings (ruling R-EP2)
A binding names a node of the endpoint tree, not a pattern: /lending authorises every endpoint beneath it. Per request, the ancestors of the request path are walked from the most specific (/lending/{borough}/home → /lending/{borough} → /lending → /), and the first node carrying any binding decides. The presenting key must be among that node's bound keys, or the request is 403 endpoint.key_not_bound.
A binding stays inside its workspace (2026-09-21, #191). At bind time, a prefix is refused unless it is the root / or a node at or above a path the CALLER'S workspace publishes — endpoint.path_invalid, with a message that names only the caller's own tree, because whether another workspace publishes at the prefix is exactly what the refusal must not reveal (§11A.1). At serve time, a binding decides only for an endpoint of ITS OWN workspace: the ancestor query filters on the endpoint's workspace_id, the authorizer re-checks the surviving rows in memory (fail closed, either layer alone suffices), and a foreign binding at a nearer node neither authorises nor shadows — the walk continues past it as if the node carried nothing. A root binding is therefore still safe: it exercises only bindings of the serving key's own workspace.
A deeper binding therefore replaces an inherited one rather than adding to it: a binding at /lending/private hides the one at /lending for that subtree, and the key bound at /lending stops working there. Bind both keys at the deeper node when both should work.
The demo workspace's public key (#224) is an ordinary endpoint key — an api_caller minted at bootstrap from datapipelines.bootstrap.demo-api-key, named demo-public-key, bound to the demo family's published paths and shown on the site's demo-data page; nothing about it is a fourth kind, and its budget is the serve path's per-key budget (rest-api §19.8).
That is the more conservative of the two readings, and it is chosen deliberately. An operator who binds a narrow key deep in the tree is drawing a boundary; an additive model would silently keep the broad key working across it. The cost of "replace" is a binding an operator can see is missing and add; the cost of "add" is a boundary that was never real.
A bound node decides for every credential that reaches it — since B2 that is only ever an endpoint key.
The unbound case
An endpoint with no binding on any ancestor is not public and not open to any key: it is unservable until a key is bound to it (#215 B3). Serving takes a key (a session is refused with auth.session.required), and since B2 the MCP key never reaches a published path — so the only credential that can arrive is an endpoint key, refused with 403 endpoint.key_kind_refused. Until slice (b) a user key of the endpoint's own workspace holding execute was admitted here; that branch went with the scopes.
That is the security property: an unbound path authorises nothing. If an unbound path fell through to "any endpoint key may call it", publishing a new endpoint would silently widen every existing endpoint key's reach at the moment of publication.
Reading results
An endpoint key may read the metadata and the cursor of executions it started (api_caller holds execution.read and execution.result.read, both own). Since #215 its runs carry its own identity in executed_by (§4.7), so ownership is the ordinary own-runs rule — executed_by = the principal — and the key's creator does NOT see the key's run as their own (only execution.read_all does). Runs served BEFORE V34 carry the creator instead; for those, ownership is still proved by the endpoint.served audit row that pairs the key id with the execution id. A different endpoint key asking for the same execution is refused either way.
Issuance
POST /api/v1/auth/api-keys takes kind and bindings in the same request, and the bindings are validated before the key is minted. They are part of issuance rather than a second call because the plaintext key is returned exactly once: a failure between mint and bind would leave an operator holding a secret they can neither use nor re-read. Since 179 the route is api_key.create (workspace admins and super admins), kind is endpoint, server or dashboard (L5) — user is refused with auth.key_kind_not_mintable, and an absent kind means user, so a pre-179 client meets the refusal rather than silently minting a different credential — and the bindings may name the key by api_key_id (the /api-keys page's shape) or, kept for REST compatibility, by the caller's own key's api_key_name (endpoint bindings only: since L5 the by-name path filters the kind, exactly as the by-id path always did). The request may name the key's role (only the kind's own); the response carries role, the identity it acts as ({id, display_name}) and created_by. Everything else about a transport key is an ordinary key — dpk_ prefix, Argon2id, expiring, revocable, rate-limited on its identity's budget (§11A). A dashboard key is minted WITHOUT bindings (they are dashboard.key.bind's verb, on the key's card after mint); the endpoint bindings field on a dashboard-key request is refused, exactly as on every non-endpoint kind.
Binding and unbinding are audited (endpoint.key_bound / endpoint.key_unbound, Enums §15), as is every serve (endpoint.served).
The server kind (091)
Promotion is one deployment writing RELEASED content into another (Versioning §10.6). Its credential is a server key, and since #215 a stored one acts as its own service identity (§4.7) with the promotion_receiver role — no human it can be traced to, and no longer the System account's authority (record C4). 091 changed where the key LIVES.
Until 091 it was a pre-shared configuration value — datapipelines.deployment.promotion.server-key — which nobody can mint, list, expire or revoke without editing a file and restarting the deployment. A server key is the same credential stored like every other key: Argon2id hash, dpk_ prefix, an owner, an expiry, a revocation flag and a last-used stamp. The configured value is accepted for one release and WARNs at boot (Configuration §3.19).
- Minting is
server_key.create— a super admin's (D18): whoever holds this credential can write pipelines, templates and datasource references into the receiving deployment. - Role:
promotion_receiver—promotion.inventory.readandpromotion.push, and nothing else. The confinement is the route family, asserted the same way an endpoint key's is. Intake is instance-wide (#215 B6, owner ruling 2026-09-24; record amendment A11): a batch names its target workspace and the receiver resolves it by name, for ANY workspace — the key'sworkspace_idis where it is listed and administered (the Keys page), not a confinement. - Presented as
DP-Promotion-Key, by a deployment.PromotionServerKeyFiltercompares the configured value first (constant time — a deployment that has not migrated pays no database read), then validates the header against the key store. Fail-closed is unchanged: no configured value AND no liveserverkey ⇒ every push refused. Missing header, malformed header, wrong key, wrong kind, revoked, expired, a deactivated identity or pinned workspace and "nothing configured here" all answer the sameauth.promotion.key_invalid, so a caller cannot classify a credential by asking. The creator's liveness is not consulted (PK2). - The actor is the key's own identity (record C4), never the admin who minted it: received versions and the
auth.promotion.acceptedrow name it. The deprecated config value — a bootstrap credential with no row to attach an identity to — stays the System account (§4.5, B6), with the samepromotion_receiverrole. The key's id rides on the principal so the audit trail can name WHICH key across a rotation, andlast_used_atis stamped like any other key's. - Presented as an ordinary
DP-API-Keyit authenticates a principal that is refused everywhere —/api/**,/partials/**,/mcpand every UI page — with403 endpoint.key_kind_refusedanddetails.reason = "server_key_off_surface".
Rotation, which the config value never had: mint a second server key, set it on the sender, revoke the first. No restart, on either side.
8. Spring Security Configuration
8.1 Filter chain
Security headers are written before the downstream filter chain starts. An SSE worker may write the response while the original servlet thread unwinds; deferred security-header inspection at that point would race the container's mutable headers. HeaderWriterFilter therefore uses eager writing. The default security and private-cache headers remain enabled; an explicit application Cache-Control value (including public page/asset TTLs) overrides that header afterward. This changes header timing, not authentication, authorization or CSRF rules.
The sketch below is the shape of SecurityConfig.securityFilterChain as it is actually
assembled, not a simplification: the three addFilterBefore calls read in the REVERSE of
the order they produce, which is why the resulting order is spelled out under §8.2 rather
than inferred from the source. The permitAll list is not written inline — it is
PublicPaths.ENTRIES (§8.3), so every pattern carries a reason and is reachable to the
tests that guard it.
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val filters: AuthFilters, // apiKey, jwt, loginRateLimit, promotionServerKey, workspaceResolution, oidcSignedInBounce
private val oidcSuccessHandler: OidcSuccessHandler,
private val scopeInterceptor: ScopeInterceptor,
private val forcedPasswordChangeInterceptor: ForcedPasswordChangeInterceptor,
// …entry point, access-denied handler, logout handler, OAuth2 authorization-request
// repository + resolver, AuthProperties
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { csrf ->
// Cookie: dp_csrf (readable by JS), header: DP-CSRF-Token; the plain
// (non-XOR) request handler keeps cookie value == header value.
csrf.csrfTokenRepository(cookieCsrfRepository())
// Exemption follows the CREDENTIAL, not the path (§8.4): DP-API-Key (or
// Bearer dpk_ on /mcp); promotion requires a server-key header (§10.6);
// screenshot upload requires a single-use DP-Upload-Token (#353). No cookie authenticates there.
csrf.ignoringRequestMatchers(ApiKeyCredentialMatcher(), PromotionRouteMatcher(), UploadCapabilityRouteMatcher())
// Double-submit needs a STABLE cookie; this chain has no server session to
// protect, so Spring's rotate-or-delete strategy is neutered (027).
csrf.sessionAuthenticationStrategy(NullAuthenticatedSessionStrategy())
}
.authorizeHttpRequests { auth ->
auth
// A re-dispatch of an already-authorized request, not a path rule (§8.3).
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
// THE allowlist — one row per pattern, each with its reason (§8.3).
.requestMatchers(*PublicPaths.PATTERNS.toTypedArray()).permitAll()
.anyRequest().authenticated()
}
// OIDC login is wired ONLY when providers are configured (§5A): a local-accounts-only
// deployment has no ClientRegistrations and no /oauth2 endpoints.
if (oidcConfigured) configureOidcLogin(http) // + OidcSignedInBounceFilter (090)
http
.addFilterBefore(filters.jwt, UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(filters.apiKey, JwtAuthenticationFilter::class.java)
.addFilterBefore(filters.loginRateLimit, ApiKeyFilter::class.java)
.addFilterBefore(filters.promotionServerKey, LoginRateLimitFilter::class.java)
.addFilterAfter(filters.workspaceResolution, JwtAuthenticationFilter::class.java)
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.exceptionHandling {
it.authenticationEntryPoint(authEntryPoint) // §8.2 — splits by Accept
it.accessDeniedHandler(authAccessDeniedHandler)
}
.logout { logout ->
logout.logoutUrl("/logout")
.addLogoutHandler(auditLogoutHandler)
.deleteCookies("dp_session")
.logoutSuccessUrl("/login")
}
val chain = http.build()
chain.filters.filterIsInstance<HeaderWriterFilter>().single().setShouldWriteHeadersEagerly(true)
return chain
}
// Two MVC interceptors, registered as WebMvcConfigurer beans: the scope interceptor on
// every handler (§7.6), and the forced-password-change gate on every handler except
// ForcedPasswordChangeInterceptor.EXCLUDE_PATTERNS (§5A.4).
}
8.2 Filter order (per request)
1. CORS filter — a container filter ahead of the security chain (one origin = auth.base-url; unset ⇒ no CORS)
2. CSRF filter — validates dp_csrf on state-changing requests; exemption follows the CREDENTIAL (§8.4), never the path
3. PromotionServerKeyFilter — /api/v1/promotion/** only: the DP-Promotion-Key gate (§10.6). Inert everywhere else
4. LoginRateLimitFilter — /login and /oauth2/ only: per-client-IP damper, 429 rate_limit.exceeded (§11.5)
5. ApiKeyFilter — checks DP-API-Key header (or Bearer dpk_ on /mcp); if present, validates and sets SecurityContext
6. JwtAuthenticationFilter — checks dp_session cookie; DECLINES when ApiKeyFilter stashed a rejection (§8.4); skips /mcp entirely
7. WorkspaceResolutionFilter — resolves the active workspace onto the principal (§5.6): DP-Workspace switch or claim/pin, membership-checked
8. OAuth2LoginAuthenticationFilter — handles /oauth2/** and /login/oauth2/code/** redirects (wired only when a provider is configured)
9. AuthorizationFilter — checks the §8.3 allowlist, then authenticated() for everything else
10. ForcedPasswordChangeInterceptor — §5A.4, every handler except EXCLUDE_PATTERNS
11. ScopeInterceptor (MVC) — kind confinement (§7.7), then @RequiredScope; default-deny for unannotated handlers outside the §8.3 allowlist;
an ASYNC re-dispatch passes untouched — the REQUEST dispatch judged it (§8.3, #404)
12. Controller — handles the request
If neither API key nor JWT is present (and the path requires auth), the AuthorizationFilter routes to the entry point, which splits by client shape (T31): a request whose Accept includes text/html — a browser navigating a UI route — gets a 302 to /login (the Location is a relative header, never sendRedirect's Host-derived absolute URL); /api/** and /mcp NEVER redirect (their 401 JSON envelope is contract, byte-pinned), and non-HTML clients (curl's Accept: */*, JSON API callers) keep the exact current envelope whatever path they hit. If authenticated but scope insufficient, the ScopeInterceptor returns 403.
A streaming response's completion (an SSE emitter, a CSV body) re-enters the servlet as an
ASYNC dispatch. Steps 5–7 are OncePerRequestFilters that skip it, so its SecurityContext is
empty; step 9 permits it by dispatcher type, step 10 finds no principal and passes, and step 11
returns at once. The handler is not invoked again on that dispatch
— only the result or exception the REQUEST dispatch's handler produced is rendered, by the
@ControllerAdvice — so nothing new is authorized and the D-R8 audit row stays one per request.
8.3 Public endpoints (no auth required)
The table below is generated from PublicPaths.ENTRIES (modules/auth), which is the
single source of the permitAll list the chain in §8.1 is built from. PublicPathsTest
parses this section and demands equality pattern-for-pattern, reason-for-reason and
round-for-round, so neither side can move without the other; PublicRouteWalkerTest
(modules/web) then walks every request mapping the application registers against these
patterns and freezes the resulting public handler set by name, so a new controller mapped
under one of the globs fails the build instead of shipping public. Everything not matched
here falls to anyRequest().authenticated().
Two things are deliberately not rows in this table:
- The
DispatcherType.ASYNC/ERRORpermit (§8.1). It is not a path rule: it says that a re-dispatch of a request the REQUEST dispatch already authenticated and authorized is not re-authorized from an emptySecurityContext. Without it every completed SSE stream dies as Access Denied on its completion dispatch.ScopeInterceptorholds the same premise for the ASYNC dispatch (§8.2 step 11): until v3.40 it judged the re-dispatch against the empty context, so a stream completed with an error BEFORE its first frame answered401 auth.api_key.missingin place of the error's envelope — the execute route's429 pipeline.execution.concurrency_limitand the idempotent retry's never-started410 result.expired(rest-api §6.1) — while every later completion hid the refusal behind the committed response (#404). The ERROR dispatch needs no exemption there: its handler is/error, a row of the table below, which the interceptor already passes. - Anything reached only with a credential. Being on this list means anonymous access, not
"unscoped": an annotated handler on a public path is still enforced by
ScopeInterceptor.
| Path pattern | Why public | Since |
|---|---|---|
/ |
The marketing home page: constant content, no datastore, no principal — public by design (033/D4). | 033 |
/site/** |
The marketing site's own css/js/img assets; the design system it references rides /vendor/** below. | 033 |
/mcp-server-for-sql-databases |
Intent-cluster page: GET-only constant content whose only live fact is the compile-time MCP tool count. | 073 |
/mcp-server/* |
Per-engine intent-cluster pages, one segment deep and enumerated by the page registry, not globbed open. | 073 |
/add-mcp-server-to-claude-code |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | 073 |
/ai-data-pipeline |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | 073 |
/text-to-sql-agent |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | 073 |
/compare/* |
The comparison pages (airflow, dbt): GET-only constant content, one segment deep, no datastore. | 073 |
/federated-query |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | 073 |
/dp-lake |
The dp-lake product page: same shape and same reasoning as the 073 intent-cluster pages. | 089 |
/faq |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/roadmap |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/security |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/published-api |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/mcp-tools |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/tableau |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/tableau/* |
Site v2 intent pages under the Tableau hub: GET-only constant content, no datastore and no principal on the request. | site-v2 |
/for/* |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | 111 |
/how-it-works |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | 115 |
/demo-data |
Demo-data page: GET-only, renders the vendored manifests parsed at startup; no datastore, no principal. | 116 |
/pricing |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | 119 |
/semantic-layer |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | 119 |
/use-cases |
Site v3 hub page: GET-only constant content, no datastore and no principal on the request. | 145 |
/explore |
Site v3 directory, generated from the page registry and the packaged docs catalog; no datastore, no principal. | 145 |
/pipeline-scheduling |
Site capability page: GET-only constant content, no datastore and no principal on the request. | 699 |
/sql-dashboards |
Site capability page: GET-only constant content, no datastore and no principal on the request. | 699 |
/reusable-parameter-sets |
Site capability page: GET-only constant content, no datastore and no principal on the request. | 699 |
/docs |
The in-product spec index: packaged Markdown, no principal, no datastore, already public in the AGPL repo. | 073 |
/docs/* |
One packaged spec per slug, rendered from the jar or raw at its .md twin (173); the same text is public on GitHub. | 073 |
/skill.md |
The agent manual's core, raw: it is the MANUAL, so requiring a key would gate learning how to use the key. | 095 |
/skill/* |
The manual's guides and references, rendered at boot from resources packaged in this jar. | 095 |
/robots.txt |
Crawler infrastructure: a document that is meaningless unless it is readable without a login. | 073 |
/sitemap.xml |
Generated from the page registry and packaged doc slugs; a sitemap behind auth indexes nothing. | 073 |
/llms.txt |
The llms.txt index, generated from the page registry and the docs catalog; an agent index behind auth indexes nothing. | 173 |
/llms-full.txt |
The full llms.txt: every packaged doc's Markdown, already public at its own .md route and on GitHub. | 173 |
/health |
Liveness probe for orchestrators, which present no credential; flattened UP/DOWN plus version only. | P3d |
/ready |
Readiness probe for orchestrators, which present no credential; flattened UP/DOWN plus version only. | P3d |
/info |
Build info (version, build time, commit) — the deployment's own identity, no principal data. | P3d |
/login |
The login page and the local password POST: the surface a caller uses BEFORE it has a credential. | P3d |
/login/** |
Login error redirects and the OIDC callback subtree, reached before any session exists. | P3d |
/oauth2/** |
The OIDC authorization redirect and callback endpoints, by definition pre-authentication. | P3d |
/vendor/** |
Vendored static assets (design system, fonts, icons, Alpine, Cytoscape, htmx) served to the login page too. | P3d |
/css/** |
The application stylesheets, which the unauthenticated login and error pages already render. | P3d |
/js/** |
The application scripts, which the unauthenticated login and error pages already render. | P3d |
/favicon.ico |
The site icon, requested by the browser on the login page before any credential exists. | P3d |
/error |
Boot's default error page with no stack trace or message; an anonymous error must not loop through login. | P8 |
/visualizations/*/preview |
The visualization test preview: its ONLY credential is the ?session= preview token, checked by VisualizationTestCapabilities.preview; no session cookie, no other route. | 353 |
/api/v1/visualizations/*/tests/sessions/*/screenshot |
The test screenshot upload: its ONLY credential is the single-use DP-Upload-Token, checked by VisualizationTestCapabilities.authorizeUpload; no cookie is read. | 353 |
8.4 API endpoints (auth via API key OR JWT)
All /api/v1/** endpoints accept either:
DP-API-Key: dpk_...header (agents, programmatic clients).Cookie: dp_session=<jwt>(browser UI calling REST directly).
The filter chain tries API key first, then JWT. If both are present the API key decides the
request — including when it decides to refuse it (096 §E, review finding F9). A presented
DP-API-Key that fails validation ends the request with its own code
(auth.api_key.invalid / auth.api_key.expired); JwtAuthenticationFilter declines to
authenticate the cookie behind it, and AuthEntryPoint answers with the key's stashed error.
Until 096 the rejection was stashed and the chain continued, so a revoked or expired key
presented alongside a live dp_session was served at SESSION privilege — at least author
(§6.1) — by a credential the server had just refused, and revoking a key produced no visible
signal to a client holding both. Not browser-exploitable (a custom header forces a preflight
this deployment does not grant), but it meant revocation did not end the session beside it.
CSRF exemption is scoped by credential type, never by path: a request is exempt only when it carries an API key (DP-API-Key header, or Authorization: Bearer dpk_ on /mcp) — a credential a hostile browser context cannot forge, with no cookie involved. A state-changing request authenticated by the dp_session cookie requires the dp_csrf double-submit token (DP-CSRF-Token header) wherever it occurs: /partials/** (UI Screens §3), POST /logout, and cookie-authenticated calls to /api/v1/** alike. /mcp accepts no cookies at all (§8.5), so CSRF never arises there. Two routes are exempt because their ONE credential is a header and no cookie authenticates anything on them — the reasoning above applied to a route, not a path glob over cookie traffic: the promotion receiver (PromotionRouteMatcher, §10.6, refused outright without a valid server key) and, since #353, POST /api/v1/visualizations/{id}/tests/sessions/{sessionId}/screenshot (UploadCapabilityRouteMatcher): its handler reads no principal, its PublicPaths entry makes it ungoverned, and the single-use DP-Upload-Token capability is verified against the run it names — an agent's Playwright request carries no dp_csrf pair, and there is no cookie session there to protect. dp_session's SameSite=Lax (§5.5 — Strict withholds the cookie on the IdP's cross-site redirect chain and strands every login) is defense-in-depth, not the control: it defends against neither a same-site subdomain attacker nor a cross-site top-level GET. In the §8.1 chain this is a RequestMatcher over the credential carrier, not a path glob. A CSRF failure returns 403 auth.csrf.invalid with details.reason: missing | mismatch (§9). All three cookies this module mints (dp_session, dp_csrf, dp_oauth2_authz) carry the Secure flag keyed off datapipelines.auth.base-url's scheme (T33): https:// — or no base-url at all — keeps Secure; an explicit http:// base-url drops it so local development login works over plain HTTP. Production MUST be https — the wrong default would silently drop sessions there, so absent configuration fails secure. The dp_csrf cookie is stable for the life of the browsing session: it is minted by the first response that renders a CSRF token (the login page) and is never rotated or deleted afterwards — the chain's CSRF config pins NullAuthenticatedSessionStrategy, because Spring's default CsrfAuthenticationStrategy rotates-or-deletes the cookie on every security-context change, which (with per-request JWT authentication, §4.2) is every authenticated request and would strand each rendered page's hx-headers token against a rotated cookie (found 027). (History: v2.2's prose and sketch contradicted each other; v2.3 briefly resolved toward path-based exemption + SameSite=Strict; v2.4 supersedes both after two Gate C seats independently flagged the subdomain gap — exemption follows the credential, not the path.)
8.5 MCP endpoint (/mcp)
POST /mcp and GET /mcp (MCP Server §3) are API-key-only — session cookies are not accepted there. Two equivalent credential carriers:
DP-API-Key: dpk_<id>.<secret>— same as REST.Authorization: Bearer dpk_<id>.<secret>— for MCP clients that can only set the standard Authorization header. TheApiKeyFilterrecognizes thedpk_prefix in a Bearer token and routes it through the identical validation path (§7.3).
/mcp is CSRF-exempt (no cookie auth) and enforces the same permission catalog (§7.6) per tool.
The filter chain authenticates on the servlet thread and the MCP layer carries the principal forward in the transport context (McpToolContext), because the MCP SDK runs each tool handler on its own scheduler thread — SecurityContextHolder is empty there. Code reachable from a tool therefore takes the principal and the workspace as arguments and never reads the thread-local; a @Bean adapter that did (the save-time datasource lookup, until 134) silently validated every MCP save as "no principal" (MCP Server §4.1). Filters, interceptors and MVC controllers run on the request thread and may keep reading it.
8.6 Entry inventory and public contract
Every way into the running application, as a reviewed table the build compares with what the application actually registers — in both directions, by name (#217, security-assurance record §4, ratified B3/B5). A registration with no row fails: somebody added a filter, a servlet, a scheduled job or a listener that nobody reviewed. A row with no registration also fails: the doc promises an entry, or a guard, that is not there. Adding one of these is a two-line change — the code and its row — in the same commit, the rule AGENTS.md already applies to handlers and §7.6.
Handler methods are not listed here: §7.6 is their reviewed table (B3 — one table, never two). EntryInventoryE2eTest places every handler the application registers, from every module, on the §7.6 row of the permission it declares or in the public contract below, and every route §7.6 names must be registered.
The entry inventory: one row per registration, read at runtime by EntryInventoryE2eTest. Servlets and container filters come from the servlet container, with each filter's order from Spring Boot's sorted initializers (container when the container registered it itself). The security chain comes from FilterChainProxy, in the order it runs. Handler mappings are every HandlerMapping bean, and a mapping of a kind the test cannot read fails until it has an adapter. Scheduled jobs are every @Scheduled task. Ports and actuator endpoints come from the PACKAGED jar booted as its own process: its LISTEN sockets are read from /proc/<pid>/fd and /proc/<pid>/net/tcp{,6}, and its management port's actuator index is read with a session, each endpoint with the status an anonymous caller gets. "Reachable from a request" is no for every scheduled job (B5 — a job is inventoried, unreachable from any transport and bounded, never an entry point), and the test also fails if a job's bean is a request handler. Since #9 the scheduler's db-scheduler tasks are a family of their own (scheduler-task), read off every Task bean — a new task is a new row, and the same B5 rules hold for it.
| Family | Name | Type | Pattern / schedule | Purpose | Reachable from a request | Bounded work |
|---|---|---|---|---|---|---|
| servlet | dispatcherServlet |
DispatcherServlet |
/ |
Spring MVC's front controller: every handler method (§7.6, §8.6.2), the static resources and the error page. | yes | Per request; the handlers' own bounds. |
| servlet | mcpTransport |
HttpServletStatelessServerTransport |
/mcp |
The MCP JSON-RPC transport (MCP Server §3); an API key only (§8.5), each tool gated by its catalog row. | yes | Per request; the tools' own bounds and the §12 limiter. |
| filter | characterEncodingFilter |
OrderedCharacterEncodingFilter |
/* · order -2147483648 |
Spring Boot: UTF-8 request and response encoding. | yes | Constant per request. |
| filter | webMvcObservationFilter |
ServerHttpObservationFilter |
/* · order -2147483647 |
Micrometer: the HTTP server observation (metrics) for every request. | yes | Constant per request. |
| filter | correlationIdFilterRegistration |
CorrelationIdFilter |
/* · order -2147483638 |
Adopts or mints the correlation id, into the MDC and the response header (Observability §3.3). | yes | Constant per request. |
| filter | corsFilterRegistration |
CorsFilter |
/* · order -2147483637 |
CORS for the one configured origin, auth.base-url (§8.2 step 1); no origin configured, no CORS. |
yes | Constant per request. |
| filter | requestBodyCapFilterRegistration |
RequestBodyCapFilter |
/api/v1/*, /mcp, /mcp/* · order -2147483636 |
The platform's request-body cap (#279): a body over datapipelines.web.max-request-bytes refused 413 request.body_too_large before the chain and before any parser reads it (pipeline-contract §13.21). ONE route exemption (#353): POST /api/v1/visualizations/{id}/tests/sessions/{sessionId}/screenshot carries a raw PNG/WebP body under its OWN 4 MiB cap (the session service's constant), refused 413 visualization.test.screenshot_too_large by the same two paths — never the platform's cap, and no other route moves. |
yes | The cap bounds the read; the counting stream never pulls past it. The one bounded exception: after a declared-over-cap screenshot's 413 the filter discards up to 6 MiB of that body so its connection closes cleanly (#467). |
| filter | formContentFilter |
OrderedFormContentFilter |
/* · order -9900 |
Spring Boot: form bodies on PUT, PATCH and DELETE. | yes | The body the container already bounds. |
| filter | requestContextFilter |
OrderedRequestContextFilter |
/* · order -105 |
Spring Boot: binds the request to its thread for request-scoped lookups. | yes | Constant per request. |
| filter | springSecurityFilterChain |
DelegatingFilterProxyRegistrationBean$1 |
/* · order -100 |
Delegates to Spring Security's filter chain — the security-chain rows below. |
yes | The chain's. |
| filter | rateLimitFilterRegistration |
RateLimitFilter |
/* · order -99 |
The per-user request limiter over /api/v1 and /mcp (REST API §12). |
yes | Constant per request; the window lives in Redis. |
| filter | endpointKeyBudgetFilterRegistration |
EndpointKeyBudgetFilter |
/* · order -98 |
The per-key request budget of every api_caller key on the serve path (Configuration §3.22). |
yes | Constant per request; the window lives in Redis. |
| filter | mcpAuthFilterRegistration |
McpAuthFilter |
/mcp · order -90 |
Turns the key the chain validated into the MCP calling context, or refuses (§8.5). | yes | Constant per request. |
| filter | Tomcat WebSocket (JSR356) Filter |
WsFilter |
/* · order container |
Registered by Tomcat's WebSocket initializer (the tomcat-embed-websocket jar is on the classpath). No WebSocket endpoint is deployed, so it passes every request on; an endpoint would be a new entry family and needs its own row and adapter first (security-assurance record §4). |
yes | Passes through: no endpoint to upgrade to. |
| security-chain | chain 1 #01 |
DisableEncodeUrlFilter |
any request |
Never writes a session id into a URL. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #02 |
WebAsyncManagerIntegrationFilter |
any request |
Carries the security context into async (SSE) processing. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #03 |
SecurityContextHolderFilter |
any request |
Loads the (empty — stateless) security context per request. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #04 |
HeaderWriterFilter |
any request |
The security headers, written eagerly (§8.1, SecurityHeaders). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #05 |
CorsFilter |
any request |
Spring Security's CORS hook — the same single-origin configuration. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #06 |
CsrfFilter |
any request |
The dp_csrf double-submit on state-changing cookie requests (§8.4); exempt by credential: an API key, and the two routes whose only credential is a header no cookie stands in for — the promotion route's server key and the test screenshot upload's capability (#353). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #07 |
LogoutFilter |
any request |
POST /logout: clears the session cookie and audits (§6.5). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #08 |
OidcSignedInBounceFilter |
any request |
A signed-in visitor never starts a new authorization ceremony at /oauth2/authorization/*. |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #09 |
OAuth2AuthorizationRequestRedirectFilter |
any request |
/oauth2/authorization/{provider}: the redirect to the identity provider (§5.4). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #10 |
OAuth2LoginAuthenticationFilter |
any request |
/login/oauth2/code/{provider}: the OIDC callback (§5.4–5.5). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #11 |
PromotionServerKeyFilter |
any request |
/api/v1/promotion/** only: the promotion credential gate (§7.7, §10.6); inert elsewhere. |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #12 |
LoginRateLimitFilter |
any request |
/login and /oauth2/ only: the per-client-IP damper (§11.5). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #13 |
ApiKeyFilter |
any request |
DP-API-Key (or Bearer dpk_ on /mcp): validates the key and sets the principal (§7.3). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #14 |
JwtAuthenticationFilter |
any request |
The dp_session cookie: validates the session and sets the principal (§6.3). |
yes | Constant per request; key and session checks read the §11.4 cache. |
| scheduled | DashboardRefreshSweepScheduler#sweep |
fixedDelay |
every PT1M |
Closes a RUNNING dashboard refresh that outlived its longest possible deadline plus one abort poll as TIMED_OUT — the instance holding it died (Metadata DB §8.4, #10 L2). |
no | One idempotent guarded UPDATE per tick (AND status = 'RUNNING'); on the jobs' one dp-scheduled thread, ticks never overlap. |
| security-chain | chain 1 #15 |
WorkspaceResolutionFilter |
any request |
Resolves the active workspace onto the principal (§5.6). | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #16 |
RequestCacheAwareFilter |
any request |
Spring Security's saved-request hook (no session: nothing is saved). | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #17 |
SecurityContextHolderAwareRequestFilter |
any request |
Exposes the principal through the servlet request API. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #18 |
AnonymousAuthenticationFilter |
any request |
Marks a request with no credential anonymous, so the §8.3 allowlist can admit it. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #19 |
SessionManagementFilter |
any request |
Session policy: stateless, no servlet session is created. | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #20 |
ExceptionTranslationFilter |
any request |
Turns an access refusal into the entry point's 401/302 or the 403 handler (§8.2). | yes | Constant per request; key and session checks read the §11.4 cache. |
| security-chain | chain 1 #21 |
AuthorizationFilter |
any request |
The §8.3 allowlist, then authenticated() for everything else. |
yes | Constant per request; key and session checks read the §11.4 cache. |
| handler-mapping | requestMappingHandlerMapping |
RequestMappingHandlerMapping |
handler methods — auth.md §7.6 and §8.6.2 |
Every @RequestMapping handler. Its routes are placed on §7.6's rows (governed) or in the public contract below. |
yes | The handlers'. |
| handler-mapping | resourceHandlerMapping |
SimpleUrlHandlerMapping |
/**, /site/**, /webjars/** |
Static resources from the packaged classpath locations. Only the §8.3 globs (/site/**, /vendor/**, /css/**, /js/**, /favicon.ico, /robots.txt) are anonymous; every other static path needs a credential. |
yes | A file read; the container bounds the response. |
| handler-mapping | welcomePageHandlerMapping |
WelcomePageHandlerMapping |
no URL |
Spring Boot's welcome page: no index.html is packaged, so it maps nothing. |
no | Maps nothing. |
| handler-mapping | welcomePageNotAcceptableHandlerMapping |
WelcomePageNotAcceptableHandlerMapping |
no URL |
The welcome page's 406 companion; maps nothing for the same reason. | no | Maps nothing. |
| handler-mapping | beanNameHandlerMapping |
BeanNameUrlHandlerMapping |
no URL |
Maps a bean named like a URL; no bean is. | no | Maps nothing. |
| handler-mapping | routerFunctionMapping |
RouterFunctionMapping |
no route function |
Functional routes: none. A RouterFunction bean would be a new entry mechanism, and this row changes with it. |
no | Maps nothing. |
| handler-mapping | healthEndpointWebMvcHandlerMapping |
AdditionalHealthEndpointPathsWebMvcHandlerMapping |
no mapping |
Actuator's additional health-group paths on the application port; no group sets one. | no | Maps nothing. |
| scheduled | StaleExecutionSweepScheduler#sweep |
fixedDelay |
every PT15S |
Marks a RUNNING execution whose heartbeat stopped as failed — the instance holding it died (deployment.md §6.2). | no | One idempotent UPDATE per tick; on the jobs' one dp-scheduled thread (#316 — never the SSE log streamer's), ticks never overlap. |
| scheduled | DatasourcePoolReaperScheduler#reap |
fixedDelay |
every PT5S |
Closes retired datasource pools once drained or past their ceiling (Datasources §5.2). | no | Walks one in-memory queue, usually empty; on dp-scheduled. |
| scheduled | ExecutionEventRetentionScheduler#retain |
fixedDelay |
every PT1H |
The retention sweep, six isolated steps in order: deletes execution_events rows older than their retention window (Metadata DB §8.1), deletes finished dashboard_refreshes on the same cutoff (#10 L2), closes parameter_evaluations left RUNNING past the evaluate deadline plus one minute as INCOMPLETE (#376, Metadata DB §8.5), deletes finished parameter_evaluations on the event cutoff (#376), purges revoked keys nothing references (§11C), and deletes audit_log rows older than datapipelines.audit.retention-days (§10.3, Metadata DB §8.2). |
no | One DELETE for the events and one for the finished dashboard refreshes; one guarded UPDATE for the stale evaluations and one DELETE of at most 5,000 finished evaluation records (their query rows cascade); one transaction for the purge; the audit step at most 50 batches of 5,000 rows or 2 s, cutoff from the database's clock; on dp-scheduled. |
| scheduler-task | schedule-dispatcher |
recurring |
every PT10S |
#9's ONE dispatcher (Scheduler): records due occurrences and enqueues their run task in one transaction. A db-scheduler task, not an @Scheduled job — one instance cluster-wide, only on an instance with datapipelines.scheduler.enabled. |
no | At most 100 due schedules per tick, FOR UPDATE SKIP LOCKED; one run and one missed summary per schedule however long the outage. |
| scheduler-task | schedule-reconciler |
recurring |
every PT10S |
Maps each watched run's execution outcome onto the run (R6) — the executor's standardized outcome, never a pipeline table. | no | At most 500 watched runs per tick; one conditional UPDATE per changed run. |
| scheduler-task | schedule-notifier |
recurring |
every PT10S |
#442b: sends the schedule notices the run transitions queued (schedule_notification_deliveries, Scheduler §2.3) through the shared SMTP transport, one message per recipient, re-checking the enable flag and the recipient-domain allowlist at send. Never changes a run. |
no | At most notifications.batch-size (20) deliveries per tick, one FOR UPDATE SKIP LOCKED claim each, every statement bounded (30 s); the SMTP timeouts bound each send. |
| scheduler-task | schedule-run |
one-time |
one instance per recorded run |
Admits one run: capacity, preparation, the start claim, the launch through the executor — as the system identity, never a request's principal. | no | One run per delivery; returns once the execution's record exists (it never holds a thread for the run). |
| port | server.port |
listening socket |
bound to * |
The application port: every handler, /mcp, the site and the published endpoints. |
yes | Tomcat's thread and connection limits. |
| port | management.server.port |
listening socket |
bound to 127.0.0.1 |
Actuator only, on loopback by default (Configuration §3.14); nothing under /actuator is routable on the application port. |
yes | Tomcat's limits; one endpoint exposed. |
| actuator | health |
management endpoint |
/actuator/health · anonymous 401 |
The one exposed actuator endpoint (management.endpoints.web.exposure.include: health). The application's security chain guards the management context too, so an anonymous caller is refused; orchestrator probes use /health and /ready on the application port (§8.3). |
yes | The health indicators' own timeouts. |
The public contract: one row per §8.3 glob, its reason exactly as PublicPaths states it. PublicContractE2eTest holds the running application to it. Every handler under a public glob is named in the row of the FIRST glob, in declaration order, that covers it. A row names only handlers that are registered: resources for the static-resource handler, filter:<Class> for a security-chain endpoint. The rows and PublicPaths are the same set of globs, reason for reason. An anonymous GET of the probe answers the row's status, never with a stack trace and never with a session cookie. An anonymous POST, PUT and DELETE on the probe (with a matching CSRF pair, so the refusal measured is the route's) are refused (403, 404 or 405). The exceptions: the row names the suites that own those verbs (own E2E: — the login and OIDC flows are tested where they live, and the test checks that those suites exist), or states that every verb gets the GET's answer (same answer:). The "principal · write · content" cell is the reviewed claim; no test measures datastore writes here (the record's slice B effect witnesses will).
| Glob | Reason (PublicPaths) |
Handlers | Probe | Answer | Principal · write · content | Other verbs |
|---|---|---|---|---|---|---|
/ |
The marketing home page: constant content, no datastore, no principal — public by design (033/D4). | GET / SiteController#home |
/ |
200 | no principal · no write · constant | refused |
/site/** |
The marketing site's own css/js/img assets; the design system it references rides /vendor/** below. | resources |
/site/css/site.css |
200 | no principal · no write · static asset | refused |
/mcp-server-for-sql-databases |
Intent-cluster page: GET-only constant content whose only live fact is the compile-time MCP tool count. | GET /mcp-server-for-sql-databases SitePagesController#pillar |
/mcp-server-for-sql-databases |
200 | no principal · no write · constant | refused |
/mcp-server/* |
Per-engine intent-cluster pages, one segment deep and enumerated by the page registry, not globbed open. | GET /mcp-server/{engine} SitePagesController#engine |
/mcp-server/postgres |
200 | no principal · no write · constant | refused |
/add-mcp-server-to-claude-code |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | GET /add-mcp-server-to-claude-code SitePagesController#addToClaudeCode |
/add-mcp-server-to-claude-code |
200 | no principal · no write · constant | refused |
/ai-data-pipeline |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | GET /ai-data-pipeline SitePagesController#aiDataPipeline |
/ai-data-pipeline |
200 | no principal · no write · constant | refused |
/text-to-sql-agent |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | GET /text-to-sql-agent SitePagesController#textToSqlAgent |
/text-to-sql-agent |
200 | no principal · no write · constant | refused |
/compare/* |
The comparison pages (airflow, dbt): GET-only constant content, one segment deep, no datastore. | GET /compare/airflow SitePagesController#compareAirflow GET /compare/dbt SitePagesController#compareDbt GET /compare/dagster-vs-airflow SiteV2Batch2Controller#compareDagsterAirflow GET /compare/fivetran-airbyte SiteV2Batch2Controller#compareFivetranAirbyte GET /compare/postgres-only SiteV2Batch2Controller#comparePostgresOnly |
/compare/dbt |
200 | no principal · no write · constant | refused |
/federated-query |
Intent-cluster page: GET-only constant content, no datastore and no principal on the request. | GET /federated-query SitePagesController#federatedQuery |
/federated-query |
200 | no principal · no write · constant | refused |
/dp-lake |
The dp-lake product page: same shape and same reasoning as the 073 intent-cluster pages. | GET /dp-lake SitePagesController#dpLake |
/dp-lake |
200 | no principal · no write · constant | refused |
/faq |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /faq SitePagesController#faq |
/faq |
200 | no principal · no write · constant | refused |
/roadmap |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /roadmap SitePagesController#roadmap |
/roadmap |
200 | no principal · no write · constant | refused |
/security |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /security SitePagesController#security |
/security |
200 | no principal · no write · constant | refused |
/published-api |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /published-api SitePagesController#publishedApi |
/published-api |
200 | no principal · no write · constant | refused |
/mcp-tools |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /mcp-tools SitePagesController#mcpTools |
/mcp-tools |
200 | no principal · no write · constant | refused |
/tableau |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /tableau SitePagesController#tableau |
/tableau |
200 | no principal · no write · constant | refused |
/tableau/* |
Site v2 intent pages under the Tableau hub: GET-only constant content, no datastore and no principal on the request. | GET /tableau/governed-dataset SitePagesController#tableauGovernedDataset GET /tableau/extracts-alerts-dashboards SiteV2Batch2Controller#tableauRoadmap GET /tableau/prep-vs-pipelines-as-code SiteV2Batch2Controller#tableauPrep |
/tableau/governed-dataset |
200 | no principal · no write · constant | refused |
/for/* |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /for/analysts SiteV2Batch2Controller#forAnalysts GET /for/agencies SiteV2Batch2Controller#forAgencies GET /for/saas-teams SiteV2Batch2Controller#forSaasTeams |
/for/analysts |
200 | no principal · no write · constant | refused |
/how-it-works |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /how-it-works SitePagesController#howItWorks |
/how-it-works |
200 | no principal · no write · constant | refused |
/demo-data |
Demo-data page: GET-only, renders the vendored manifests parsed at startup; no datastore, no principal. | GET /demo-data SitePagesController#demoData |
/demo-data |
200 | no principal · no write · vendored manifests read at startup | refused |
/pricing |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /pricing SiteV2Batch2Controller#pricing |
/pricing |
200 | no principal · no write · constant | refused |
/semantic-layer |
Site v2 intent page: GET-only constant content, no datastore and no principal on the request. | GET /semantic-layer SiteV2Batch2Controller#semanticLayer |
/semantic-layer |
200 | no principal · no write · constant | refused |
/use-cases |
Site v3 hub page: GET-only constant content, no datastore and no principal on the request. | GET /use-cases SiteHubController#useCases |
/use-cases |
200 | no principal · no write · constant | refused |
/explore |
Site v3 directory, generated from the page registry and the packaged docs catalog; no datastore, no principal. | GET /explore SiteHubController#explore |
/explore |
200 | no principal · no write · generated from the page registry and the docs catalog | refused |
/pipeline-scheduling |
Site capability page: GET-only constant content, no datastore and no principal on the request. | GET /pipeline-scheduling SiteCapabilitiesController#pipelineScheduling |
/pipeline-scheduling |
200 | no principal · no write · constant | refused |
/sql-dashboards |
Site capability page: GET-only constant content, no datastore and no principal on the request. | GET /sql-dashboards SiteCapabilitiesController#sqlDashboards |
/sql-dashboards |
200 | no principal · no write · constant | refused |
/reusable-parameter-sets |
Site capability page: GET-only constant content, no datastore and no principal on the request. | GET /reusable-parameter-sets SiteCapabilitiesController#reusableParameterSets |
/reusable-parameter-sets |
200 | no principal · no write · constant | refused |
/docs |
The in-product spec index: packaged Markdown, no principal, no datastore, already public in the AGPL repo. | GET /docs DocsController#index |
/docs |
200 | no principal · no write · packaged Markdown | refused |
/docs/* |
One packaged spec per slug, rendered from the jar or raw at its .md twin (173); the same text is public on GitHub. | GET /docs/{slug} DocsController#doc GET /docs/{slug} DocsController#markdown GET /docs/{slug}.md DocsController#markdown |
/docs/auth |
200 | no principal · no write · packaged Markdown | refused |
/skill.md |
The agent manual's core, raw: it is the MANUAL, so requiring a key would gate learning how to use the key. | GET /skill.md SkillController#skill |
/skill.md |
200 | no principal · no write · the rendered manual | refused |
/skill/* |
The manual's guides and references, rendered at boot from resources packaged in this jar. | GET /skill/{name}.md SkillController#reference |
/skill/endpoints.md |
200 | no principal · no write · the rendered manual | refused |
/robots.txt |
Crawler infrastructure: a document that is meaningless unless it is readable without a login. | resources |
/robots.txt |
200 | no principal · no write · static file | refused |
/sitemap.xml |
Generated from the page registry and packaged doc slugs; a sitemap behind auth indexes nothing. | GET /sitemap.xml SitemapController#sitemap |
/sitemap.xml |
200 | no principal · no write · generated from the registries | refused |
/llms.txt |
The llms.txt index, generated from the page registry and the docs catalog; an agent index behind auth indexes nothing. | GET /llms.txt LlmsTxtController#index |
/llms.txt |
200 | no principal · no write · generated from the registries | refused |
/llms-full.txt |
The full llms.txt: every packaged doc's Markdown, already public at its own .md route and on GitHub. | GET /llms-full.txt LlmsTxtController#full |
/llms-full.txt |
200 | no principal · no write · packaged Markdown | refused |
/health |
Liveness probe for orchestrators, which present no credential; flattened UP/DOWN plus version only. | GET /health HealthController#health |
/health |
200 | no principal · no write · probe state and version | refused |
/ready |
Readiness probe for orchestrators, which present no credential; flattened UP/DOWN plus version only. | GET /ready HealthController#ready |
/ready |
200 | no principal · no write · probe state and version | refused |
/info |
Build info (version, build time, commit) — the deployment's own identity, no principal data. | GET /info HealthController#info |
/info |
200 | no principal · no write · build info | refused |
/login |
The login page and the local password POST: the surface a caller uses BEFORE it has a credential. | GET /login UiController#login POST /login LocalLoginController#login |
/login |
200 | GET: no principal · no write · the login page. POST: a login attempt — the lockout counter and the audit row (§5A.3) | own E2E: LocalLoginE2eTest, LocalAdminSeedE2eTest |
/login/** |
Login error redirects and the OIDC callback subtree, reached before any session exists. | filter:OAuth2LoginAuthenticationFilter |
/login/oauth2/code/google |
302 | the OIDC callback: a session only on a completed ceremony (§5.5) | own E2E: OidcLoginIntegrationTest, OidcOnlyRegressionE2eTest |
/oauth2/** |
The OIDC authorization redirect and callback endpoints, by definition pre-authentication. | filter:OAuth2AuthorizationRequestRedirectFilter |
/oauth2/authorization/google |
302 | the redirect to the identity provider: the authorization-request cookie only | own E2E: OidcLoginIntegrationTest, PkceAuthorizationRequestTest |
/vendor/** |
Vendored static assets (design system, fonts, icons, Alpine, Cytoscape, htmx) served to the login page too. | resources |
/vendor/htmx/htmx.min.js |
200 | no principal · no write · static asset | refused |
/css/** |
The application stylesheets, which the unauthenticated login and error pages already render. | resources |
/css/app.css |
200 | no principal · no write · static asset | refused |
/js/** |
The application scripts, which the unauthenticated login and error pages already render. | resources |
/js/csrf.js |
200 | no principal · no write · static asset | refused |
/favicon.ico |
The site icon, requested by the browser on the login page before any credential exists. | resources |
/favicon.ico |
200 | no principal · no write · static asset | refused |
/error |
Boot's default error page with no stack trace or message; an anonymous error must not loop through login. | GET /error BasicErrorController#errorHtml GET /error BasicErrorController#error |
/error |
500 | no principal · no write · Boot's error page, no stack or message | same answer: 500 |
/visualizations/*/preview |
The visualization test preview: its ONLY credential is the ?session= preview token, checked by VisualizationTestCapabilities.preview; no session cookie, no other route. | GET /visualizations/{id}/preview VisualizationPreviewController#preview |
/visualizations/00000000-0000-4000-8000-000000000000/preview |
404 | no principal · no write · one run's saved fixtures, served only for its live preview capability — every refusal is the one 404 page | refused |
/api/v1/visualizations/*/tests/sessions/*/screenshot |
The test screenshot upload: its ONLY credential is the single-use DP-Upload-Token, checked by VisualizationTestCapabilities.authorizeUpload; no cookie is read. | POST /api/v1/visualizations/{id}/tests/sessions/{sessionId}/screenshot VisualizationTestsController#screenshot |
/api/v1/visualizations/00000000-0000-4000-8000-000000000000/tests/sessions/00000000-0000-4000-8000-000000000000/screenshot |
405 | POST: no principal · one screenshot row and the capability's consumption, in one transaction, for a valid capability only | own E2E: VisualizationTestSurfacesE2eTest |
9. Auth Errors
Codes follow the {domain}.{entity}.{failure} convention; the registry of record is Pipeline Contract §13.7.
| Code | HTTP | Description |
|---|---|---|
auth.api_key.expiry_invalid |
400 | Key issuance named an unusable expiry — unknown preset, unparseable date, a date in the past, or custom with no date (§7.4). details.reason names which |
auth.login.domain_not_allowed |
403 | Email domain not in allowlist (OIDC) |
auth.login.user_inactive |
403 | User account deactivated (OIDC or local) |
auth.login.bad_credentials |
401 | Local login rejected: email unknown or password incorrect — deliberately identical (§5A.5) |
auth.login.locked |
403 | Local account locked after consecutive failures (§5A.3) |
auth.password.change_required |
403 | Session principal must change password before any other operation (§5A.4) |
auth.login.oidc_error |
500 | OIDC or OAuth 2.0 provider login failed — details.reason names the cause (§5.5) |
auth.session.expired |
401 | JWT expired |
auth.session.invalid |
401 | JWT signature invalid or malformed |
auth.api_key.missing |
401 | No DP-API-Key header, no Bearer dpk_ token, no dp_session cookie |
auth.api_key.invalid |
401 | Key id not found, revoked, hash mismatch, or the row of the user it acts as gone — a deactivated member or identity is auth.principal_deactivated |
auth.api_key.expired |
401 | Key's expires_at is in the past |
auth.permission.undeclared |
403 | The route or tool declares no catalog permission, so nothing can judge it (§7.6) — a build defect the coverage guards fail, kept as the runtime defence. details.route or details.tool names the surface. Replaced auth.scope.insufficient (#215 PK8) |
auth.role_required |
403 | Principal's role lacks the required PERMISSION in the active workspace — the role axis of the §7.6 matrix (§11A). details.required is the catalog permission (pipeline.update); details.held the ROLE it was judged as (viewer … workspace_admin, super_admin, a key role) — informative only, never compared (#215). The one authorization refusal for every principal since slice (b); the MCP ownership rules ride it too (details.reason: not_creator, started_outside_mcp, different_credential) |
auth.key_issuer_role_lost |
403 | A key principal judged through a workspace CONTEXT (the promotion config-value peer) lacks the permission (§7.5). Since keys v2 every key carries its own role and refuses with auth.role_required; this code remains for the context-judged path. Retrying with the same credential will not help |
auth.key_kind_not_mintable |
400 | Keys v2 (§7.4): issuance named NO kind — there is no default kind since the login mint retired (A15); name mcp, endpoint or server. A request naming the retired user wire word is an unknown kind (endpoint.key_kind_refused) |
auth.key_workspace_inactive |
404 | The key's pinned workspace is deactivated (§11A); reactivating it restores the key |
auth.key_name_taken |
409 | Keys v2 A18 (§7.4): the create path named a key that already exists LIVE in the workspace — the unique index's clean refusal ahead of the constraint's raw failure. details.reason is key_name_taken; revoking the existing key frees the name |
auth.principal_deactivated |
401 | The principal's user is deactivated (§11A.3) — a session's or MCP key's member, or an endpoint/server key's own identity (§4.7); judged by PrincipalLiveness where the credential becomes a principal, within the §11.4 window. A session's cookie is cleared and an HTML navigation lands on /login?error=inactive. Never on /api/v1/promotion/**, where every refusal is auth.promotion.key_invalid |
auth.csrf.invalid |
403 | CSRF token missing or mismatched on a state-changing UI request (details.reason: missing | mismatch) |
auth.promotion.key_invalid |
401 | The promotion peer's pre-shared server key was absent, malformed, or did not match — and the same code when the receiver has no key configured, so promotion-disabled is indistinguishable from wrong-key (Versioning §10.6) |
Rate limiting uses the single system-wide rate_limit.exceeded code (Pipeline Contract §13.11) — there is no separate auth-layer rate-limit code. The two limiters answer with DIFFERENT user messages (#232): the login damper says "Too many sign-in attempts. Wait a minute and try again." (what it counts — sign-in attempts), while the per-user API limiter and every API surface answer with the error catalog's request-volume sentence, "You're sending requests faster than we allow. Wait a moment and try again." (§13.11 carries both). The login damper is in-process (per instance, no Redis), so it never raises the companion rate_limit.unavailable: that code belongs to the shared per-user limiter, whose counters live in Redis and which fails closed when they cannot be read (REST API §12.3). The login rate limit is datapipelines.auth.rate-limit.login-per-minute (Configuration §3.4), and the address it buckets by — like every audit source_ip — is resolved through datapipelines.auth.trusted-proxies (Deployment §6.2): behind a load balancer, without that list every request shares the LB's address and the per-IP limiter becomes a global one. Empty (the default) means X-Forwarded-For is ignored entirely and the direct peer is the client, so a bare deployment is unaffected.
Workspace resolution failures (§5.6) use the workspace.* codes — catalogued in Pipeline Contract §13.12. The 404 rule governs them: an addressed workspace the caller cannot reach — unknown, non-member, or deactivated — is one answer, workspace.not_found (404), never a 403. workspace.membership_required (403) survives only where NO workspace was addressed (a principal with zero memberships), because there is no name there whose existence a 403 could leak. workspace.creation_forbidden was retired with the provisioning modes (§4.2).
10. Audit Log
10.1 Events
| Event | Trigger |
|---|---|
auth.login.success |
Login succeeded, JWT issued (OIDC or local — the details' provider names the method) |
auth.login.domain_not_allowed |
User's email domain not in allowlist |
auth.super_admin_acting |
A super admin acted in a workspace they hold no explicit membership in (§11A). Emitted by ScopeInterceptor at the one choke point every governed handler passes — reads included: the 404 rule's promise is that a workspace is invisible from outside, and the one principal exempt from that promise is the one whose reads most need to be on the record. details: the operation, workspace, path, method, and acting_via: super_admin |
workspace.member_added |
A member was added, with their role (details.role). The first-login demo join (§4.2) carries reason: first_login_demo_viewer |
workspace.member_invited |
An invitation was created (§4.6) — or an existing one's role REPLACED by a re-invite; the upsert is the latest admin decision winning, so it is audited every time it fires. details: workspace, email, role |
workspace.invitation_revoked |
A pending invitation was revoked (§4.6). details: workspace, email |
workspace.invitation_materialised |
A pending invitation became a real membership at login (§4.2 step 2a / §4.6). details: workspace, email, role, and inviter — the actor whose decision the login is executing |
workspace.member_removed |
A member was removed |
workspace.member_role_changed |
A member's role was replaced (D1; was workspace.member_flags_changed) — details.from and details.to carry both roles, because a membership row keeps no history of its own |
workspace.deactivated |
A workspace was deactivated (§11A). Nothing it owns is purged |
workspace.reactivated |
A deactivated workspace was restored |
datasource.granted |
A datasource was granted to a workspace — the verb that decides who can reach a live database's data. details.already_granted separates a new grant from an idempotent re-grant |
datasource.revoked |
A datasource's grant to a workspace was removed; the datasource itself is untouched |
auth.login.user_inactive |
User account is deactivated (OIDC or local — same event) |
auth.login.oidc_error |
OIDC or OAuth 2.0 provider login failed — details.reason (the OAuth 2.0 error code, or a §4.2/§5.5 refusal) and details.provider (a configured registration id, else null) |
auth.login.bad_credentials |
Local login failed: unknown email, OIDC-only account, or wrong password — deliberately indistinguishable (§5A.5) |
auth.login.locked |
Local account locked after lockout.max-failures consecutive failures (§5A.3) |
auth.password.seeded |
Config seeded the bootstrap admin's one-time local credential (§5A.2) |
auth.password.changed |
User changed their own password (self-service or forced, §5A.4) |
auth.password.reset |
Admin reset a user's password — new one-time credential (§5A.1) |
auth.password.disabled |
Admin disabled a user's local access — account is OIDC-only (§5A.1) |
auth.user.created |
Admin created a local account (§5A.1; details carry the acting admin) |
auth.user.unlocked |
Admin cleared a local account's lockout (§5A.3) |
auth.logout |
User logged out (cookie cleared) |
auth.api_key.created |
New API key issued |
auth.api_key.revoked |
API key revoked |
auth.api_key.revoked_by_admin |
A member's login-minted key was revoked by a workspace admin or super admin (#200; roles record §3.7) — because their membership ended (details.reason: member_removed, the same act that removes them) or as an explicit act that keeps the member (reason: admin_revoked). details carry workspace, target_user_id and reason; key_id is the revoked key |
auth.api_key.used |
API key validated (sampled 1/100) |
auth.api_key.rejected |
API key validation failed |
auth.scope.denied |
Request rejected for insufficient scope |
auth.user.deactivated |
Admin deactivated a user |
auth.user.activated |
Admin reactivated a user |
auth.user.admin_granted |
Admin granted admin scope to user |
auth.user.admin_revoked |
Admin revoked admin scope from user |
auth.workspace.created |
Workspace created through the service path |
auth.workspace.header_rejected |
DP-Workspace presented on an API-key request (§5.6) |
visualization.test.screenshot_uploaded |
A visualization test run's screenshot was stored (#353, audited by #164). The upload route is a public path whose ONLY credential is the single-use upload capability, so there is no principal: user_id is the run's starter (started_by), key_id is null, the source IP is on the row. The one write an agent makes over REST. details: workspace, visualization, run, media type, size in bytes, the case named — never the token, its hash or the bytes (Enums §15) |
The same audit_log table also carries the promotion events — auth.promotion.rejected when the peer-credential gate refuses a request, auth.promotion.accepted when a batch is applied (Versioning §10) — the datasource decryption events (Datasources §7.4), the MCP tool events — mcp.tool.called for every tool call, mcp.tool.write for every mutating one — emitted by the MCP dispatcher through the same sink (MCP §14), and the mail events — mail.sent / mail.failed for every notice §5A.8 sends, kind and recipients in details and never a body (all registered in Enums §15).
Scheduler notification mail is not audited per send — a deliberate split (#442b). A schedule's notices are sent by the system, not by a person, on transitions a run made; their record of truth is the run's own trail (notification_enqueued / _sent / _failed / _uncertain rows — ids, attempt counts and sanitized codes, never an address; Scheduler §2.3) and the delivery rows a schedule.update holder reads on the run (REST §20.11). They go through the shared transport's MailSender directly, below MailNotifier, so they write no mail.sent / mail.failed row: that row's details carry recipients, and copying every scheduled send's address into audit_log would spread personal data into a store kept for a person's acts. What IS a person's act is audited: the resend (schedule.notification_resent, REST §20.13 — schedule, run and delivery ids and the prior state, never the address), beside the settings changes themselves (schedule.created / schedule.updated, counts and events only).
10.1A Delivery: durable before log returns (#266)
Audit rows are authorization inputs, not only a trail: an MCP key's read of its own execution is decided by its mcp.execution.launched / mcp.tool.called rows (McpCallAudit.calledByKey), an endpoint key's by its serve row (EndpointServeAudit.servedByKey), and McpToolLearnings decides the MCP entry-point checks from a key's own mcp.tool.called rows. So AuditEventSink.log returns only once the row is committed — never from memory — and a caller that proceeds can always read what it just did.
The default path is the direct INSERT (#266b). The audit writer ships switched off — datapipelines.persistence.audit.enabled: false (Configuration §3.32) — because, measured, audit rows never formed batches (mean batch size 1.0 at 500 rows/s, 1.0–1.1 at 5,000) and the writer only added to their tail (p99 +4 ms at 100 concurrent executions). By default every row is the pre-#266 INSERT: on the caller's own connection inside its transaction when it holds one, on a pool connection otherwise. The batched path exists behind the switch for a deployment whose metadata database is slow enough for audit rows to queue. How a row is committed is decided per call, by AuditLogger:
| The caller… | The row is written | Why |
|---|---|---|
holds a transaction (TransactionSynchronizationManager.isActualTransactionActive() — e.g. ApiKeyService.issue, revokeOwn, member removal) |
on the caller's own connection, inside its transaction — the pre-#266 INSERT, whatever the switches say | it commits with the business write it describes and vanishes if that write rolls back, and a refused row refuses the write (#704); a separately committed row would record an action that never happened, and would hold a second pool connection while the caller holds one |
holds none (every filter, every MCP call, every serve) — the default (audit.enabled: false, or enabled: false) |
the direct INSERT on the caller's thread | the pre-#266 path |
holds none — with datapipelines.persistence.audit.enabled: true (and enabled: true) |
by the audit batching writer (Module Structure §5.19): concurrent callers share one JDBC batch in ONE transaction; each caller still waits for its own row's commit | one commit for N rows under load; nothing waits at idle |
The paragraphs below on order, bounds, timestamp and the natural key describe the batched path; on the default path each row is its caller's own INSERT, as it always was.
Order. The writer is partitioned by key id (then user id): one credential's events commit in the order they were logged, and because each caller waits for its commit, an event that happens because of an earlier one (a revoked after its created) is always committed after it. That causal order is the one the product relies on. The order of ids is weaker across the transactional boundary (#266b, the security pass's observation 2): a transactional row takes its id inside its caller's transaction, a batched row when its batch commits, so a transactional row of one key can carry a lower id than a batched row of the same key that was admitted to the writer earlier — "revoked, then called" by id for a call that was admitted first. No reader compares two audit rows' relative order: the four FROM audit_log reads in main sources are McpCallAudit.calledByKey and EndpointServeAudit.servedByKey (EXISTS) and McpToolLearnings (max(timestamp) and a DISTINCT set), so no authorization decision moves; it matters to a forensic reader of the table, who should order one key's rows by causality, not by id, around a transactional event.
Bounded. A row waits at most record-max-wait-ms (Configuration §3.32) for its batch; then the caller takes it back from the queue and runs the INSERT itself, on its own thread. A row already inside a commit is waited on — exactly as long as the caller's own INSERT would have waited on the same database. The sink never returns before the row is durable or definitively not.
Failures — which reach the caller (by direction: #266b outside a transaction, #704 inside one). Outside a transaction a store failure is the row's, never the request's: a row the database refuses (a user id with no user row, a malformed address — on the batched path it fails alone, the rest of its batch commits), or a database that cannot be reached, is WARNed (audit_log write failed …), counted (datapipelines.persistence.failures{store=audit,kind=…} on the batched path) and swallowed; the request proceeds without the row. On the direct path a store failure is a DataAccessException; on the batched path it is that or a TransactionException, because the batch runs in its own transaction and an unreachable database surfaces there as CannotCreateTransactionException (a failed COMMIT as TransactionSystemException) — neither is a DataAccessException, and the direct INSERT swallowed the same outage. Anything else propagates out of log and fails the request, exactly as the pre-#266 INSERT's catch (DataAccessException) let it: a runtime exception from the driver or the template, a details map that cannot be serialized, an Error. The batched path rethrows it on the caller's thread (AuditRowSink.propagates, BatchingWriter's one claimed exit). Every WARN names the row's ids and the failure's class and SQLState — never the database's message, which quotes the refused row (Postgres puts the row in DETAIL and a fragment of details_json in CONTEXT). A malformed credential still costs no audit write at all (ApiKeyFilter, pinned by ApiKeyRejectionAuditTest). Inside a caller's transaction the same failure propagates (#704): AuditLogger runs the joined INSERT uncaught, the caller's transaction rolls back and the request is refused — the boundary's catch-all (the generic 500 envelope, reason: internal_error; REST API catalogues no code for it, and none is added), the boundary logging the failure once. A swallowed failure there kept nothing alive: on Postgres the refused INSERT aborts the caller's transaction, so its COMMIT became a silent rollback behind a success response — #517's falsification saw a release answer 200 with nothing committed. Audit rows are authorization inputs; a key issued with no auth.api_key.created row is not an outcome this product produces.
The release rows are strict (#517, release-dependencies record §11). A release — the pipeline's, and every
dependency it flips — writes its *.version.released rows through StrictReleaseAuditSink, not AuditLogger: the
same audit_log INSERT (AuditRowSink.INSERT), on the release transaction's own connection, and never caught. A
store failure throws, the transaction rolls every flip and pointer back with it, and the release is refused —
unavailable audit storage refuses the release — the same rule, for rows the release coordinator writes through its own sink. The sink refuses to write outside a
transaction, never batches, never defers, and its rows carry ids, names, versions and words only, never a body.
One row per actual flip, dependencies first and the root last; every row of one release shares release_id, carries
scope: "version", a cascaded row cascade_root: {family, id, version}, and the root's dependencies_released
(Enums §15). via keeps its existing words (session / api_key).
timestamp. Still the column default NOW() — the database's clock, which McpToolLearnings compares with pipeline_versions.updated_at and must keep free of cross-clock skew. For a batched row it is the batch transaction's start: later than the call by the queue wait (at most record-max-wait-ms, 2 s by default, before the caller writes the row itself; measured ≤ 43 ms under load), and equal across the rows of one batch (callers that were concurrent — id orders them). The consequence, stated (#266b, the security pass's observation 1): McpToolLearnings's check "rendered after the last edit" compares a key's latest render row's timestamp with the pipeline's updated_at, so a render whose row commits in a batch that began δ after the call reads as later than an edit that landed inside that δ — the check passes a render that did not see the edit. It is permissive, and it needs a SECOND actor editing the pipeline inside δ: the key's own next call starts only after log returned, i.e. after the commit, so the single-key sequential flow the check was built for is unaffected. The race existed before #266 at the granularity of the tool's own run time (the row was inserted after the render finished); the switch-on batched path widens it by δ, and the default direct path does not.
No natural key (batched path). audit_log has none to deduplicate by, so the batch is all-or-nothing (a failed batch leaves nothing behind before its rows are retried one at a time). A batch whose COMMIT succeeded but whose acknowledgment was lost to a dropped connection is retried as singles and its rows land twice — the at-least-once edge of any retried write; the writer counts every retried batch (datapipelines.persistence.batches.retried{store=audit}).
Retention (datapipelines.audit.retention-days) is documented but still not implemented — nothing binds the key or deletes rows (#266 finding, tracked separately).
10.2 Log shape
{
"timestamp": "2026-08-05T14:30:00.123Z",
"event": "auth.login.success",
"user_id": "uuid",
"provider": "google",
"source_ip": "10.0.0.42",
"user_agent": "Mozilla/5.0...",
"details": {
"email": "alice@company.com"
}
}
10.3 Retention
datapipelines.audit.retention-days (Configuration §3.12: default 365, bounds 30–3650, refused at startup outside them) is enforced by AuditLogRetention, the retention sweep's last step — hourly, under no principal, reachable from no route or tool (ArchitectureGuardTest's B5 list names it; §8.6's inventory row is the sweep). #310.
What is deleted. Every row whose timestamp is older than the database's NOW() minus the retention, the cutoff read ONCE per tick from the database's clock — the clock timestamp was written with; a replica whose JVM clock runs fast deletes nothing early. One cutoff for every event. §10.1 draws no line between security events (auth.*, workspace.*, the promotion and datasource events) and the per-call rows (mcp.tool.called, endpoint.served), so the job draws none either and carries no event list: a deployment that must keep its security trail longer raises the retention for the whole log, and the 30-day floor — not an exemption list — is what protects the trail from a misconfiguration. Rows go in bounded batches (at most 5,000 a statement, each its own short transaction; at most 50 statements or 2 seconds a tick), oldest first through idx_audit_timestamp; a tick that stops with rows left WARNs and the next continues (Metadata DB §8.2, Observability §7).
What is never deleted. A row younger than the cutoff, and nothing outside audit_log: keys and identities are §11C's, executions and their events their own retention's. The job writes no audit row of its own — its record is its log line and its meter (an audit row per tick would be a row a later tick deletes).
The read-backs — what a cutoff can and cannot change. Four readers decide something from audit rows, and every one asks whether a row EXISTS (EXISTS, max(timestamp), DISTINCT), so a deleted row can only turn a yes into a no: retention can refuse what was allowed, never allow what was refused.
McpCallAudit.calledByKey—executions_cancel's same-credential rule. It gates only a RUNNING execution, and an execution runs at mostdatapipelines.executor.execution-timeout-seconds(default ten minutes) before it ends or the crash sweep aborts it: nothing it reads is near a 30-day cutoff.EndpointServeAudit.servedByKey— §7.7's endpoint-key visibility arm. It can answer yes only for a run served before V34 (#215), whoseexecuted_byis the key's creator; a run served since carries the key's own identity and is visible without it. An endpoint key loses sight of a pre-V34 run once that run's serve row ages out.McpToolLearnings.columnsRead— check A (pipeline.validation.table_not_learned). A key's learning of a table lasts as long as itsdatasources_get_columnsrow: past the retention the key reads the columns again (the refusal names the one call that clears it).McpToolLearnings.lastRenderAt— check B (pipeline.execution.template_unrendered). A render older than the cutoff no longer counts, so a draft whose last edit and last render both predate it is rendered again before it runs.
And the keys purge. An audit row naming a revoked key's identity (audit_log.user_id, a foreign key) is one of the references that keeps the key and its identity (§11C); once the last such row ages out, the next tick's purge may delete the pair. audit_log.key_id is deliberately not a foreign key (Metadata DB §4.3), so a surviving row may name a key the purge has removed — the trail outliving its key is the design.
11. Configuration
OIDC providers are configured as a generic list. The deployment chooses which provider(s) to enable — any OIDC-compliant provider works.
11.1 OIDC provider configuration
Providers are defined in application.yml (structural config) with secrets referenced from env vars:
datapipelines:
auth:
oidc:
providers:
- name: google # registration ID
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
issuer-uri: https://accounts.google.com
display-name: "Sign in with Google"
# picture-hosts: lh3.googleusercontent.com # #197 — see below; empty = no avatar fetch
# trust-email-without-verified-claim: true # default false (§4.2/#187) — only for
# # an IdP that never emits the claim
# GitHub — OAuth 2.0 WITHOUT OpenID Connect (§5.1, #655): the endpoints are named,
# nothing is discovered; the address is the verified PRIMARY row of emails-uri.
- name: github
protocol: oauth2
client-id: ${GITHUB_CLIENT_ID:}
client-secret: ${GITHUB_CLIENT_SECRET:}
authorization-uri: https://github.com/login/oauth/authorize
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
user-name-attribute: id
scopes: [read:user, user:email]
emails-uri: https://api.github.com/user/emails
display-name: "Sign in with GitHub"
picture-hosts: avatars.githubusercontent.com
# Microsoft (Entra ID) — SINGLE-TENANT only in v1: the issuer-uri MUST name
# your tenant. Multi-tenant `/common` is NOT supported: its discovery metadata
# carries the `{tenantid}` issuer template, which the OIDC discovery client
# (Nimbus, via Spring's ClientRegistrations.fromIssuerLocation) refuses, so a
# `/common` issuer fails startup — no request flow can ever succeed behind it.
# - name: microsoft
# client-id: ${MICROSOFT_CLIENT_ID}
# client-secret: ${MICROSOFT_CLIENT_SECRET}
# issuer-uri: https://login.microsoftonline.com/{tenant-id}/v2.0
# display-name: "Sign in with Microsoft"
# Add any OIDC provider the company uses:
# - name: okta
# client-id: ${OKTA_CLIENT_ID}
# client-secret: ${OKTA_CLIENT_SECRET}
# issuer-uri: https://company.okta.com
# display-name: "Sign in with Okta"
#
# - name: keycloak
# client-id: ${KEYCLOAK_CLIENT_ID}
# client-secret: ${KEYCLOAK_CLIENT_SECRET}
# issuer-uri: https://sso.company.com/realms/main
# display-name: "Company SSO"
jwt:
secret: ${DATAPIPELINES_JWT_SECRET}
ttl-hours: 8
algorithm: HS256
allowlist:
domains: ${DATAPIPELINES_AUTH_ALLOWLIST_DOMAINS:} # comma-separated, empty = open
api-keys:
cache-ttl-seconds: 60
datapipelines.auth.oidc.provider-read-timeout-seconds— shared by all providers, default10; 1–2147483 seconds for connect and read timeouts on token, OAuth2 user-info, emails, OIDC user-info calls and the ID-token decoder's JWKS fetch (§5.5, #686). Startup discovery keeps Spring's separate 30 s bound.
Per provider, three values are required:
client-id— from the OIDC provider's app registration.client-secret— from the provider's app registration.issuer-uri— the provider's OIDC issuer URL. Triggers auto-discovery of all endpoints. (oidcentries only; refused on anoauth2entry.)
The protocol and the oauth2 keys (#655, §5.1):
protocol— optional,oidc(default) oroauth2; anything else refuses startup.authorization-uri,token-uri,user-info-uri— required foroauth2, empty by default;https://(plainhttp://only forlocalhost/127.0.0.1).user-name-attribute— required foroauth2, empty by default: the user-info attribute that is the stable subject.scopes— required foroauth2, empty list by default; neveropenid.emails-uri— optional, unset by default; when set, the verified primary row is the only source of the address and the trust knob is not consulted (§5.5).
Two further values:
name— required. Registration ID used in URLs (/oauth2/authorization/{name}) and stored inusers.provider. Lowercase[a-z0-9-]+. (The §5.2 bean uses it directly; there is no derivation fallback.)display-name— optional. Text shown on the login button; defaults toname.
One further value (#197):
picture-hosts— optional, comma-separated bare hostnames, default empty. The allowlist the avatar proxy (GET /avatar, §7.6profile.read) may fetch the storedusers.profile_picture_urlfrom, so the CSP'simg-srccan stay'self' data:and the browser never loads an image from an identity provider's host. The host is NOT derivable fromissuer-uri(Google issues fromaccounts.google.com, serves pictures fromlh3.googleusercontent.com; Entra's pictures live on Microsoft Graph; a self-hosted Keycloak's sit wherever its operator mapped them), so the operator names the hosts the deployment trusts. Empty (the default) = the proxy fetches nothing and every avatar falls back to initials — exactly the rendering a login without apictureclaim already gets. A non-hostname entry (scheme, port, path, userinfo or wildcard) refuses startup. Only providers with a non-blankclient-idcontribute; a dormant entry's hosts are validated for shape but never allowlisted (#651). The fetch itself is fenced: the caller's OWN row only, http/https, no userinfo component, redirects never followed, a 1 MiB cap on the body, and a small private TTL cache. Three rules narrow it further (#246). A stored picture URL that names a port — any explicit port, the scheme's default included — is refused before any connection is made: the allowlist names hosts, never ports, so a port would steer the fetch to any service on a trusted host. Only raster images are served —image/png,image/jpeg,image/gifandimage/webp, the type passed through as the provider sent it — and anything else, SVG included (a document format that can carry script), is refused. The whole fetch — connect, headers and body — completes within 5 s or is abandoned and its connection closed, so a provider that dribbles or stalls its body holds a request thread no longer than that and the avatar falls back to initials. A refused or failed fetch is itself remembered (#254): it enters the same bounded TTL cache as a negative entry, so a broken picture host costs ONE provider fetch per user per TTL instead of one per page view, the 404 answers withCache-Control: private, max-age=<ttl seconds>so the browser stops asking while the refusal stands, and a later successful fetch replaces the negative entry.
11.2 Common provider issuer URIs (reference)
| Provider | issuer-uri |
|---|---|
https://accounts.google.com |
|
| GitHub | none — not OIDC; a protocol: oauth2 entry (§5.1, §11.1) |
| Microsoft (single tenant) | https://login.microsoftonline.com/{tenant-id}/v2.0 |
/common — unsupported in v1 (§11.1: the {tenantid} discovery metadata is refused at startup) |
|
| Okta | https://{your-org}.okta.com |
| Auth0 | https://{your-tenant}.auth0.com |
| Keycloak | https://{host}/realms/{realm} |
| AWS Cognito | https://cognito-idp.{region}.amazonaws.com/{user-pool-id} |
| Ping Identity | https://{host}/as |
11.3 Required environment variables
The env vars depend on which providers the deployment configures. The structural config (which providers, issuer URIs) is in application.yml; secrets are in env vars:
| Variable | Required? | Description |
|---|---|---|
DATAPIPELINES_JWT_SECRET |
yes | Internal JWT signing secret (≥ 32 bytes random, base64) |
DATAPIPELINES_AUTH_BASE_URL |
yes (when any OIDC provider is configured) | The deployment's exact external origin, e.g. https://dp.example.com — the absolute OIDC redirect URI is built from it (§5.2). Application startup fails if unset while providers are configured (Configuration §3.4 datapipelines.auth.base-url). |
DATAPIPELINES_AUTH_BOOTSTRAP_ADMIN_EMAIL |
no | Bootstrap the first admin (§4.4). Optional; when set, the OIDC user with this exact (lowercased) email is granted admin at row creation. |
| Provider-specific client-id/secret env vars | yes | One pair per configured provider (names defined in application.yml) |
For example, a deployment using Google + Okta would set:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
OKTA_CLIENT_ID=...
OKTA_CLIENT_SECRET=...
DATAPIPELINES_JWT_SECRET=...
11.4 API key validation cache
Validated API keys, user liveness and memberships are cached in-memory per instance for datapipelines.auth.api-keys.cache-ttl-seconds (default 60). Revocation, deactivation and a role change invalidate the local cache immediately and take effect elsewhere within the TTL — 60 s by default (#215 B5, record amendment A9: this is the freshness contract; no eviction bus, no Redis stamp).
Running executions and open streams (security-assurance record P4, owner ruling 2026-09-24, #230). The bound above governs every NEW request. An execution that is already running when its caller's authority is revoked keeps that authority to completion, under the executor's own drain and cancel rules; revocation never cancels it. An open SSE stream — the live execute stream, the idempotent-retry follow and the replay — re-judges its subscriber before EVERY write (event or heartbeat), with the same predicate a new request would run: liveness through the cache above, the live is_admin snapshot, the workspace resolution then execution.read and the execution's own visibility. The workspace re-judged is the one the stream OPENED in (#263): the filter stamps only the RESOLVED context — a DP-Workspace header switch included — so the re-judgement re-resolves that name, not the JWT claim, and a stream opened under a header switch keeps reading while that workspace's membership holds; its revocation still cuts, because the resolution re-checks the membership through the same cache. The token's expiry is judged too (#263): the subscriber carries the validated token's exp (read at the principal's construction from the claims the credential filter already validated), and a write at or past it ends the stream at that write — the same cut a fresh request meets at the credential filter. The re-check costs nothing within the TTL and sees a mutation on the instance that performed it at once (the cache evictions), so every cut lands at the stream's next write: the stream ends there, carrying a final revoked SSE comment — the same static string for a revocation and an expiry — and never the event, while the execution itself runs to completion — a revoked or expired READER never cancels the run it was watching. The duration timer records the credential fact (close_reason, Observability §4.1): revoked for a standing revocation, expired for a token past its exp (#263).
11.5 Other auth configuration keys
All auth config keys (allowlist domains, JWT TTL, cache TTL, login rate limit) are defined in Configuration §3.4 — the single config authority. This spec does not restate names or defaults.
The login limiter's window is per INSTANCE (096 §F, review finding F8). It is an in-memory
fixed one-minute window per client address, held in each replica's own heap — so N replicas
behind a load balancer give a client that lands on all of them an effective budget of
N × login-per-minute. Deliberate: this is a brute-force damper, not a distributed quota,
and a shared store on the login path costs more than it buys.
It also fails OPEN at its ceiling: the table is bounded at 10,000 tracked clients, and once
it is full (after sweeping rolled-over windows) a new client is admitted UNMETERED rather than
the map being grown, so a spoofed-source-IP flood can never exhaust the heap. That is the
owner's posture and it is unchanged; what 096 added is that each such admission increments
datapipelines.auth.login_rate_limit.saturated (Observability §4.1),
so saturation is a rate to alert on rather than a WARN line lost inside the flood that caused
it. The per-user API/MCP limiter (Configuration §3.7) is
a different budget with a different key and fails CLOSED — the two are not interchangeable.
11A. Roles
Permission lives in the workspace membership, not on the user (RBAC design D-R1, ratified 2026-09-10; roles design D1, ratified 2026-09-20). A person is a viewer in one workspace and an author in another; there is no global "author" any more. The one global authority left is users.is_admin, which means super admin — the instance.
A membership row (workspace_members, metadata-db §4.12) carries exactly one role — role, one of viewer, author, promoter, workspace_admin (V29; WorkspaceRole). V23 had split the role into three additive booleans so that "an author who also releases" could be one row; the 2026-09-20 rulings took release away from the promoter and made it an ops role that authors nothing, so the combination the booleans existed for no longer exists and the row is one value again. The migration's precedence for old rows: admin → workspace_admin, else promoter → promoter, else author → author, else viewer. An invitation (§4.6) carries the same one role (D20).
Vocabulary (D21). A role is what a member holds. A permission is an action a role may perform — a ROW of the §7.6 matrix (Permission in code: the 65 <functionality>.<permission> catalog rows, pipeline.read …, each held by the roles its §7.6 row admits). "The author role has the permission to release" is a sentence about a row; a permission is never a free string, and nothing in the product grants or stores one.
The five roles, as the record's §2 ratifies them (the row-by-row table is §7.6; this is the shape):
| Role | Holds | Does NOT hold |
|---|---|---|
| viewer (D3) | reads everything in the workspace; executes pipelines, cancels and reads its OWN runs and results; tests a datasource connection; introspects schemas | authoring, release, promotion, members |
| author (D4, D8, D9) | the viewer's rows + create/edit/discard/restore/purge pipelines and templates, release, switch the served version, publish endpoints, register lake tables, record/retire learned facts, read the promotion page | promote, members, workspace datasources |
| promoter (D5) | reads datasources; reads pipelines, templates and endpoints through the lens (§11A.1: released, and newer than the promotion target's — what is promotable, and nothing else); introspects schemas; reads the promotion page; promotes | execute, cancel, execution and result reads, connection test, authoring, release, switch, members. Promoters are ops people. |
| workspace admin (D6) | everything in the workspace: every row above plus members, roles and invitations, workspace-bound datasources, the workspaces page, the audit trail (D12 — no surface yet), and promotion | the instance verbs |
| super admin (D7) | everything everywhere: every workspace's rows without a membership (audited acting_via=super_admin), global datasources and grants, workspaces, users |
— |
Reads are cumulative — viewer ⊂ author ⊂ workspace admin ⊂ super admin — and the promoter is NOT on that chain: it reads datasources as a viewer does, reads pipelines, templates and endpoints through the lens (§11A.1), reads no executions, and adds the promote verb.
Two rows changed hands on 2026-09-20 and are worth naming: release is the author's (it was the promoter's — "the author releases, the promoter promotes", D8), and the workspaces page is the workspace admin's (D13) while the switcher in the chrome stays every member's. Executions became own-or-admin on every surface (D11): a member's list is their own runs, a workspace admin's is the workspace's, a promoter's does not exist; an endpoint-key run is nobody's own and lists for admins only (executed_by_key_kind, metadata-db §4.6).
11A.1 The 404 rule
A workspace you cannot reach does not exist. Non-member, unknown name, and deactivated are ONE answer — 404 workspace.not_found, the same body a genuinely missing row produces — on every surface: REST, MCP, UI pages and htmx partials. Never 403 for a foreign id. The same rule covers every entity inside a workspace: an id or name from another workspace is not-found, not forbidden, because a 403 would turn a flat namespace into an enumeration oracle one request at a time.
The rule is mechanical, not a convention: every repository read that a caller-supplied id or name can reach carries the workspace predicate IN ITS SQL, and WorkspaceIsolationSweepTest walks every REST route and every MCP tool with a foreign workspace's identifiers and asserts the not-found answer on every one.
The lens clause (178, roles design §3.1). For a principal whose role in the active workspace is promoter (and not a super admin), the same rule covers every pipeline and template the promotion rule does not list: an object is VISIBLE iff it has a current RELEASED version and that version is newer than the promotion target's inventory entry for the same name — Versioning §10.2's rule, the target's absence counting as version 0, the same content hash counting as "nothing to push" — and an endpoint is visible iff its pipeline is. Everything else — a draft, an already-promoted object, one the target serves at a higher version — is ABSENT for that principal: lists, levels, counts, search, the rail badges, the resource catalogue and used_by leave it out, and a get by id or name answers exactly the not-found an id from another workspace gets. A visible object's pending DRAFT is invisible too ("it should hide draft"): the working version a promoter reads is the current RELEASE, the draft pointer and the list badge are absent, DRAFT rows leave the version list, and an explicit DRAFT version number is not-found. The rule covers VERSION CONTENT, not only names (178b): the node-SQL partial renders the release's node with the pinned template read through the lens (a hidden or DRAFT template version is "template not found"), the checks reads refuse a DRAFT version exactly as an absent one (PipelineService.findExecutable under the lens), and used_by / the used-by pane list RELEASED pins of RELEASED template versions only — a draft's number or status never travels. The edge of the rule: a bundle is the artifact's pins (owner ruling 2026-10-02, #344). What an artifact PINS is part of the artifact the caller may already see: an export bundle (the pipeline, parameter-set, visualization and dashboard export routes) and a promotion batch carry the templates the artifact pins — and a dashboard's pinned visualizations with their templates — at the exact pinned versions, read WITHOUT the lens. A pinned template the lens hides rides with the artifact: it is never dropped and never a reason to refuse, and only Versioning §10.4's skip rule (already on the target at the same version and hash) leaves one out of a batch. The lens governs the ROOT — a hidden or draft root exports as an absent one, a hidden promotion root refuses — and every direct template read. PromoterLensSweepTest exports a visible pipeline that pins a hidden template and reads that template directly in one case (the bundle carries it; the direct read is the absent 404), and each family's transfer test is red if its bundle applies the lens. The rule is applied ONCE per request (PromotableView, the same computation the promotion page lists from) and passed as a value into every read (PipelineService, TemplateService, EndpointPublishService) — never a thread-local, because an MCP tool runs where no security context exists. The higher environment is read through a per-workspace cache (inventory-cache-ttl-seconds, Configuration §3.19); when it cannot be read the lens FAILS CLOSED — the promoter sees nothing, the list screens say why in the promotion page's words, a read is never a 502 — and pipeline.promotion.lens_unavailable is logged once per window. Both credential kinds are lensed alike: a promoter's API key is a promoter. PromoterLensSweepTest walks every promoter-reachable GET route and read tool with a hidden and an absent identifier and demands identical answers, exact lists, a leak-free body, a viewer control that never touches the target, and the fail-closed walk.
workspace.membership_required (403) survives in exactly one place: a principal with ZERO memberships, which addressed no workspace at all and so has no name to protect. Two permissions are judged WITHOUT a workspace context for a session: workspace.read (the page) and workspace.switch (the REST list-own, §17.1) — "which workspaces do I belong to" is meaningful when the answer is none, and it is how a zero-membership person reaches the no-workspace page (UI §4.13) instead of a JSON 404. A key never gets that exception: a key with no context is a key whose workspace is gone, and it stays the 404.
Dashboard delegated runtime reads (#343). dashboard.execute authorizes the delegated evaluation of a dashboard's pinned parameter set under D50, even when the caller does not hold parameter_set.evaluate. The runtime returns the evaluator's existing bounded response unchanged, including its selector diagnostics; this is not a direct parameter-set read grant. Refresh source frames include execution links only when the caller's current principal also holds execution.read.
A second null-context exception stands beside it (#113): a super admin session keeps the INSTANCE verbs — the operations whose permission is super_admin (create / deactivate / reactivate / delete a workspace, user administration, instance datasources and their grants) — when NO workspace is reachable at all. Those operations are instance-level by construction: none of them reads or writes a workspace's content, and refusing them stranded the one principal who could repair an empty deployment (deactivate the last active workspace, and even the reactivate verb answered workspace.not_found). The evidence is the user row's is_admin, re-read per request through the auth cache — not a context the request does not have. Every workspace-scoped operation stays the 404 for that principal, and a key never gets this exception either: no key is ever a super admin (B1, §7.5).
11A.2 Super admins
A super admin is an implicit member of every workspace (D-R8) and resolves any workspace through the SAME path everyone else does — no bypass branch, so there is no second code path to keep correct. Every action they take in a workspace where they hold no explicit membership is audited as auth.super_admin_acting with acting_via=super_admin, reads included (§10.1 says why).
A deactivated workspace is not selectable by a super admin either: §11A.3's first effect has no exception, and a super admin who needs to act inside one reactivates it first — an audited, reversible step.
11A.3 Deactivation
Deactivate, never delete (D-R10, D15). Deactivation — of a USER (users.is_active, §4.2) or of a WORKSPACE (workspaces.deactivated_at) — purges nothing and revokes nothing; it is reversible, and reactivation restores every credential. What it does is make the principal or the workspace served nothing, through ONE predicate.
The predicate. PrincipalLiveness answers "user active ∧ (pinned workspace active, when the credential pins one)", both reads through the §11.4 liveness cache (so a valid request costs zero extra queries, and a deactivation takes effect on the instance that performed it at once and everywhere else within one TTL, ~60 s by default). It is judged where each credential BECOMES a principal — the earliest point a refusal can happen, and the one every surface passes through because the security chain is global, so no boundary downstream restates it and no new surface can forget it:
| where | credential | deactivated USER | deactivated WORKSPACE |
|---|---|---|---|
JwtAuthenticationFilter |
session | auth.principal_deactivated 401 on the API; a page navigation is sent to /login?error=inactive, cookie cleared either way |
a session never resolves one: the stamped claim falls through to another active membership (§5), DP-Workspace naming it is workspace.not_found 404 |
ApiKeyService.validate — /mcp, published endpoints |
MCP key, endpoint key |
auth.principal_deactivated 401 — the key's own identity for every kind, and for an mcp key its CREATOR besides (A20: a deactivated person's mcp keys are refused until reactivation; endpoint/server keys stay creator-independent, PK2) |
auth.key_workspace_inactive 404 (the pin) |
ApiKeyService.validateServerKey — /api/v1/promotion/** |
server key |
auth.promotion.key_invalid 401 — the peer's ONE answer, whatever failed |
auth.promotion.key_invalid 401 (the pin, closed in 180) |
PublishedEndpointServeService |
any key, the ENDPOINT's own workspace | — | the unknown-path 404, byte-identical, before the pipeline is read |
The USER case has one code on every surface but the promotion peer. The WORKSPACE case keeps the older 404 rule (§11A.1): a deactivated workspace is indistinguishable from one that does not exist — auth.key_workspace_inactive stays distinct only because a key's holder is a member by construction and already knows the pin.
A deactivated workspace's five effects, all consequences of readers consulting Workspace.isActive (or its cached form, the predicate):
- it cannot be selected — the switcher hides it, and
DP-Workspacenaming it answers 404 like a non-membership; - its published endpoints are unknown paths — to every caller, not only to keys pinned there;
- API keys pinned to it are refused with
auth.key_workspace_inactive(404), server keys with the promotion peer's one answer; WorkspaceLivenessanswers false for it — the question a scheduler will ask (no consumer exists yet; the interface is the hook);- a super admin's listing shows it greyed with the date.
To a MEMBER a deactivated workspace is indistinguishable from one that never existed, so deactivation is not a signal anybody can read. Reactivation is a super-admin verb and is audited. DeactivationSweepTest proves the whole table on the live server: every route and every tool, for a deactivated session and for keys with a deactivated identity, pin or — mcp keys only (A20) — deactivated creator, plus the differential (deactivated vs unknown workspace: same body on every route) and the promotion peer's one body.
There is deliberately NO last-active-workspace guard: decommissioning the final workspace is a legitimate operator act, and it is recoverable — with zero active workspaces the super admin's session resolves no workspace at all, and §11A.1's second null-context exception (#113) keeps the instance verbs, reactivation included, available to exactly that principal.
11A.4 Keys
Keys v2 (#233, A13–A19; the record §3 as amended): a key is a robot member of one workspace — kind, workspace, identity, role, created by. The mcp key carries the member role CHOSEN AT CREATION under the subset rule and acts as its own identity (§4.7, §7.5) — nothing is derived from a membership any more, so a member's role change does not affect their existing keys at all (C2 retired with the derivation). endpoint (api_caller) and server (promotion_receiver) keys are unchanged in shape (PK2). No key is ever a super admin (B1). On every request, inside the validation-cache TTL (60 s by default): the key is live, its identity is active, and its pinned workspace is active.
Lifecycle (A15/A17): nothing is minted at sign-in; creation is the Keys page (or the REST twin) alone; revocation is the end state — the application never hard-deletes a key or an identity. The retention sweep's purge (§11C) deletes a revoked, unbound key and its deactivated identity only once no FK column references them (B5); removing a member revokes every key they created in that workspace (B6).
11C. The keys purge (keys v2 A17/B5)
Revocation is the end state; the purge is the only delete. The application never hard-deletes a key or an identity. As a step of the retention sweep (the hourly job that also retains execution events and — its last step since #310 — the audit log, RetentionSchedulingConfiguration), KeyRetentionPurge deletes:
- a REVOKED key that no
endpoint_key_bindingsrow names, and whose identity nothing references — the two are one unit: an execution'sexecuted_by, an audit row'suser_id, a membership, an invitation, or a surviving key row keeps the pair alive (B5's falsification: a revoked key with one live execution STAYS; one with none goes); - then the deactivated
serviceidentities that nothing references at all.
An audit row keeps its identity only while it lives: the audit-log retention (§10.3) deletes rows past datapipelines.audit.retention-days, after which the next tick's purge may take a revoked key whose only references were those rows.
The "nothing references" predicate is DERIVED from the live schema (information_schema, every FK referencing users(id)), so a migration that adds a new FK automatically blocks the purge — the purge never disables a constraint and never guesses. A test enumerates the FKs independently and asserts the purge respects exactly that list; KeysV2MigrationTest and the retention tests prove both directions.
12. Implementation Notes
12.1 Where this lives
auth Gradle module:
co.datapipelines.auth.Userdata classco.datapipelines.auth.ApiKeydata classco.datapipelines.auth.Permission,RolePermissions,KeyRole,UserKind— the catalog, the one role table, the key roles, what ausersrow isco.datapipelines.auth.AuthenticatedPrincipaldata classco.datapipelines.auth.JwtService— issue + validate internal JWTsco.datapipelines.auth.ApiKeyService— issue + validate + revoke API keysco.datapipelines.auth.UserService— find-or-create by OIDC identityco.datapipelines.auth.OidcSuccessHandler— OIDC login callbackco.datapipelines.auth.JwtAuthenticationFilter— cookie → JWT → principalco.datapipelines.auth.ApiKeyFilter— header → key → principalco.datapipelines.auth.ScopeInterceptor—@RequiredScopeenforcementco.datapipelines.auth.AuditLoggerco.datapipelines.auth.SecurityConfig— Spring Security wiring
12.2 Dependencies
- Spring Security (OAuth2 client, CSRF, filter chain).
- Spring Security OAuth2Jose (JWT validation for OIDC ID tokens).
de.mkammerer:argon2-jvm— API key hashing.io.jsonwebtoken:jjwt— internal JWT issue/validate.
12.3 Persistence (JDBC)
All auth tables accessed via JdbcTemplate + RowMapper. No JPA. See Metadata DB spec for table definitions.
13. Security Checklist
- [ ] OIDC client secrets stored in env vars or secret manager, not in source.
- [ ]
DATAPIPELINES_JWT_SECRETis high-entropy (≥ 32 bytes random). - [ ] Email domain allowlist configured for internal-only deployments.
- [ ] API keys hashed with Argon2id, never stored plaintext.
- [ ] Session cookies
HttpOnly,Secure,SameSite=Lax(§5.5 — Strict is unusable with an IdP redirect chain; CSRF, not SameSite, is the control). - [ ] CSRF protection on all state-changing UI endpoints.
- [ ] No
/actuator/*path reachable without auth on the application port;/actuator/prometheusserved only on the separate management port (Observability §4.2). Root/health,/ready,/infoare the only public probes. - [ ] All auth events audited.
- [ ] All traffic over TLS.
- [ ] OIDC redirect URI locked to the deployment's exact domain (not
localhostin production).
14. What Changed from v1.0
| v1.0 (local auth) | v2.0 (OIDC) |
|---|---|
| Local username + password | Google/Microsoft OIDC |
users.password_hash (Argon2id) |
Removed — no passwords stored |
POST /auth/login (JSON body) |
GET /oauth2/authorization/google (browser redirect) |
| No identity provider | Google + Microsoft OIDC providers |
users.provider, users.provider_subject |
New fields for OIDC identity |
| Email domain allowlist | New — restricts who can log in |
| JWT, API keys, scopes, audit log | Unchanged |
Postscript (v2.10): optional local accounts returned in §5A — narrower than v1.0's: admin-created only (no self-registration), Argon2id via the API-key SecretHasher, per-account lockout, forced rotation of seeded/reset credentials, off by default.
15. Open Questions / Future
- Additional OIDC providers (Okta, Auth0) — easy to add; just another registration with an
issuer-uri. GitHub is OAuth 2.0, not OIDC — shipped as aprotocol: oauth2entry (#655, §5.1). - SAML — for enterprises that require SAML instead of OIDC. Spring Security SAML extension.
- Group/role sync from provider — map OIDC groups to internal roles automatically.
- Per-datasource ACLs — fine-grained access beyond roles.
- Service accounts — non-human principals for CI/CD.
- MFA — if provider enforces it (Google/Microsoft MFA is provider-side, transparent to us).
Appendix A: Change Log
| Date | Version | Author | Change |
|---|---|---|---|
| 2026-10-25 | v3.60 | #704 audit strict in a transaction | §10.1A: a store failure of an audit INSERT inside the caller's transaction propagates — the transaction rolls back and the request is refused with the generic 500 envelope (no new code); outside a transaction the #266b swallow stays. The transactional table row and the Failures paragraph are split by direction; the strict release rows are the same rule. No route, row, role cell or configuration key change. |
| 2026-10-24 | v3.59 | #517 release dependencies, core (S1) | §7.6: the pipeline.release cell — a release also flips every dependency the plan selects, each under its own family's *.release. §10.1A: the release rows are strict: written by StrictReleaseAuditSink in the release transaction, never caught; unavailable audit storage refuses the release. |
| 2026-10-25 | v3.58 | 442 (#442) integration | §7.6: the session-only resend action uses SCHEDULE_UPDATE; §8.6.2: the schedule notifier joins the recurring-job inventory. |
| 2026-10-24 | v3.57 | #686 JWKS fetch joins the provider read bound | §5.5: the ID-token decoder's JWKS fetch is bounded by datapipelines.auth.oidc.provider-read-timeout-seconds through a JwtDecoderFactory bean that re-creates Spring's decoder with the bounded client — the same algorithm resolution, validators and claim-type conversion, one decoder per registration; a stalled JWKS read fails the decode as invalid_id_token and reaches the failure path. §11's key row names the JWKS fetch. No key added, no default change, no route, row or role cell change. |
| 2026-10-22 | v3.56 | #651 enabled-provider avatar hosts | §11.1's picture-hosts allowlist includes only providers with a non-blank client-id, using the same predicate as sign-in registration. Dormant entries are still validated for shape at startup but contribute no hosts. No key, default, route or permission changes. |
| 2026-10-22 | v3.55 | #682 | Shared provider read timeout for token exchange, OAuth2 user-info/emails and OIDC user-info; JWKS exception documented (§5.5, §11.1). |
| 2026-10-21 | v3.54 | #655 GitHub sign-in (answers #154 part 2) | GitHub is an OAuth 2.0 provider, not an OIDC one. §5.1: an entry's protocol is oidc (default) or oauth2; an oauth2 entry names authorization-uri, token-uri, user-info-uri, user-name-attribute, scopes and optionally emails-uri, is built with no HTTP at startup, and is refused with an issuer-uri, an openid scope or a non-https endpoint (loopback http excepted). §5.2: the protocol branch. §5.5: identity per protocol; with emails-uri only the verified PRIMARY row is accepted (missing_email / email_not_verified), the user-info email is discarded and the trust knob is not consulted; provider-side failures are now audited auth.login.oidc_error with reason and a configured provider; the access token is no longer retained in memory (a no-op authorized-client repository). §4.2 step 1, §6.2, §10's two oidc_error descriptions, §11.1 (the github block, the seven keys), §11.2 (GitHub row) and §15 reworded. §7.6: no row, route or role cell changes — /oauth2/** and /login/** already cover the flow. |
| 2026-10-21 | v3.53 | #640 the script-node rename (script-node-rename lane, V50, owner ruling 2026-10-20) | §7.6: no row or role cell changes — four rows' prose renames. The template.read / template.create / template.update / template.evaluate Surfaces cells now name the script face's routes /partials/templates/script-face/… and say "the script face" and "a script template" where they said transform; the operations, the scope constants and every role cell are unchanged. A rename only: no behaviour, route, permission or key change beyond the names, and no alias (the ruling). The visualization block's transform key, transform.template / transform.inputs and the CSS/SVG sense keep their word. |
| 2026-10-19 | v3.52 | #585 the API home — renumbered at merge after #584's v3.51 | §7.6: no new row, no role cell change — one route token joins the endpoint.read Surfaces cell. GET /overview/api-console is the API menu's destination: endpoint counts and the endpoints-by-state chart over the console's own two reads (EndpointPublishService.list and listLegacy, behind the promoter lens — the row's lens cells unchanged), key counts and the keys-by-kind chart over the Keys page's own narrowing (the whole workspace with api_key.read, otherwise the caller's own mcp keys — mcp_key.own / api_key.read, unchanged), the MCP connection card (the console's, now one shared fragment: the key PLACEHOLDER, never a key) and a starter prompt that stops at publishing. The console keeps GET /api-console as the home's View all. No POST, no MCP tool, no key route, no migration. |
| 2026-10-19 | v3.51 | #584 the Schedules home | §7.6: no new row, no role cell change — one route token joins the schedule.read Surfaces cell. GET /overview/schedules reads the viewer's schedules — four counts, By condition, Last run, Up next and Recently changed — through ONE ScheduleService.summary, which is §20.1's own lensed list at the workspace cap, so a promoter's numbers are the admitted ones (the row's lens cells unchanged) and never the unlensed live count. No recipient address or block reason is read onto the page (schedule.update's, and a pipeline error's). No POST, no REST route, no MCP tool, no migration. |
| 2026-10-18 | v3.50 | #583 the Datasources family overview | §7.6: no new row, no role cell change — one route token joins the datasource.read Surfaces cell. GET /overview/datasources reads the family's VISIBLE-SET inventory — counts, connection health, by dialect — and Recently added to this workspace (grants-ordered): a datasource has no lens, so every member sees the same granted set (§4.9 D-R7 visibility), and the row's five ✓ cells are unchanged. The overview's dialect bars link the catalog's existing ?dialect= filter (the register-row precedent, #186); no POST, no MCP tool, no migration. |
| 2026-10-17 | v3.49 | #556 the family overview pages | §7.6: no new row, no role cell change — five route tokens join five read rows' Surfaces cells. GET /overview/pipelines (pipeline.read), GET /overview/templates (template.read), GET /overview/dashboards (dashboard.read), GET /overview/visualizations (visualization.read) and GET /overview/parameter-sets (parameter_set.read): each family's overview reads that family's inventory (and, for pipelines, Recently updated) through the caller's lens — a promoter's numbers are the admitted ones, so the role cells are the rows' existing ones. The pipelines catalog's optional lifecycle filter is a filter on the existing GET /pipelines read, decided over the versions the lens admits. No POST, no MCP tool, no migration. |
| 2026-10-16 | v3.48 | 467 (#467) the over-cap screenshot refusal reaches the client | §7.6: no new row, no role or permission change. The requestBodyCapFilterRegistration row's read bound names the screenshot route's bounded drain (≤ 6 MiB of a refused declaration, before authentication), the per-request cost an unauthenticated caller can impose there |
| 2026-10-15 | v3.47 | #466 (lane 466b) the sidebar's server-rendered tree removed — renumbered at merge after #515's v3.46 | §7.6: no new row, no role cell change — two route tokens leave the Surfaces cells with their handlers. visualization.read drops GET /partials/visualizations/tree and dashboard.read drops GET /partials/dashboards/tree (owner ruling 2026-10-10, option A): the sidebar has read the REST tree routes (GET /api/v1/{family}/tree, …/tree/search, already on these rows) since #460, and nothing requested the partials. The prose of both rows and of parameter_set.read stops naming the sidebar's partial branch; GET /partials/parameter-sets/tree STAYS on parameter_set.read — the /parameter-sets catalog requests it — and renders the catalog list alone. EntryInventoryE2eTest's placed-route check reads the two tokens' removal (red with the handlers gone and the tokens kept); RoleWalkE2eTest walks the same rows. |
| 2026-10-15 | v3.46 | #515 | §8 sketch and the class KDoc name all three CSRF exemptions; parity test. |
| 2026-10-09 | v3.45 | 473 merge follow-up (#495) | §7.6: no new row, no cell change, no route change — dashboard.read's prose names the board page's load-time render of the caller's own refresh history (#473); the Surfaces lists are untouched, so RoleWalkE2eTest and RoleMatrixDocE2e read the same cells. |
| 2026-10-08 | v3.44 | 419 merge follow-up — the issue's acceptance row the lane's fence kept out | §7.6: no new row, no cell change — parameter_set.read's prose names the flat listing's optional q name search, on the REST listing and on the MCP list tool (#419): a filter on the existing read, lensed in the service (a promoter's q matches only the sets the promoter lens admits), with no new route, tool or permission; the Surfaces routes and tools are unchanged, so RoleWalkE2eTest and ScopeMatrixSpecDriftTest read the same cell. |
| 2026-10-06 | v3.43 | 460 merge follow-up — the row #460 changed without one | §7.6: no new row, no new permission — the five *.read Surfaces cells (pipeline.read, template.read, dashboard.read, visualization.read, parameter_set.read) each gain GET /api/v1/<family>/tree and GET /api/v1/<family>/tree/search: session-only navigation metadata (a key is refused by kind; the handler also refuses any non-session principal with auth.session.required), the workspace from the principal, the promoter lens applied before folders and ancestors are derived; RoleWalkE2eTest walks all ten. |
| 2026-10-03 | v3.42 | 442a (#442) — renumbered at merge after 398b's v3.41 | Saved schedule notification settings: recipients and five event choices, validated and shown by role; omitted PUT settings preserved; mail remains off pending part b. |
| 2026-10-03 | v3.41 | #398 the templates workspace — renumbered at merge after 404's v3.40 | §7.6: no new row, no cell change — template.read's Surfaces cell gains GET /templates/{*name} (the canonical workspace page, PathPatternParser's capture-everything variable — a dotted, multi-segment name; the grammar check answers the family's 404 before any read) and renames GET /templates/editor's entry to the compatibility redirect it now is (302 onto the workspace, version and supported tab preserved); the cell loses GET /partials/templates/versions — the explorer's detail pane retired with the page it lived in (#398), the route with it. GET /partials/templates gains the scope query parameter (nav or catalog — the markup picker, never the rows). No handler's @RequiredScope changed; the dialogs' GETs and POSTs keep their rows and gain nothing. |
| 2026-10-03 | v3.40 | #404 the async re-dispatch is not judged again | No route, permission, role or §7.6 row changes. ScopeInterceptor.preHandle returns at once on a DispatcherType.ASYNC dispatch (§8.2 step 11, §8.3's dispatcher-type note): the REQUEST dispatch already judged the request, the handler is not invoked again, and the re-dispatch's SecurityContext is empty (the credential filters skip async dispatches). Judging it anyway wrote 401 auth.api_key.missing on every streaming completion — unseen once a frame had committed the response, but the WHOLE answer of a stream completed with an error before its first frame, so the execute route's 429 pipeline.execution.concurrency_limit and the never-started 410 result.expired (rest-api §6.1) never reached the wire. Wire-proven by IdempotentAttachRowOrderE2eTest (both statuses, and the unkeyed 429), unit-pinned by SseErrorCompletionTest (the interceptor named as the writer by its control) and ScopeInterceptorTest; the D-R8 audit stays one row per request. The ERROR dispatch is unchanged: its handler is the public /error. |
| 2026-10-03 | v3.39 | #399 the visualizations workspace — renumbered at merge after 376's v3.38 | §7.6: no new row, no cell change — the existing visualization rows' Surfaces cells gain the workspace's routes, and visualization.read drops "no UI page yet". visualization.read: GET /visualizations (the flat catalog), GET /visualizations/{id} (the tabbed, version-explicit WORKSPACE, ?version=&tab=; a DISCARDED or lens-hidden version is the family's 404), GET /partials/visualizations/tree (the sidebar's lazy branch; q searches it flat), GET /partials/visualizations (the catalog's swap root) and the six lazy tabs …/{id}/preview, …/{id}/overview, …/{id}/evidence, …/{id}/evidence/{runId}, …/{id}/used-by, …/{id}/versions. The Preview tab renders the version's test-case FIXTURES — it executes nothing, so no execute row is consulted; the public /visualizations/{id}/preview capability page (§8.3) is unchanged and admits none of these routes. visualization.release, visualization.version.manage (purge-draft, purge-version, discard, restore), visualization.switch_version, visualization.delete (purge-entity): each gains its workspace dialog pair (GET+POST /partials/visualizations/{id}/lifecycle/…, session-only, the POST calling the same service, refusals the family's 4xx). No new permission; no key-kind surface change. |
| 2026-10-03 | v3.38 | S3 (#376) the parameter-set evaluation history — renumbered at merge after 400's v3.37 | §7.6: no new row, no cell change — parameter_set.read's Surfaces and prose gain the History tab's two partials, GET /partials/parameter-sets/{id}/evaluations (the paged house table, 25 a page) and GET /partials/parameter-sets/{id}/evaluations/{evaluationId} (one record's detail): both declare the row's permission (the owner's §11.2 ruling — no new permission; ReadFloorTest's family rule names them), a set the caller's lens hides is the workspace page's own 404 on both, and a narrowing lens lists only the records of the versions it admits (a draft's record names the draft's parameters). The spec's §5 spells the list route GET /parameter-sets/{id}/evaluations; every htmx fragment lives under /partials/, so the routes do too. Recording is no action: it is a side effect of the evaluate row's act (and of dashboard.execute and the MCP tool's row), checked by nothing new. §8.6: the retention sweep's row names six steps — the evaluation history's stale sweep (INCOMPLETE, metadata-db §8.5) and its retention (the event cutoff, one bounded batch) between the dashboard refreshes and the keys purge. No REST or MCP history surface (§11.6), no permission, key kind or role cell changed. |
| 2026-10-02 | v3.37 | #400 the dashboards workspace (#409 closes with it) | §7.6: no new row, no cell change — the existing rows' Surfaces cells gain the workspace's routes. dashboard.read: GET /dashboards/{id} is the tabbed, version-explicit WORKSPACE (?version=&tab=; the no-release case is the choose-a-version state, #409 — never a 404 for a record the caller can see); GET /dashboards/{id}/preview stays as a 303 redirect onto it; GET /dashboards is the flat catalog; the tree route gains q; GET /partials/dashboards (the catalog's swap root), …/{id}/overview and …/{id}/versions (the lazy tabs) join the cell. dashboard.release, dashboard.version.manage, dashboard.switch_version, dashboard.delete: each gains its workspace dialog pair (GET+POST /partials/dashboards/{id}/lifecycle/…, session-only, the POST calling the same service, refusals the family's 4xx). dashboard.key.bind: gains the read-only Keys tab GET /partials/dashboards/{id}/keys — the lowest row whose cells match the tab's visibility. No new permission; no key-kind surface change. |
| 2026-10-02 | v3.36 | S1 (#374) the Parameter Sets pages | §7.6: no new row, no cell change — the two existing parameter-set rows' Surfaces and prose gain the pages. parameter_set.read gains GET /parameter-sets (the flat catalog), GET /parameter-sets/{id} (the version-explicit workspace page; ?version= names one admitted version and never clamps, ?tab= is the closed set workspace/history) and GET /partials/parameter-sets/tree (the sidebar's lazy branch, one level per request): all three declare the row's permission, and a set the caller's lens hides — or a version it does not admit — is the family's one 404 (a promoter's lens reads released sets only, so a draft number is the same 404 an unknown number is). parameter_set.evaluate gains the workspace page as a UI caller of POST /api/v1/parameter-sets/{id}/evaluate (the live form, through the initParameters entry of the dashboard runtime); the page renders the form only for a role this row admits, so the promoter's page — which the lens lets read — issues no evaluate. No permission, key kind or role cell changed; the walk (RoleWalkE2eTest) and ScopeMatrixSpecDriftTest read the three routes from the Surfaces cell. |
| 2026-10-02 | v3.35 | #369 the dashboard draft preview — renumbered at merge (main sat at v3.34) | §7.6: no new row, no cell change — the two existing dashboard rows' Surfaces and prose gain the preview. dashboard.read gains GET /dashboards/{id}/preview?version= (the draft preview page: a named DRAFT or RELEASED version, the banner page; the runtime it mounts stays on the execute rows; a named version must resolve for the caller, so the promoter's lens hides a draft as the family's 404; a dashboard key never reaches the page — session-only). dashboard.execute's four runtime routes take the optional version query parameter (#369 R2): absent = the current RELEASED version unchanged; a key naming a version is refused dashboard.key.kind_refused before anything is looked up. No new permission, no key-kind surface change. |
| 2026-10-02 | v3.34 | S2 (#375) the observed parameter-set evaluation | §7.6: no new row, no cell change — parameter_set.evaluate's Surfaces cell gains POST /api/v1/parameter-sets/{id}/evaluations, the observed evaluation (the parameter-set workspace spec §4; rest-api §21.5): the same act as POST …/evaluate, its cascade streamed as Server-Sent Events to the Parameter Sets page. The stream re-judges its subscriber before EVERY write, the heartbeat included — session expiry, liveness, the OPENING workspace by id and this row (ParameterEvaluationStreamAuthority, #343's rule) — and a refusal ends the stream (: revoked) while the evaluation runs on. Session-only: no key kind reaches the route, and no MCP placement is added (spec §7). |
| 2026-10-02 | v3.33 | 235 (#164) the screenshot upload is audited | §10.1 gains one event row, visualization.test.screenshot_uploaded (defined in enums.md §15): the upload route reads no principal, so the row's actor is the run's starter, key_id null, the source IP from the request, ids/size/type in details — never the token. No permission, no §7.6 row, no route and no scope changes; the refusal path (the capability judged before a byte is read) writes nothing, as before. |
| 2026-10-02 | v3.32 | 344 (#344) bundled templates are the artifact's pins | §11A.1's lens clause gains its edge (the owner's ruling of 2026-10-02 on #344): an export bundle or a promotion batch carries what the artifact PINS — its templates, and a dashboard's pinned visualizations with theirs — read without the lens; a hidden pinned template rides, is never dropped, never refuses, and only versioning §10.4's skip rule leaves one out of a batch. The lens governs the root and every direct template read. No behaviour changed — every export and promotion path already followed the rule (PipelineTransferController said so in a comment); the ruling is now written where the next reader looks, with one red-able guard per family and the sweep's discriminating case. §7.6: no row, cell or permission changed. |
| 2026-10-01 | v3.31 | L5 (#367, #10) the dashboard key kind |
§7.6: 100 permissions — dashboard.key.bind, and the 13th key-role column dashboard_viewer. The column is dashboard_viewer's: bound on the two dashboard rows (dashboard.read, dashboard.execute — the bindings ARE the lens), ✗ on every other row; dashboard.key.bind takes the api_key.bind cells (✗ ✗ ✗ ✓ ✓, mcp:ws_admin ✓). §7.5 gains the dashboard_viewer transport role; §7.7 gains the dashboard kind row (runtime + refreshes prefixes ONLY), the bindings section (dashboard_key_bindings, R-EP2 verbatim: deeper replaces, unbound = unservable, the family 404), the /mcp refusal restated as the allowlist "every kind except mcp", and the one-key-one-budget note. McpAuthFilter reads ScopeInterceptor's per-kind refusal tables, so the endpoint/server messages on /mcp are word-for-word the interceptor's. ScopeMatrixSpecDriftTest.PERMISSION_COUNT 99 → 100. |
| 2026-10-01 | v3.30 | L4b (#353) the visualization test workflow on the wire | §7.6: no new row, no new permission — visualization.read gains the evidence reads and the on-demand check (GET …/tests/runs, …/runs/{runId}, …/runs/{runId}/screenshot, POST …/versions/{version}/check; a run visible only when its version is), visualization.update the session start and the results (POST …/tests/sessions, …/sessions/{sessionId}/results) and the tools visualizations_test_start / visualizations_test_submit (the catalog 59 → 61). §8.3: two capability-authenticated entries (43 → 45) — the preview page /visualizations/*/preview and the screenshot upload /api/v1/visualizations/*/tests/sessions/*/screenshot, each authenticated by ONE capability its handler checks against the run and the starter's CURRENT authority, neither carrying @RequiredScope. §8.4: the upload joins the promotion route's credential-not-cookie CSRF exemption (UploadCapabilityRouteMatcher). §8.6: the request-body cap's ONE route exemption (the upload's own 4 MiB, refused 413 visualization.test.screenshot_too_large), the CSRF row, and two public-contract rows. |
| 2026-10-01 | v3.29 | L4a (#352) the evidence gate — row added at merge after 348's v3.28 | §7.6: no new row, no cell change — visualization.release's description names the installed evidence gate (the exact draft under a row lock, THE LATEST run of the version GREEN for the exact body hash, the mechanical check re-run now; refusals tests_missing / tests_stale / tests_red / mechanical_failed) instead of the pre-#352 "refuses tests_missing until the test sessions land". |
| 2026-09-30 | v3.28 | 348 (#348) the pipeline workspace's version-explicit reads — renumbered at merge after L3b's v3.27 | §7.6: GET /pipelines/{id} (the canonical read workspace) and GET /pipelines/{id}/editor move to the pipeline.read row; GET /pipelines/{id}/editor LEAVES pipeline.execute. The old editor route is a compatibility redirect into the canonical read page, whose floor is the READ it is (122's execute floor described the page the route used to serve — that page is gone), so the promoter now walks it to the released content and the lens narrows what the page shows, exactly as §10.2's rule says. No cell changed on any row: pipeline.read was already ✓ ✓ lens ✓ ✓, and execute keeps its row on its POST alone. ReadFloorTest's PIPELINE_EDITOR family re-keys to pipeline.read in the same commit; §7.6's page-floor paragraph is rewritten — no PAGE route floors above read anymore. Execute, run-checks and every lifecycle verb are untouched routes with their own rows and CSRF. |
| 2026-10-01 | v3.27 | L3b (#10) the first-party pages — renumbered at merge after L1c's v3.26 | §7.6: no new row, no cell change — the two existing dashboard rows' Surfaces cells gain the UI routes. dashboard.read gains GET /dashboards (the tree page), GET /dashboards/{id} (the board page, which resolves the runtime configuration server-side to declare its one Plotly bundle — D50 makes every reader an executor, so the page stays on the read row) and GET /partials/dashboards/tree (the one-level-per-request fragment the page's tree and the sidebar's lazy branch both render). dashboard.execute gains GET /partials/dashboards/{id}/refreshes — the board page's events pane, the caller's own refreshes through the same runtime read the list route performs, so it floors with that route. The promoter's lens is unchanged and is applied in the service's reads; a hidden dashboard answers as an absent id on every new route. |
| 2026-09-29 | v3.26 | L1c (#10) the transfer — export/import routes and the promotion wire — renumbered at merge after L2's v3.25 | §7.6: 99 permissions (98 at the lane's base; L2's dashboard.execute landed first) — the two *.import rows, visualization.import and dashboard.import, inserted between each family's delete and release (the §5 order), the cells of api_key.bind by the owner's ruling (2026-09-29): workspace admin and super admin only — never an author, never an MCP author — because the verb lands RELEASED content with no evidence re-run (the D56 promise travels with the exported release; the promotion receiver lands promoted artifacts through the WIRE, never through this verb). Surfaces: POST /api/v1/visualizations/import, POST /api/v1/dashboards/import; the export GETs stay on the *.read rows, lensed before the export. |
| 2026-09-29 | v3.25 | L2 (#10) the dashboard runtime | §7.6: 97 permissions — dashboard.execute, the D50 delegated-act row: viewer ✓, author ✓, promoter LENS, workspace admin ✓, super admin ✓, the two transport key roles ✗, mcp:author ✓ / mcp:promoter lens / mcp:ws_admin ✓ (the key columns read the member columns) — with no MCP placement, because no tool executes a dashboard until L5's dashboard key kind. Six REST routes in the Surfaces cell (and §8.6.1 gains the DashboardRefreshSweepScheduler#sweep scheduled row, the retention row its fourth step): the runtime configuration, parameters and refresh stream, the abort, and the caller's refresh list and read. The row authorizes the whole refresh — its sources, set evaluation and transform run without consulting pipeline.execute, parameter_set.evaluate or template.evaluate — while ABORT additionally needs OWN or execution.cancel_all, and reading others' refreshes needs execution.read_all. Principals.cancellableBy refuses a DASHBOARD-triggered execution on the executions route for every caller: it is cancelled only through its refresh's abort. |
| 2026-09-29 | v3.24 | L1b (#10) the visualization and dashboard surfaces — renumbered at merge: 316 took v3.23 | §7.6: 96 permissions — the fourteen lifecycle rows of the two artifact families, in the parameter-set rows' shape: visualization.read and dashboard.read (every role; the promoter through the LENS — released dashboards whose every source pipeline her pipeline lens admits, and the visualizations they pin) plus the six author rows each (.create, .update, .version.manage, .delete, .release, .switch_version). POST /api/v1/dashboards/{id}/validate sits on dashboard.update — the implementation spec's §6.2 names dashboard.read; the owner ruled it an AUTHOR verb (2026-09-29) because the validator reads pin statuses unlensed (the L1a security pass's O5), so the interceptor refuses the viewer and the promoter. The two *.import rows land with L1c's import routes, dashboard.execute with L2, dashboard.key.bind and the dashboard_viewer column with L5 — a row nothing claims fails the reachability gate. The two transport key roles hold none of them. REST routes and the eleven MCP tools (`visualizations_list |
| 2026-09-29 | v3.23 | 316 (#316) the scheduled jobs' own scheduler — numbered after 310's v3.22 | §8.6: the three scheduled rows' bounded-work cells name the thread — dp-scheduled, the jobs' own scheduler (SweepSchedulingConfiguration's taskScheduler, one thread). Until #316 every @Scheduled job ran on the SSE log streamer's dp-sse-log thread, Spring's fallback while no TaskScheduler bean existed. No registration, route, permission or key changed; no row added or removed. |
| 2026-09-29 | v3.22 | 310 (#310) audit-log retention — numbered after 266b's v3.21 | New §10.3 Retention: datapipelines.audit.retention-days is enforced — the sweep's last step deletes every row older than the database's NOW() minus the retention, one cutoff for every event (§10.1 draws no security/tool-call line, so neither does the job; the 30-day floor protects the trail), batched; what is never deleted; the four audit read-backs stated against the cutoff (each an existence check, so retention can only refuse, never allow — check A's learning now lasts the retention, not the key's lifetime); the keys-purge interaction. §8.6's ExecutionEventRetentionScheduler#retain row names the three steps and bounds the audit step. §11C: the purge is no longer the sweep's LAST step, and an audit row keeps an identity only while it lives. No route, tool, permission or role changes |
| 2026-09-29 | v3.21 | 266 (#266) persistence batching, with its correction round 266b | New §10.1A Delivery: AuditEventSink.log still returns only once the row is committed (audit rows are authorization inputs — the #266 ruling). The default path is the direct INSERT: the audit writer ships off (datapipelines.persistence.audit.enabled: false, the orchestrator's ruling on the measurement — audit rows never formed batches and the writer raised their tail); switched on, rows outside a transaction go through the audit batching writer (group commit, partitioned by key then user, bounded by record-max-wait-ms with the caller's own INSERT as the fallback); inside a caller's transaction a row is always on the caller's connection (commits and rolls back with it). Which failures reach the caller — the same on every path: store failures (DataAccessException, and on the batched path TransactionException) are WARNed by class and SQLState, never the database's message, and swallowed; anything else fails the request as it always did. The batched path's timestamp (NOW() at the batch's start) and its consequence for McpToolLearnings' freshness check, and the causal-not-id order across the transactional boundary, stated. The at-least-once edge of a retried batch; retention still unimplemented. No event, route, permission or role changed. |
| 2026-09-28 | v3.20 | 195 (#195) the editor's CSP exemption retired — renumbered at merge after 298's v3.19 | The security-header posture, no route, permission or key changed: SecurityHeaders lost CSP_POLICY_EDITOR and isEditorRoute — ONE policy now covers every policed route (§8.1's writer list is one matcher), and no response carries 'unsafe-eval' anymore. The route-scoped exception 188 shipped on GET /pipelines/{id}/editor (owner ruling 2026-09-21: route-scoped over global) is retired because the editor's page runs Alpine's CSP build (@alpinejs/csp 3.14.1, vendored at the same path; the vendor manifest records the npm tarball's sha256) and every template expression is a pure property path. style-src carries Cytoscape's one sheet hash on every route — a hash admits exactly that sheet, so routes that never load Cytoscape are not weakened. SecurityHeadersTest's former exemption case now pins the directive's ABSENCE on the editor route (falsified by re-adding it); AuthHttpBoundaryTest reads the one policy on every walked path. Deployment §6.2's CSP row carries the same story. |
| 2026-09-28 | v3.19 | 298 (#293) the result row's own prose | §7.6's execution.result.read row says what it enforces — own runs unless execution.read_all, and every scheduled run (R3), the scheduled arm asking this row — instead of the stale "same own-or-all filter" (the execution.read row was corrected in f54a35bf). executions_get_result's scheduled branch now asks execution.result.read, its own catalogue row, not execution.read; no role holds one without the other, so no role's access changed. No route, tool, cell or constant changed. |
| 2026-09-28 | v3.18 | 194d (#194) the parameter engine's surfaces — renumbered at merge after 286's v3.17 | §7.6: 82 permissions — the nine parameter_set.* rows (the record's §9.3 table verbatim, the template.* rows the mould): parameter_set.read (every role; the promoter through the LENS — released sets newer than the promotion target's) and parameter_set.evaluate (every role but the promoter — a VIEWER row, C24: the evaluate fan-out's callers include every viewer; it reads the datasources the set's selectors name, which a viewer can already read through) plus the seven author rows (.create, .update, .version.manage, .delete, .import, .release, .switch_version). The two transport key roles hold none of them — an endpoint or server key never reaches /api/v1/parameter-sets (ScopeInterceptor.reachableBy; proven by PermissionSeamE2eTest's nine new witness pairs). REST routes in the Surfaces cells; the MCP placements (`parameter_sets_list |
| 2026-09-28 | v3.17 | 286 (#271) — renumbered at merge (landed before 194d) | §4.2 step 4: the demo join — the login stamp's third fallback — is built by the same super-admin-aware helper as the last-used and first-membership stamps (#262 covered two of three; the join returned a plain VIEWER context for a super admin with no membership — inert while both callers read only .name). §11.4's stream re-judgement is ONE verdict carrying its reason (ExecutionStreamAuthority.verdict: allowed / expired / revoked): the live stream no longer re-asks the clock after a refusal, so a token expiring in between can no longer tag a standing revocation expired. No route, permission or row changed. |
| 2026-09-27 | v3.16 | 254 (#254) the avatar proxy remembers a refusal | §11.1: a refused or failed fetch enters the same bounded TTL cache as a NEGATIVE entry — one provider fetch per user per TTL instead of one per page view — and the 404 carries Cache-Control: private, max-age=<ttl seconds>, so a stalling picture host cannot hold a request thread on every page view and the browser stops asking while the refusal stands; a later success replaces the negative entry. No route, permission or fence rule changed; the cache stays one map with one entry bound. AvatarControllerTest red-first (a counting fetcher stub saw two fetches on the base; the header absent). |
| 2026-09-27 | v3.15 | 250 landing (#250, #259) — R3 on every surface | §7.6 execution.read: the row's R3 clause no longer says "not yet the UI lists or MCP" — the UI's execution lists and the MCP execution tools apply R3 since 250 (the key's own role deciding); no cell changed. |
| 2026-09-26 | v3.14 | 262 (#262, with #263) the login stamp and the stream's claim | §11.4: the open-stream re-judgement asks the workspace the stream OPENED in (the filter's resolved context — a DP-Workspace header switch included), not the JWT claim, whose re-resolution wrongly cut a header-switched stream at its first write; and it judges the validated token's exp, which the subscriber now carries from the claims the credential filter already validated — a write at or past it ends the stream with the same final comment, close_reason expired (the closed set, Observability §4.1). No route, permission or row changed. §5 (262): workspaceForLogin's two membership fallbacks build the super-admin-aware context resolveForSession's fallback builds (#216's helper — the predicate is the just-authenticated User row's live is_admin), so a super admin's login response keeps the instance authority instead of one request's demotion. |
| 2026-09-26 | v3.13 | scheduler lane 2 (#9) — numbered after origin/main's v3.10 (246) | §7.6: the Schedules page joins schedule.read — GET /schedules (UI Screens §4.20) is the page SHELL; every schedule read and write on it is a call to rest-api §20 from the browser (session + DP-CSRF-Token), so no /partials/schedules… route exists and no other row changes. Its verbs render by role (canAuthor = the five schedule.* write rows) and the §20 routes enforce the same rows. No new permission; 73 unchanged. |
| 2026-09-26 | v3.12 | 230 (#230, with #216) the P4 stream cut and the super-admin fallback | §11.4: the "in flight" note on P4 is retired — the behaviour is built. An open SSE stream (live, idempotent-retry follow, replay) re-judges its subscriber before every write with the same predicate a new request runs (liveness, the live is_admin snapshot, the request path's workspace resolution, execution.read, visibility), all through this section's caches; a refusal ends the stream at that write with a final revoked SSE comment, and the execution keeps running (P4's first half — a revoked READER never cancels the run). No route, permission or row changed. §11A/§5 (216): resolveForSession's first-active-membership fallback builds the same super-admin-aware context the claim branch does, so a super admin with a stale claim is no longer judged as their membership's role until they switch. |
| 2026-09-26 | v3.10 | 246 (#246) the avatar proxy hardened — numbered after scheduler lane 1's v3.9 (#9, merging at dispatch) | §11.1, the avatar proxy's fence narrowed at three edges, no route, permission or key changed: a picture URL naming ANY explicit port is refused before a connection; only raster types are served (image/png, image/jpeg, image/gif, image/webp — image/svg+xml is refused); the whole fetch, headers and body, has one 5 s budget, enforced per chunk and by closing a stalled body's stream at the deadline. AvatarControllerTest red-first on each edge. |
| 2026-09-26 | v3.9 | scheduler lane 1 (#9) — numbered after origin/main's v3.6 (197), v3.7 (232), v3.8 (239) | §4.5: the system account is the identity schedules fire under (R2) — built in-process (AuthMethod.SYSTEM, SystemActorPrincipals), never by a filter (WorkspaceResolutionFilter refuses one that arrived on a request); PermissionResolver's new system arm answers a FIXED set in any workspace — pipeline.execute, pipeline.read, execution.read — and nothing else (RolePermissions.SYSTEM_ACTOR, pinned by SystemActorPrincipalTest). §7.6: 73 permissions — schedule.read (every member; the promoter through the lens), schedule.create, schedule.update, schedule.pause (pause, resume, unblock), schedule.delete, schedule.run (Run now, L2) — author, workspace admin, super admin (R8); no key role holds any of them (slice 1 is session-only). execution.read covers the durable event record (?format=json) and every SCHEDULED run of the workspace (R3) — visibility, not ownership. §8.6: a new scheduler-task family — schedule-dispatcher, schedule-reconciler, schedule-run, read off every db-scheduler Task bean by EntryInventoryE2eTest, unreachable from any transport (B5). |
| 2026-09-26 | v3.8 | 239 (#239) the key through the seam | §7.5, mechanism only, no route/tool/cell changed: an mcp key's member-role admission asks the same PermissionResolver a session's decisions ask (security-assurance record §7.1/B4) — the production resolver reads the member column, so no observable answer changed, and the isolated-permission witness (PermissionSeamE2eTest) covers the MCP tool surface again (red-first on the direct read). The two transport key roles (api_caller, promotion_receiver) are still read from the table at admission. |
| 2026-09-25 | v3.7 | 232 (#232) the limiter's own sentence | §9: the two rate limiters answer with DIFFERENT user messages — the login damper keeps its sign-in sentence, the per-user API limiter answers with the catalog's request-volume sentence (§13.11 carries the split). The shared RateLimitExceededException's message became a parameter defaulting to the login sentence; RateLimitFilter overrides with the catalog's. Code, status and details unchanged. |
| 2026-09-25 | v3.6 | 197 (#197) the avatar proxy | img-src narrowed to 'self' data: — the standing https: grant is gone. The OIDC picture (users.profile_picture_url) is served by GET /avatar (§7.6 profile.read row gains the route): the signed-in principal's OWN row only, fetched server-side from the per-provider picture-hosts allowlist (§11.1 — NOT derivable from issuer-uri: Google issues from accounts.google.com, serves pictures from lh3.googleusercontent.com), redirects never followed, image/* only, 1 MiB cap, private TTL cache. The page's <img> points at the proxy; the provider URL never reaches the HTML. Deployment §6.2's CSP row and Configuration §3.4 carry the same story. |
| 2026-09-25 | v3.5 | keys v2 (#233) | Every key is a robot member of one workspace (record §10, A13–A19). §7.4 issuance rewritten: no key is minted at sign-in (A15 — D16, the top-bar chip and minted_at_login retire); the Keys page is the ONE creation path; an mcp key's role is CHOSEN at creation under the subset rule (A14): a creator may give a key any role whose §7.6 column is a subset of their own — mcp_key.create (author, promoter, ws admin) and mcp_key.revoke_own (author+) join the catalog; api_key.create/api_key.revoke become service-checked floors on the same routes. §7.5: the PK4 cap, the freshness rule (C2) and the admin cap (C3) are gone — the key's identity holds its chosen member role (author | promoter | workspace_admin; never viewer, A15) exactly as a member does, and a promoter-role key is lensed. §7.6: 67 permissions; the catalog gains the three mcp:<role> key-role columns; POST /api/v1/auth/api-keys + POST /partials/api-keys move to mcp_key.create, both delete routes to mcp_key.revoke_own, GET /api-keys to mcp_key.own; GET /api/v1/auth/api-keys/mine, DELETE /partials/mcp-key and GET /partials/mcp-key/chip are gone. §7.7: the user kind is renamed mcp (A19); the endpoint kind's reach is the published tree INCLUDING its result-paging routes and EXCLUDING the framework's GET /api/v1/executions/… (A16 — session-only now, with ExecutionsController refusing keys as the second line). §9: auth.key_kind_not_mintable reworded (no kind named); auth.key_issuer_role_lost narrowed to the context-judged path. §11A.4/§11C: keys and identities are never hard-deleted — revocation is the end state, the retention sweep's purge deletes only what nothing references (A17/B5), member removal revokes created keys (B6), and names are unique per workspace among live keys (A18). Migration: V37 (every live user-kind key — login-minted and the pre-R3 on-demand ones — converted or revoked, B4; the CHECK spells role IS NOT NULL on every role-bearing arm, because the three-valued-logic trap admits a NULL-role row otherwise). |
| 2026-09-25 | v3.4 | 7e (#7) the semantic link | §7.6: no route, tool or cell changed — the semantic link is fields on existing rows (transform-nodes design §9.4): implements on template.create / template.update (a released version too), needs_review and the implements list filter on template.read, implemented_by on semantic.read (lensed by the reader's template.read), the release response's warnings on pipeline.release. The row texts say so. |
| 2026-09-25 | v3.3 | 7d (#7) the transform editor face | §7.6: three UI routes join existing rows, no cell changed — GET /partials/templates/transform-face (the four panes, read-only for a non-author) on template.read; POST /partials/templates/transform-face/save (Save draft, 7b's write path and save gate) on template.update; POST /partials/templates/transform-face/run-suite (Run suite over the unsaved panes — nothing written) on template.evaluate, the row 7b's evaluate tool and route sit on (transform-nodes design §9.4). |
| 2026-09-24 | v3.2 | 217a (#217) entry inventory | §8.6 (new): the entry inventory and the public contract (security-assurance record §4, ratified B3/B5). The inventory has one row per registration the running application holds that is not a handler method: servlets, container filters in order, the security chain's filters in order, handler mappings, scheduled jobs (each unreachable from a request, B5), and the packaged jar's listening sockets and actuator endpoints. EntryInventoryE2eTest compares it with the runtime both ways and places every handler route on §7.6 or §8.6.2. The public contract has one row per §8.3 glob: its reason, the handlers it covers, a probe and its anonymous answer, what it touches, and the other verbs; PublicContractE2eTest holds the running application to it. §11.4: work already in flight (P4): a running execution keeps its authority to completion, and an open stream is cut at its next event after revocation — not built yet (#230). The member permission decisions now ask one PermissionResolver (record §7.1, B4) — no answer changed. The ASVS 5.0.0 Level 2 index is security-assurance.md. |
| 2026-09-24 | v3.1 | 215b (#215) key identities, key roles | Scopes removed; keys carry roles (permissions and keys record §3–§5, PK4–PK9). §4.7 key identities (new): an endpoint/server key acts as its own users row (kind = 'service', provider key, <key id>@keys.invalid), created with the key in one transaction, deactivated by its revocation, managed only through its key; one predicate (kind = 'human') guards every login, linking and user-admin path — closing the pre-existing hole where the identity reset could make the System row claimable. §7.5 key roles replaces scopes: api_caller, promotion_receiver, and the MCP key as its member capped at author (PK4); no key is ever a super admin (B1). §7.6 gains the two key-role columns; the two key-scope tables are gone; the heading drops "two axes". §7.7: the MCP key is confined to /mcp (B2, owner ruling 2026-09-24); an unbound published path serves no one (B3); promotion intake is instance-wide and a stored server key acts as its identity (C4, B6). §9: auth.permission.undeclared replaces auth.scope.insufficient; auth.key_scope_unavailable retired. §7.3/§11.4: authority freshness is the 60 s auth cache TTL (B5, A9). Migration: V34 (PK9). |
| 2026-09-24 | v3.0 | 215a (#215) the permission catalog | §7.6 is the permission catalog (permissions and keys record §2, ratified 2026-09-23, amended 2026-09-24): 65 <functionality>.<permission> rows replace the thirty coarse operations and the separate MCP role table. Each row's Surfaces cell places every route (VERB /path) and tool, so the drift test, the reachability gate and the role walk read the placement from the doc; the cell alphabet gains fenced (the two promotion-receiving rows — no role holds them, super admin included). Handlers declare @RequiredScope(Permission.X); tools their entry in ScopeMatrix.MCP_TOOL_PERMISSION; the one role table is RolePermissions. Every route keeps its role set and scope floor (the A4 shim: the permission key-scope table replaces the REST key-scope table); the MCP role axis moves on five cells — viewer × datasources_preview_rows, sql_probe, pipelines_execute_node and promoter × the two probes — which the record puts at author-and-above (keys minted at login were already refused them by scope; a member demoted from author keeps an author-scoped key, and that key is now refused them). Two named changes by owner ruling (2026-09-24): revoking a SERVER key is a super admin's (server_key.revoke; §7.4 — #191 had given it to the page's workspace admin), and retiring another workspace's DATASOURCE fact asks datasource.manage (the workspace admin, as before). Refusal details: required is the catalog permission, held the role (§9). Drifts fixed on the way: GET /api/v1/endpoints sits on endpoint.read (v2 said the publish row; the handler declared the read row); DELETE /api/v1/workspaces/{name} on workspace.lifecycle (v2 listed it on the update row too); the pinned-workspace tool count reads 38 of 42. Slice (b) removes scopes and this shim. |
| 2026-09-23 | v2.37 | 7b (#7) templates_evaluate | §7.6 MCP table 41 → 42 tools: templates_evaluate joins the author/author row — the templates_render row (R6: evaluating untrusted code on the server is the same authoring act as rendering; viewer ✗, promoter ✗). The REST twin POST /api/v1/templates/evaluate declares the existing MUTATE_PIPELINES_TEMPLATES row (no new RestOperation); its row now names the route. Both ScopeMatrixSpecDriftTest counts moved 41 → 42 in the same commit. |
| 2026-09-23 | v2.36 | 213 (#213) show-once MCP key | §7.4: the login-minted key is copyable ONCE (D16 amended 2026-09-23): the sealed copy exists until its first read and is destroyed by it — the open and the clear are one owner-scoped statement (ApiKeyRepository.openAndClearSealedSecret), a second copy in any tab answers 404, and the key is hash-only from then on; nothing the server holds can reveal a key that has been read. V32 cleared every pre-amendment sealed copy fleet-wide (rotation — delete + sign in — is the path back to a copyable key). §7.6: the VIEW_OWN_MCP_KEY row gains GET /partials/mcp-key/chip (the post-copy chip re-render); no role cell changed, the secret endpoint's authorization is unchanged — its semantics went one-shot. Merge review: the secret route carries a fetch-metadata guard — a request whose Sec-Fetch-Site is not same-origin, or whose Sec-Fetch-Mode is navigate, is refused 403 before the open, because dp_session is SameSite=Lax and would otherwise ride a cross-site top-level navigation that destroys the one copy (never readable by the attacker, but a forced rotation). |
| 2026-09-22 | v2.35 | #210 mint honours the sign-in method | §7.4: the login/switch mint skips only a PASSWORD session whose local password must still be changed; an OIDC session, which the forced-change gate lets through, mints its key. Before this a Google user with a stale bootstrap flag was keyless on every sign-in, silently. |
| 2026-09-22 | v2.34 | #208 self-membership | §7.6's MANAGE_WORKSPACE_MEMBERS row (unchanged cells): the three member verbs are never available on the caller's OWN membership — 409 workspace.self_membership, refused at the service on every surface; a super admin's members-list row reads "super admin" (owner ruling 2026-09-22). |
| 2026-09-22 | v2.33 | 186 review M1 (#186) — LAKE in the in-process gate | §7.6's MUTATE_WORKSPACE_DATASOURCES row (unchanged cells): LAKE joins the in-process engines whose registration and re-pointing are super-admin-only — its embedded DuckDB runs with external access on and no local-filesystem lock (Datasources §9, v2.44). |
| 2026-09-21 | v2.32 | 200 (#200) membership-bound keys | A user key now ends with the membership it is pinned to, and a workspace admin can revoke it (roles record §3.7, owner rulings 2026-09-21). §7.4 rewritten on the three rulings: removing a member revokes their user key in the same act (one transaction; reason member_removed); a workspace admin can revoke a member's key WITHOUT removing them — NEW DELETE /api/v1/workspaces/{name}/members/{user_id}/key and the members-row verb, on the existing MANAGE_WORKSPACE_MEMBERS row (§7.6), idempotent 204, reason admin_revoked, the member's SESSION untouched and the next login minting fresh; NO automatic rotation on password or identity events (a login that stops working is not a security signal — recovery is an admin act). Before this round a removed member's key kept authenticating through the issuer check's viewer fallback, which admitted EXECUTE. NEW audit event auth.api_key.revoked_by_admin (§10.1, Enums §15). |
| 2026-09-21 | v2.31 | 186b (#186) H2 URL form case-sensitivity | §7.6's MUTATE_WORKSPACE_DATASOURCES row (unchanged cells, extended description): the in-process H2 prefixes are matched case-sensitively, exactly as the driver reads them — a mixed-case TCP:/MEM:/FILE: is an unknown URL form the driver would open as a literal file, and is refused for every role. No operation, route or role cell changed; the super-admin rule of v2.30 is unchanged — this closes the spelling bypass of it. |
| 2026-09-21 | v2.30 | 186 (#186) in-process datasource containment | §7.6's MUTATE_WORKSPACE_DATASOURCES row (unchanged cells, extended description): registering or re-pointing an IN-PROCESS or file-backed datasource (H2 mem:/file:, DuckDB, SQLite) is super-admin-only even where member-datasources-enabled admits workspace admins — decided in DatasourceWorkspaceRules on the same route, like the instance-datasource rule. No operation or role cell changed; the row's ws_admin ✓ now reads as "server-form datasources". The runtime halves (file roots, the de-privileged H2 pool and probe scratch) are Datasources §3/§4.2A/§9. |
| 2026-09-21 | v2.29 | #202 csrf cookie lifetime | §8.4: dp_csrf now carries the session's lifetime (Max-Age = jwt.ttl-hours) and an explicit SameSite=Lax. It was a browser-session cookie while dp_session persisted for 8 h, so a tab restored after a browser restart failed every POST (missing, then mismatch on the retry — the logout / workspace-switch report) until reloaded. Pinned by AuthHttpBoundaryTest. |
| 2026-09-21 | v2.28 | 191 (#191, #192, #187) — auth/serve security | Three security fixes, one round. §4.2: a MISSING email_verified claim is now UNVERIFIED — fail closed; the per-provider knob trust-email-without-verified-claim (§5.1, §11.1, default false) admits an IdP that never emits the claim, audited auth.login.email_verified_assumed; a claim present-and-false is refused regardless. §4.2/§7.6: an identity is linked ONCE — a login whose (provider, provider_subject) does not match the stored row (a different provider, a different subject, a local/system row) refuses with auth.login.identity_mismatch → /login?error=identity_mismatch, nothing updated; NEW row USER_IDENTITY_RESET (super admin, §7.6) — PATCH /partials/admin/users/{userId}/identity-reset returns the row to the bootstrap placeholder, audited auth.user.identity_reset, so the next sign-in with that email claims it. §7.7: endpoint-key bindings are workspace-confined — bind time refuses a prefix that is not at or above a path the caller's own workspace publishes (endpoint.path_invalid, naming only the caller's own tree); serve time considers only bindings of the ENDPOINT's workspace (SQL predicate + in-memory backstop; a foreign binding neither authorises nor shadows). The serve audit (R4) records the OBSERVED outcome: started pre-answer, then a terminal row completed/failed/accepted (#192). |
| 2026-09-21 | v2.27 | 180 (#180) roles R4 — deactivation | D15, the liveness round. §11A.3 rewritten around ONE predicate (PrincipalLiveness: user active ∧ pinned workspace active, through the §11.4 cache) judged where each credential becomes a principal — JwtAuthenticationFilter, ApiKeyService.validate, validateServerKey — with the serve path refusing a deactivated workspace's endpoint as an unknown path to every caller (before, only keys pinned there were refused). NEW code auth.principal_deactivated (401, §9, §13.7's registry) for a deactivated USER on every surface but the promotion peer; auth.api_key.invalid no longer means "owner deactivated"; a deactivated session's page navigation lands on /login?error=inactive. The WORKSPACE case keeps the 404 rule (auth.key_workspace_inactive, workspace.not_found). Gap closed: a server key pinned to a deactivated workspace authenticated on /api/v1/promotion/**. Gate 7 (DeactivationSweepTest) proves every route and tool. §4.2, §6.3, §7.3 steps updated; §5A.6's form field corrected to _csrf and its dead auto-per-user clause removed (#182). |
| 2026-09-21 | v2.26 | 179 (#179) roles R3 — keys | D16/D17, the keys round. §7.4 rewritten: a user key is minted by the login/switch hook, one per user per workspace (V31's partial unique index), scopes derived from the role, the plaintext SEALED (secret_sealed, the credential-encryption key, AAD = the key id) so the top bar can serve a copy; rotation is delete + sign in again; a user who owes a password change is minted nothing until after it. On-demand user minting is refused on every surface — auth.key_kind_not_mintable (400, §9, §13.7's registry). §7.6: MANAGE_OWN_API_KEYS is RENAMED VIEW_OWN_MCP_KEY (the self surface: list, /mine, the top bar's copy and delete-to-rotate) and creation left it; NEW row MANAGE_API_KEYS (ws_admin + super admin, author scope) takes POST /api/v1/auth/api-keys, the /api-keys page and its partials, and the endpoint-bindings routes — which moved OUT of MANAGE_ENDPOINTS (association is the admin's verb; the by-name request shape is kept, and api_key_id joins it). §7.7's kind table restates who mints each kind. §11A.4 rewritten. The console (/api-console) is read-only again: the keys card moved to /api-keys; the MCP connection card stays, and the top bar's chip links to it. |
| 2026-09-21 | v2.25 | 178 (#178) roles R2 | The promoter lens lands (roles design §3.1, D5): the lens cell in §7.6 now has behaviour — a promoter reads pipelines, templates and endpoints through PromotableView, Versioning §10.2's rule applied on every read surface (REST, UI pages and partials, the rail counts, search, MCP tools and resources); §11A.1 gains the lens clause (a hidden object is an absent one; fail closed when the target cannot be read; the rule travels as a value, never a thread-local). The cell alphabet and the §11A role table say so in the present tense. New guard PromoterLensSweepTest; new knob datapipelines.deployment.promotion.inventory-cache-ttl-seconds (Configuration §3.19); new WARN pipeline.promotion.lens_unavailable and counter datapipelines.promotion.lens.inventory (Observability §3.4D). No matrix row changed. |
| 2026-09-20 | v2.24 | 177 (#177) roles R1 | §7.6 rewritten role-first from the ratified roles design §2: five role columns per row (viewer | author | promoter | ws_admin | super_admin), every REST row naming its RestOperation constant, the key-scope axis as its own smaller table per surface; ScopeMatrixSpecDriftTest parses the new shape (a cell alphabet of ✓ ✗ own all lens, a reserved-row list). Rows that changed: RELEASE_VERSION and SWITCH_SERVED_VERSION promoter → author (D8); EXECUTE_PIPELINE, CANCEL_EXECUTION, the execution reads and TEST_DATASOURCE lose the promoter (D5; the connection test follows execute — ratified); TEST_DATASOURCE ws_admin → execute; INTROSPECT_DATASOURCE author → every role ("introspection is reading" — ratified); WORKSPACES_READ every member → ws_admin (D13). New rows: READ_EXECUTIONS (D11: own unless workspace admin, promoter none — the execution reads leave READ_RESOURCES), WORKSPACE_SWITCH (the switcher stays every member's), PROMOTION_READ (owner rule 13: the page is the author's too); a reserved audit-log row (D12 — no surface exists). MUTATE_DATASOURCES REMOVED: no handler ever declared it (the instance-datasource rule is DatasourceWorkspaceRules' on the same route) — the reachability gate's first finding. §11A rewritten to the five roles: ONE role per membership (V29, WorkspaceRole), Capability → Permission (D21), the vocabulary rule; §4.6 and §10.1 say role, not flags (workspace.member_flags_changed → workspace.member_role_changed). §4.4/§7.4 keys unchanged (R3). |
| 2026-09-19 | v2.23 | 173 (#173) the agent surface | §8.3: /llms.txt and /llms-full.txt join the allowlist (generated from the page registry and the docs catalog, no datastore, no principal — meaningless behind a login, like the sitemap); the /docs/* reason names the raw .md twin the same glob already covers. PublicPathsTest 41 → 43 rows. |
| 2026-09-19 | v2.22 | 172 (#172) endpoint URL shape | §7.6/§7.7: the published-endpoint surface is /api/<category>/<version>/<path…> (R-EP5) — the endpoint-kind confinement follows the FIRST segment of the path (reserved: v[0-9]+, api), never a literal prefix. No scope, role or kind rule changed; the route family an endpoint key reaches is the same set of URLs under a new shape. |
| 2026-09-17 | v2.21 | 158 (#121) mail connect retry | §5A.8 step 3: a connect failure (the connection never opened — the one class that cannot have delivered) is retried in place, bounded (3 attempts, 250 ms / 1 s backoff, mail.send_retry logged per retry); anything past connect stays terminal at once — the never-twice rule for password mails is unchanged. The claim row and audit reflect the final outcome only. |
| 2026-09-17 | v2.20 | SSE response headers (#131) | §8.1: configure eager security headers before asynchronous response handoff; retain security defaults and explicit application caching. Guarded at the configured filter boundary and by real HTTP/OIDC tests. |
| 2026-09-14 | v2.19 | 140 release checks over MCP | §7.6 MCP table: pipelines_run_checks joins the execute/execute row (40 → 41 tools) — the D-R3 verb, the same floor as the EXECUTE_PIPELINE REST twin (POST …/checks/run): a viewer runs what they can read, and a check run returns no row data beyond the one observed cell per check. Both ScopeMatrixSpecDriftTest counts moved 40 → 41 in the same commit. The status line had drifted a version behind the rows again (the v2.16 note's pattern); now current. |
| 2026-09-14 | v2.18 | 137 mail notices | New §5A.8 Mail: the welcome mail (login URL + one-time password) at local-account creation, the reset mail at an admin reset, and the sys-ops "New user" notice (no password) at every creation — local or the OIDC callback's create branch (§5.5: findOrCreateByEmail answers Provisioned(user, created)). Enabled exactly when configured (Configuration §3.27); claimed before sent (mail_sends, Metadata DB §4.19) so a password mail never goes twice; sent after commit off the request thread; mail.sent / mail.failed audited without a body. §5A.1's "no email flow — the product has no SMTP" is gone: there is still no self-service reset, but the credential an admin mints is mailed, and the admin screen shows where it went instead of what it was (UI §4.12). |
| 2026-09-14 | v2.17 | 134 MCP save sees workspace datasources | §8.5: the principal reaches an MCP tool through the transport context, not the thread — the SDK's scheduler thread has an empty SecurityContextHolder, so anything reachable from a tool takes principal and workspace as arguments (the save-time datasource port did not, and every MCP save saw owner-less datasources only). No matrix change. |
| 2026-09-13 | v2.16 | 122 viewer executes | §7.6: no matrix row changed — the round re-floored a PAGE route. The pipeline editor (GET /pipelines/{id}/editor) declares EXECUTE_PIPELINE instead of MUTATE_PIPELINES_TEMPLATES: D-R3 says viewers execute, the §4.3e table already gave the viewer's editor an Execute verb, and the author floor refused the viewer before any of it was reachable (the owner's 2026-09-12 report). The template editor keeps the author floor. New paragraph after the UI-screens one states the page-route reasoning; the read key's "everything except the editors" property is unchanged (read < execute). Status line had drifted a version behind the rows (119-style); now current. |
| 2026-09-11 | v2.14 | 117 templates_update | §7.6 MCP table: templates_update joins the author/author row (34 → 35 tools) — the template mirror of pipelines_update, the draft write REST PUT /templates already gates. Both ScopeMatrixSpecDriftTest counts (scope and capability) moved 34 → 35 in the same commit. |
| 2026-09-11 | v2.15 | 118 learned semantic layer | §7.6 MCP table 35 → 38 tools: semantics_list joins the read/view row; a new semantics_record / semantics_retire row on author/author (recording is an authoring act, D-S8; the DATASOURCE-scope grant requirement is the §5.3 gate — not-found otherwise; the cross-workspace retire rule needs the workspace-admin role, enforced in the service). §10's event registry (enums.md §15) gains semantics.recorded / semantics.retired. |
| 2026-09-07 | v2.13 | 094 the agent boundary | §7.6 MCP table: datasources_create LEAVES the matrix (28 → 27 tools). The standing rule it becomes: no credential travels through an agent — a password passed through a tool call transits the agent's context, its transcript and whatever the client logs, which 068 documented as an accepted trade and 094 rejected. Datasource create, update and delete are UI/REST-only; the read and probe tools (datasources_list/_get/_test, the three introspection tools, datasources_preview_rows) are unchanged, and none of them accepts a credential. |
| 2026-09-04 | v2.12 | 068 datasources_create | §7.6 MCP table: datasources_create joins the author row (21 → 22 tools) — the same floor datasources_test sits on, since registration opens a real pool against a production database at save time. global: true still requires admin, but as a workspaces D8 rule inside the shared create service, not a scope floor, so it does not appear in this matrix. |
| 2026-09-02 | v2.11 | 040 template used-by | §7.6 MCP table: templates_used_by joins the read row (20 → 21 tools) — it returns which pipelines reference which template version, reference structure a workspace reader may already see by reading the pipelines themselves; no customer row data (040 D7). |
| 2026-08-30 | v2.10 | local password auth | §5A (new): optional local password accounts — admin-created only (no self-registration), Argon2id via the API-key SecretHasher, provider = 'local' placeholder, per-account lockout (§5A.3), config-seeded first admin with a forced first-login change (§5A.2/§5A.4), enumeration-resistant failures with timing equalization (§5A.5), POST /login converging on the OIDC session (§5A.6). §1/§2 Principle 1 amended honestly: identity delegated by default, the narrow local exception and WHY. §5.3 login page: form + divider + buttons, only enabled methods. §8.3: /login covers the local POST. §10.1 gains auth.login.bad_credentials, auth.login.locked, auth.password.{seeded,changed,reset,disabled}, auth.user.{created,unlocked}; auth.login.success/user_inactive are shared with OIDC. The local-auth error codes join §9 and the §13.7 registry (see the catalog commit). §14 postscript. |
| 2026-08-28 | v2.9 | sample data, slice A | §4.4 pre-provisioning: when datapipelines.bootstrap.datasources-file is set, startup creates the configured bootstrap admin's row before serving traffic (placeholder provider = 'bootstrap' / provider_subject = the email, display_name from the local-part) so bootstrap-registered datasources have a real created_by. Grant-at-creation semantics are unchanged — this is the same single path firing earlier, one audit event across provision → restart → login. §4.2 linking: the update no longer rewrites display_name on every sign-in; it is set at row creation, and refreshed from the ID token's name claim only when the login completes a provider = 'bootstrap' placeholder |
| 2026-08-26 | v2.8 | workspaces slice 2 | Workspace resolution (design §5/§7): §4.2 step 4 — login stamps active_workspace (last-used, else first membership, else freshly provisioned personal workspace under auto-per-user); §5.6 — DP-Workspace per-request switch, membership-checked (403 workspace.membership_required), API-key requests pin the key's workspace and refuse the header (400 workspace.header_forbidden); §7.4 — issuance restricted to the creator's workspaces; workspace.* codes catalogued in pipeline-contract §13.12. §11.1: stock config ships Google only; Microsoft becomes a commented single-tenant example — multi-tenant /common documented as unsupported in v1 (Nimbus refuses its {tenantid} discovery metadata at startup). |
| 2026-08-05 | v1.0 | initial draft | Local username/password auth, JWT sessions, API keys, scopes, audit log |
| 2026-08-05 | v2.0 | OIDC migration | Replaced local auth with Google/Microsoft OIDC. No passwords stored. Internal JWT issued after OIDC login. Added email domain allowlist. Added Spring Security filter chain configuration. Added OIDC success handler. Users provisioned automatically on first login. |
| 2026-08-05 | v2.1 | generic OIDC | Replaced hardcoded Google/Microsoft with generic OIDC provider model. Any OIDC-compliant provider works (Google, Microsoft, Okta, Auth0, Keycloak, AWS Cognito, Ping, etc.). Deployment configures a provider list in application.yml; login page renders buttons dynamically. provider column in users table is free text (not constrained to GOOGLE/MICROSOFT). OIDC discovery auto-configures all endpoints from issuer-uri. |
| 2026-08-07 | v2.2 | consistency campaign | D10: X-API-Key → DP-API-Key; CSRF = dp_csrf cookie + DP-CSRF-Token header. D11: /mcp in the chain (§8.5): API-key-only, CSRF-exempt, Bearer dpk_ accepted. D13: liveness re-check on every request via 60s cache — deactivation effective ≤ ~1 min (§4.2, §6.3, §7.3). D14: scope derivation at login (§6.1). D15: authoritative scope↔operation matrix (§7.6); key scopes ⊆ creator's scopes (§7.4). D5: error codes normalized to 3-segment (auth.api_key.missing etc.), auth.csrf.* collapsed to auth.csrf.invalid, auth.rate_limit.exceeded removed in favor of rate_limit.exceeded. name required per provider (§11.1); audit example provider lowercase; config tables replaced by pointers to configuration.md (D8). See SPEC-REVIEW-2026-08 |
| 2026-08-09 | v2.3 | P3 build (Gate C testing review) | §8.4 CSRF prose corrected to match the §8.1 chain (which is the ratified D10 design): /api/** and /mcp are CSRF-exempt — API keys carry no cookie, and cookie-authenticated API calls are defended by dp_session SameSite=Strict (§5.5); the dp_csrf/DP-CSRF-Token double-submit guards the cookie-native UI surfaces (/partials/**, POST /logout), failing 403 auth.csrf.invalid with details.reason. The v2.2 sentence requiring the token on cookie-authenticated /api calls contradicted the sketch and is withdrawn. |
| 2026-08-10 | v2.5 | P3 build (auth re-review) | §7.6: added the GET /api/v1/auth/me row (any authenticated) — default-deny (§8.3 ScopeInterceptor) turns a missing matrix row into a hard block for that endpoint, and §7.6 is the sole authority. §11.3: DATAPIPELINES_AUTH_BASE_URL listed as required-with-providers (startup fails without it — the v2.4 §5.2 change added the requirement but not the env-list entry) + DATAPIPELINES_AUTH_BOOTSTRAP_ADMIN_EMAIL optional. |
| 2026-08-09 | v2.4 | P3 build (Gate C security + API reviews) | CSRF re-ruled (supersedes v2.3): exemption follows the CREDENTIAL, not the path — only API-key-carrying requests skip CSRF; cookie-authenticated state-changing requests require the dp_csrf double-submit everywhere, SameSite=Strict demoted to defense-in-depth (same-site subdomain gap, flagged independently by two Gate C seats); §8.1 sketch updated. §5.2: OIDC redirect URI built absolutely from new datapipelines.auth.base-url (Configuration §3.4), never request-derived (Host/X-Forwarded-Host attack); startup fails when unset with providers configured. §4.2: emails lowercase-normalized at every lookup/store; login rejected when email_verified: false (unverified-account takeover via email-keyed linking). §4.4: bootstrap admin grant fires only at row creation — never re-grants after a deliberate revoke. |
| 2026-08-14 | v2.6 | v1.1 introspection build | §7.6 REST table: new "Introspect a datasource schema" row (GET /api/v1/datasources/{name}/schema, /tables, /tables/{t}/columns) at author — the §8.1 connection-test precedent (live connection against a production datasource; consumer is authoring). Sourced from datasources §7A. MCP rows follow with the mcp-server amendment. |
| 2026-08-15 | v2.7 | surface restructure (part 1) | §7.6: datasources_get_schema row removed from the MCP table and GET /api/v1/datasources/{name}/schema from the introspection REST row (the bundled whole-schema snapshot is gone — table listings stay lightweight); MCP count 18 → 17 pending the schemas listing. |
| 2026-09-02 | v2.8 | MCP audit (052) | §10.1: cross-link added — the shared audit_log also carries the MCP tool events (mcp.tool.called per call, mcp.tool.write per mutating call, node runs included), authored by MCP §14 and registered in Enums §15. No auth events changed. |
| 2026-09-05 | v2.5 | 074 key kinds | New §7.7: every API key gains a KIND (user | endpoint). An endpoint key carries no scopes — its authority is its rows in endpoint_key_bindings, walked from the most specific ancestor of the request path, where the first node carrying any binding decides and a deeper binding REPLACES a shallower one (R-EP2). An endpoint key with no binding on any ancestor authorises nothing; it reaches published endpoints and the cursor of executions it started, and is refused everywhere else — centrally in ScopeInterceptor, and again in McpAuthFilter because /mcp is a servlet the interceptor never sees. §7.6 gains two REST rows and four MCP tools. |
| 2026-09-07 | v2.13 | 089 dp-lake registry | §7.6 MCP table: lake_tables_register, lake_tables_import and lake_tables_unregister join the author row (28 → 31 tools) — the datasource-mutation floor, and mutating a GLOBAL datasource's registry is admin-only as a workspaces D8 rule inside the shared registry service, not a scope. §7.6 REST table gains the lake-table writes row at author; the registry listing GET /api/v1/datasources/{name}/lake-tables is covered by the existing metadata-read row. |