Repository navigation
The web dashboard and MCP server connect to a compose or bring-your-own store as least-privilege roles, not as its owner (#3914) - #3983
Merged
Conversation
…wn store as least-privilege roles, not as its owner (#3914) Outside managed mode both hosts connected with postgres.connectionString, the store owner, so a web session or an MCP token-holder had none of the viewer/mcp roles' secret-column carve, narrow write grants or statement_timeout backstop. - Compose: the service recognizes its own store (in a container, logged in as the store's bootstrap superuser) and provisions admin/viewer/mcp with the managed batch, under its own 'darling-compose' marker, keeping the generated passwords as owner-only files in a new darling-credentials volume. The hosts wait for the worker's verdict and connect as viewer and mcp; a refusal or failure falls back to the owner with the reason. - Bring-your-own: postgres.webConnectionString / postgres.mcpConnectionString (literal or env:/file:), resolved at host start; unset, the surface connects as the owner and a startup warning names what that gives up. provision-roles.sql creates mcp too, with managed's grants and carve, and gives viewer the custom_alert_rules write it lacked. - mcp gets a default SELECT on collect, so continuous aggregates created after provisioning are readable to the MCP tools without a restart. - README, provision-roles.sql, compose files and the get_store_query_stats text state the owner-identity behavior accurately. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
Resolves the StartupCommandTimeoutTests site total (dev's 32 after #3908, plus #3914's compose pre-read: 33) and keeps both README edits to the web dashboard paragraph (#3935's Offline wording, #3970's note). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
…a role it adopts was given (#3914) Security review F5. Step 1b re-asserted only LOGIN NOSUPERUSER, so a role the batch adopts (it already carries the marker) kept CREATEROLE, CREATEDB, REPLICATION, BYPASSRLS and any role membership granted to it since, each of which outranks the grants: pg_read_all_data undoes the secret-column carve. Step 1b now switches all five off by name, and a new section 11 revokes every membership in which admin, viewer or mcp is the member. The batch grants none (pinned: every GRANT in it is a privilege ON something), so revoking all of them is exact. The shared batch covers managed and compose alike. The revoke names the recorded grantor and cascades, measured on the bundled PostgreSQL 18: a superuser's plain REVOKE removes only grants recorded as the bootstrap superuser's (another grantor's survives with a WARNING), and RESTRICT fails the whole batch on a membership the member granted onward (2BP01). The managed batch's text pins change deliberately for the new attribute list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…not a blank secret (#3914) Security review F3, first half. The env branch of DarlingSecretSource.Resolve refused only a null or empty variable, so one set to whitespace resolved to the whitespace itself. The file branch already trims to nothing and refuses. A caller that reads a blank result as "not configured" (the web and MCP store logins) then treated the configured setting as unset and put the surface on the owner login. The env branch now refuses a blank value too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…credentials, and an earlier start's role instead of the owner (#3914) Security review, round 1: F1: a start that does not provision the roles (a refusal, a failure, or a collector that stood down first) no longer puts the web dashboard and the MCP server on the owner for the whole process. Each connects as its role with the credential an earlier start wrote, if that file passes the trust checks. The not-provisioned verdict carries the worker's credential directory; a stand-down uses the shipped one. The store is asked to accept that login before the host starts. If it refuses (role dropped or re-keyed), the surface stays down with the store's error and never becomes the owner. Only a surface with no trusted credential falls back to the owner, and its warning gives both reasons. The three cases log distinguishable lines. F2: the compose store must also be a dedicated cluster. The facts read lists databases other than the store's own, postgres and the templates; any such database refuses provisioning with an operator-readable reason, naming up to three. F4: the credentials directory is set back to 0700 before any file is read. A symbolic link, or a directory still reachable by others after the chmod, has none of its files read or written. One that was writable by others before the chmod has its files ignored this start and replaced after the commit. Each file must be a regular file (not a link, directory, device, pipe or socket) with no group or other bits. Managed .NET cannot read a file's owner; the comment on UntrustedComposeCredentialReason states that residual. F6: the Windows writer creates the temp file with its ACL applied at creation (DarlingFileSecurity.CreateHardenedFile, sharing HardenFile's ACL builder). It hardens the directory when creating it. It verifies the file is not readable by ordinary users before putting it in place, and re-hardens once before giving up. The read side now distrusts a file ordinary users can read. F7: the derived role login keeps the owner's ChannelBinding, RequireAuth, CheckCertificateRevocation and SslNegotiation. F3, second half: a configured web or MCP login that is set but resolves to blank keeps its surface down with an Error, never the owner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
ComposeStoreRolesLiveTests, on the bundled runtime: - a cluster holding another database is refused and gets no roles (F2); - a second start strips CREATEROLE/CREATEDB and every membership, including one granted by a non-bootstrap grantor and one the member granted onward, and the secret carve holds again (F5); - a refused start and a stand-down keep both real host entries on viewer/mcp with the earlier start's credentials (F1); - a role re-keyed by hand keeps its surface down with 28P01 (F1); - a missing credential falls back to the owner with both reasons (F1); - a credential file ordinary users can read is regenerated and never read (F6). The README's compose and security sections now describe the dedicated-cluster check, the earlier-credential behaviour, the trust checks and their residual, and the attribute and membership re-assert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…vilege-hosts Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata
enabled auto-merge (squash)
September 23, 2026 04:18
erikdarlingdata
added a commit
that referenced
this pull request
Sep 23, 2026
…3920, #3927, #3931, #3932, #3940, #3942, #3946, #3947, #3950, #3952, #3955, #3956, #3957, #3964, #3965, #3966, #3968, #3972, #3975, #3979, #3980, #3981, #3983, #3984, #3985) (#3989) The wave's fix PRs deliberately carried no CHANGELOG edits (parallel-agent hot-spot protocol); each agent reported its entry and this commit lands them together, byte-verified against origin/dev. Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
added a commit
that referenced
this pull request
Sep 23, 2026
… viewer login from its #3983 verdict (#3970) (#4002) The evaluator (#3285) only ever built its viewer-role pool from a managed Windows store's DPAPI credential, so a compose user's saved rules were authored but never fired. DarlingStoreLogins.ResolveComposeCustomAlertViewerAsync admits the compose verdict beside the managed gate -- this start's provisioned role, or a trusted credential an earlier start left, accepted before use -- but, unlike a host, never falls back to the owner login. A bring-your-own store stays out: its viewer role comes from tools/provision-roles.sql, which does not create config.record_custom_alert_resolution. Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This was referenced Sep 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3914.
Why
On a managed store, the web dashboard connects as
viewerand the MCP server asmcp. On every other store, both hosts connected withpostgres.connectionString. That is the collection login, which is the store owner. So a web session or an MCP token-holder on a compose or bring-your-own store had none of the role-level protections:statement_timeoutbackstop on composed queries.The README said otherwise.
Erik's ruling on the issue:
What changes
The compose store provisions its own
admin/viewer/mcproles. It runs the managed batch, and the web and MCP hosts connect asviewerandmcp.How the service knows the store is its own. In a container, the store login must be the cluster's bootstrap superuser (oid 10, the role
initdbcreated). The compose file's store image runsinitdb --username $POSTGRES_USERwith the service's login, so that is exactly the compose store. A superuser granted on someone else's cluster never is. Round 1 added a second condition: the cluster must hold no database besides the store's own,postgresand the templates (F2 below). This needs no new configuration, and an existing compose deployment gets the roles on its first start with the new image.Why not an env var or "any container". An adversarial design review showed both could re-key passwords that
tools/provision-roles.sqlset on a store the operator runs. That would break whatever logs in with them.Its own marker. The roles carry the comment
darling-compose, notdarling-managed. The script stampsdarling-managedtoo, so this is what lets the service manage only the roles it created. A same-named role the service did not create is left alone. Provisioning is then refused and the warning gives the reason. Since round 1, each surface first tries its role with the credentials an earlier start wrote, and falls back to the owner only when there are none (F1 below).Credentials (the choice the task asked me to state). Service-generated passwords in the
file:secret shape (Darling on Linux: Docker/compose distribution of the service + TimescaleDB store #1804): one plaintext file per role (pg-admin-credential,pg-viewer-credential,pg-mcp-credential)./var/lib/darling/credentials. The Dockerfile creates it at 0700; the service creates each file at 0600, then renames it into place.darling-credentialsvolume keeps them across container recreation. Without the volume they regenerate, and the marker makes re-asserting them safe.secrets:. That would have meant three new operator-created secret files, which is new configuration. Also, compose failsupwhen a declared secret file is missing, so existing deployments would break.Readiness. The worker publishes an in-process verdict only after the batch commits, and a container host waits for it. Every collection-blocking stand-down settles the verdict, so a host never waits for one that is not coming. A refused or failed provisioning keeps each surface on its role with an earlier start's credentials, and falls back to the owner with the reason only when there are none, rather than taking down the only Linux UI (F1 below).
Same batch, three differences (via a new
ProvisioningTarget):POSTGRES_USER/POSTGRES_DBstill works;darling-compose;REVOKE ALL … FROM PUBLICis left out, because people read the compose store with roles of their own and that statement would take theirCONNECT.The managed batch is byte-identical to before, pinned, apart from two comment words, the fix below, and round 1's F5 (the attribute list and the membership revoke, deliberately re-pinned).
Reload. The The compose statement_timeout only reaches the live roles on a restart, unlike every other config_service knob #2918 reload re-asserts the compose store's
statement_timeout, as on a managed store. It does so only when this process provisioned the roles.Bring-your-own:
postgres.webConnectionStringandpostgres.mcpConnectionString.env:/file:reference, likepostgres.connectionString.statement_timeoutbackstop.connectionString.tools/provision-roles.sqlnow createsmcptoo. It gets managed's exact grants and the same fail-closed column carve. This fixes a drift found on the way: the script'sviewernever got thecustom_alert_ruleswrite that the managed one has (#3285), so pointing the web dashboard at it would have 42501'd every rule edit. A new ungated test holds the script's single-table grants, default privileges and role settings toBuildProvisioningSql's output. A live test runs managed provisioning over the script's roles and asserts that no privilege changes.Fix to the managed batch (please look at this one):
mcpgetsALTER DEFAULT PRIVILEGES … IN SCHEMA collect GRANT SELECT … TO mcp.42501: permission denied for view, reproduced red on the bundled runtime before the fix). A container runs for months on one start.collectonly (no secrets). mcp still has no default write anywhere and no default read onconfig. The pin that forbade any mcp ADP is narrowed to exactly that.Docs.
postgresconfig table, the web and MCP identity paragraphs ("What a web token can reach", "The store-side identity is still…"), the Security & Least-Privilege Roles section, the troubleshooting paragraph, and the network-endpoints intro. The intro wrongly said MCP/web exposure is managed-only; a container honors it too.provision-roles.sqlheader. States the owner-identity behaviour.get_store_query_stats,StoreStatementStats,McpCommandDeadlines,DarlingStoreMetricsReader). The MCP description and the instructions got shorter, not longer.docker-compose.ymlgets the credentials volume, and its comments and the servicedarling.sample.jsonBYO block are updated.Lite: there are no store roles in DuckDB, so there is no parity item.
Security review, round 1
Seven findings, each fixed as the coordinator decided; the commits are on this branch.
postgresand the templates (bydatistemplate, andtemplate0/template1by name). If there is any, provisioning is refused with a reason naming up to three and counting the rest (control characters print as?).env:reference set to whitespace is an error, not a blank secret. The env branch usesIsNullOrWhiteSpace. InResolveUnmanagedAsync, a set setting that resolves blank from any branch logs an Error and keeps the surface down.darling-credentialsnamed volume is seeded root:root 0700 by the Dockerfile. A bind mount whose host directory another host user owns or can write to is outside what the check can see. The same residual is stated in the code comment onUntrustedComposeCredentialReasonand in the README.LOGIN NOSUPERUSER NOCREATEROLE NOCREATEDB NOREPLICATION NOBYPASSRLS, and the password clause is unchanged.format('REVOKE %I FROM %I', ...), which was measured wrong. The statement addsGRANTED BY <recorded grantor>andCASCADE. On the bundled PostgreSQL 18, a superuser's plain REVOKE removes only a grant recorded as the bootstrap superuser's; another grantor's survives with a WARNING (mutation run: 1 membership left). RESTRICT fails the whole batch on a membership the member granted onward (mutation run:2BP01: dependent privileges exist, provisioning failed).DarlingFileSecurity.CreateHardenedFilecreates the temp file withHardenFile's ACL at creation (FileSystemAclExtensions.Create,CreateNew). One ACL builder is now shared by both.IsReadableByOrdinaryUsersand re-hardens once before it gives up and leaves the old file in place.ChannelBinding,RequireAuth,CheckCertificateRevocationandSslNegotiation(all present in Npgsql 10.0.3).Options, the passfile and client certificates stay out.Not changed:
tools/provision-roles.sqldoes not get the F5 attribute list. The operator runs it on their own cluster, often as a managed-cloud admin that is not a true superuser, and switching offREPLICATION/BYPASSRLSthere is untested.Test plan
Security review, round 1 (after merging current dev, 5fb3750):
Darling.Tests, withDARLING_TEST_PGon a fresh bundled-runtime cluster andDARLING_TEST_PGRUNTIME: 12,926 run, 3 failed, 15 skipped.DarlingPgRuntimeVersionPinTestsx2,DarlingLiveStoreExtensionParityTests).Darling/artifacts/pg-runtime.zip, dated Jul 26) is PostgreSQL 18.4 with TimescaleDB 2.28.1, and dev pins 18.6 and 2.30.1. These are the same 3 as the first run above, so they fail on the zip, not on this change.DARLING_TEST_PG_TARGET, and similar.DarlingStoreLogins,DarlingManagedRoles,DarlingSecretSource,DarlingFileSecurity,ComposeStoreRolesLive,DarlingSecuritySplitLive,ScramVerifierLive,NotificationRoutesRung,CustomAlertResolveGrantLive,DarlingComposeTests,ProvisionRoles*,ComposeStatementTimeout*,StartupCommandTimeoutandControlPlaneReload.DocCommentHygieneand the census/meta classes pass: 205/205.format('REVOKE %I FROM %I', ...), the live compose test fails with2BP01: dependent privileges exist, and provisioning fails. With CASCADE but noGRANTED BY, it fails with one membership left (the one granted by a non-bootstrap role). The shipped form passes.ComposeStoreRolesLiveTests, on the bundled runtime:operator_app_3914is refused with the exact reason. No role is created and no credential directory is made.CREATEROLE/CREATEDBon viewer,pg_read_all_dataon mcp (the control shows it readssmtp_encrypted_password),pg_monitoron admin granted by a non-bootstrap role, andpg_signal_backendthat mcp granted onward to an operator role are all gone after the next start. Passwords are unchanged, and mcp reading the secret column is 42501 again.ResolveUnmanagedAsync, in a container):DarlingSecretSourceand throughResolveUnmanagedAsync(Error, no login);CreateHardenedFile: a folder with inheritable Users read makes an ordinary file readable, while the hardened create is protected from creation and refuses an existing path;Before round 1:
Darling.Testsfull suite,DARLING_TEST_PGon the rig (55504) andDARLING_TEST_PGRUNTIME: 12,761 run, 3 failed. All three are runtime version pins (DarlingPgRuntimeVersionPinTestsx2,DarlingLiveStoreExtensionParityTests): the rig's runtime is TimescaleDB 2.28.1 and dev pins 2.30.1 since The store's TimescaleDB moves to 2.30.1 before the store opens, closing GHSA-hcfx-29v5-2rcw, with nothing ever reverted and its own alert (#3908) #3948, so they fail on the rig, not on this change. CI's fresh runtime covers them.After merging current dev (resume session): targeted classes 121/121; full
Darling.Tests12,781 run, 0 failed, 561 skipped (live-gated, no rig).New
ComposeStoreRolesLiveTests(bundled runtime):vieweris left alone and its password still logs in, and a non-bootstrap superuser is refused;darling-composeand writes owner-only files;DarlingWebHostServiceandDarlingMcpHostService, started as in a container withDARLING_CONFIG, build their store pools asviewerandmcp(15 sstatement_timeout;smtp_encrypted_password→ 42501);CONNECT;BYO live test: the shipped script runs clean; the two settings resolve through the host entry (literal and
file:) toviewer/mcp; an unreadable reference returns no login (not the owner); managed provisioning over the script's roles changes zero privileges.Red first: without the mcp collect default, the live test fails with
42501: permission denied for view zz_3914_after_provisioning.Mutation checks: pointing the web host's unmanaged branch back at
config.Postgres.ConnectionString, and setting_composeStoreRolesProvisionedoutside the provisioned branch, each fail their source pin.New
DarlingStoreLoginsTests: the decision table, the warning text (the three losses, per surface), the derived login (dropsOptions/passfile/client cert), refusals, both batch targets, the reload gate, the settings, and the host and worker wiring by containment.Updated pins, deliberately:
StartupCommandTimeoutTests: the batch site moves toProvisionRolesAsync, plus one bootstrap site for the pre-read (dev's 32 → 33), and the class prose no longer carries site counts that went stale;ControlPlaneReloadDurabilityTests: the sentinel is read in the core, and the catch-hygiene sweep now coversDarlingStoreLogins.cstoo;DarlingManagedRolesTests: the mcp ADP pin is narrowed;ProvisionRolesAclDriftTests: both read roles, plus a grant-parity test;NotificationRoutesRungTests,StoreStatementStatsTests.Docker compose smoke on Linux (the shipped compose file, the image built from this branch,
timescale/timescaledb:latest-pg17with TimescaleDB 2.30.1). Store logins while the web dashboard and MCP served requests (pg_stat_activity, client backends)::nightlyimageWhat else the after stack showed:
darling-composeandstatement_timeout=15s, log_min_duration_statement=5000ms, log_parameter_max_length=0, and PUBLIC keepsCONNECT.700 root(seeded from the image) and each file is600 root.Role passwords: unchanged./api/viewsreturns 200 asviewer, andget_store_query_stats(web and MCP) reportsconnected_as_owner: false.Upgrade path. The before stack (dev's compose file, no credentials volume, a store the nightly created), switched to this image, provisions on its first start and serves web and MCP as viewer and mcp.
--force-recreateregenerates and re-asserts all three passwords and keeps working.postgres.mcpConnectionStringpointed at the owner inside the container: the MCP host starts on it without waiting, and the "logs in as 'darling', the owner login" warning fires.Out-of-lane finds from the smoke, reproduced identically as the owner, so not caused by this change, and filed:
get_fleet_overviewand/api/fleetfail on a fresh store. Measured on 2.28.1 and 2.30.1.Filed rather than fixed:
viewerrole the evaluator needs. Enabling them would start firing rules users have already saved, and the ruling did not cover that.🤖 Generated with Claude Code
https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv