Let every duckgres login authenticate to Trino - #1139
Open
fuziontech wants to merge 1 commit into
Open
Conversation
A tenant's only Trino credential was its org root password: the projection
joined duckgres_org_users on username = 'root' and wrote one password.db
line per org, so anyone wanting Trino access had to be handed a shared
production credential. Every login an org already has now authenticates to
Trino too, as <database_name>.<username>, with the bcrypt hash copied
through unchanged - it is the same hash pgwire verifies, so one password
works on both engines and nothing is re-hashed, minted or reset. The bare
<database_name> principal survives alongside them for service-to-service
use and for clients configured before this existed.
Trino's password file is ONE flat namespace per cell, while duckgres keys a
login on (org, username) and recovers the org from SNI - which a Trino login
carries no equivalent of. Qualifying the username is what makes that flat
namespace safe, and it brings three consequences that are load-bearing
rather than cosmetic:
* Usernames are projected through an allowlist. duckgres validates a
username as little more than "not empty", while password.db is
<user>:<hash> per line and group.db is <group>:<user>,<user>. A username
holding ':', ',' or a newline would let whoever can create org users
append arbitrary lines to those files, including a line for the admin
principal.
* rejectPrincipalCollisions now also holds back orgs deriving the same
Trino username. Valid database_names make that unreachable, but
grandfathered rows may hold a dot, and a duplicate password.db line lets
one org's user authenticate against another org's entry.
* The resource-group selector captures only up to the first '.'. The
previous (?<org>.*) matched the whole username, so every login would get
a private leaf carrying the full per-tenant limits and an org with ten
logins would quietly hold ten times its concurrency and memory budget.
Project-scoped logins keep their scope rather than being excluded or
silently widened. Such a login joins scope_<org>_team_<id> instead of the
org group; that group owns the same catalog in group_catalogs - so the
cross-tenant check is the unchanged check - and carries a new group_scopes
document narrowing it to the team's schemas and individually granted
relations. Scopes only ever subtract, which is the property that keeps this
off the tenant-isolation path: a bug in the new rules can widen access only
within one org's own catalog. The scope is read through OrgUserQueryAccess,
the same derivation the pgwire session path uses, so the two engines cannot
disagree about it, and a scoped row whose scope will not resolve is dropped
rather than projected unscoped.
Scoped logins get no write authority at all. duckgres has a read-only
project login and a read/write one, and only the read-only half is
expressible here today, so denying writes to both narrows project_user
rather than widening project_reader.
Two lags are worth stating: a disabled user leaves password.db only when the
projected Secret is re-read (kubelet sync plus the group provider's
file.refresh-period), so the kill switch takes effect on Trino in up to a
couple of minutes rather than instantly as it does on pgwire; and service
credentials (duckgres_service_grants) are deliberately not projected here -
their TTL and revocation semantics deserve their own change.
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
bill-ph
approved these changes
Sep 2, 2026
bill-ph
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed current head 8ecd760. No P0 blockers found.\n\nThe auth-file, OPA policy, collision, project-scope, and fail-closed behavior are covered by the added tests; required CI and security checks are green. Non-blocking operational note: disabled-user changes reach Trino on the kubelet/group-provider refresh interval rather than immediately, and that lag should remain clearly documented anywhere operators see the kill switch.\n\n— Robo Bill
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.
Why
A tenant's only Trino credential was its org root password.
ListTrinoEnabledOrgsjoinedduckgres_org_usersonusername = 'root'and wrote onepassword.dbline per org, so giving someone Trino access meant handing them a shared production credential — the wartdocs/design/trino-data-governance.md(#1125) names as the actual gap.Everything downstream was already shaped for this:
group.dbis<group>:<user>,<user>(plural), and the OPA bundle keys on group, not username, explicitly so per-user identity would not change the bundle shape. The blocker really was the five words in the join.What
Every login an org already has now authenticates to Trino as
<database_name>.<username>, with the bcrypt hash copied through unchanged — it is the same hash pgwire verifies (Gobcrypt.DefaultCost→$2a$10$…; Trino's floor is cost 8), so one password works on both engines and nothing is re-hashed, minted or reset. The bare<database_name>principal survives alongside them, so nothing in flight breaks.Trino's password file is one flat namespace per cell, while duckgres keys a login on
(org, username)and recovers the org from SNI — which a Trino login carries no equivalent of. Qualifying the username is what makes that flat namespace safe, and it brings three consequences that are load-bearing rather than cosmetic:password.dbis<user>:<hash>per line. A username holding:,,or a newline would let whoever can create org users append arbitrary lines to those files — including a line for the admin principal. Covered byTestBuildTrinoAuthFiles_RefusesUsernamesThatCouldInjectLines.rejectPrincipalCollisionsnow also holds back orgs deriving the same Trino username. Validdatabase_names make this unreachable (DNS labels have no dot), but grandfathered rows may hold one, and a duplicatepassword.dbline lets one org's user authenticate against another org's entry... The previous(?<org>.*)matched the whole username, so every login would get a private leaf carrying the full per-tenant limits — an org with ten logins would quietly hold ten times its concurrency and memory budget, with nothing erroring.Project scopes are kept, not dropped
A
project_reader/project_userjoinsscope_<org>_team_<id>instead of the org group. That group owns the same catalog ingroup_catalogs— so the cross-tenant check is the unchanged check — and carries a newgroup_scopesdocument narrowing it to the team's schemas plus individually granted relations.Scopes only ever subtract. That is the property that keeps this off the tenant-isolation path: every schema/table decision still requires a group that owns the catalog, so a bug anywhere in the new rules can widen access only within one org's own catalog, never across tenants.
TestScopedGroupStillCannotCrossTenantspins it.The scope is read through
OrgUserQueryAccess— the same derivation the pgwire session path uses — so Trino and DuckDB cannot disagree about which schemas a project login may read. A scoped row whose scope will not resolve is dropped rather than projected unscoped.Known limits, stated rather than discovered later
project_userrather than wideningproject_reader. Making it writable means gating writes per-schema, not per-catalog.password.dbonly when the projected Secret is re-read (kubelet sync + the group provider'sfile.refresh-period=60s), so up to a couple of minutes, versus instantly on pgwire. Same class of lag the governance doc already accepts for grants — but it applies to the kill switch, which reads as a stronger promise than it is. Worth saying wherever it is surfaced to operators.duckgres_service_grants) are deliberately not projected. Their TTL/revocation semantics deserve their own change and their own tests.Review focus
controlplane/provisioner/opa/policy.regois the tenant-isolation boundary. The new "Project scopes" section is the part to read closely — specifically thatgranting_groupsis drawn fromdata.group_catalogsbefore any scope is consulted.Testing
scoped_groupfails 5 of them, so they have teeth.TestBuildTrinoAuthFiles_UnchangedForOrgsWithoutUsersas the regression guard for every tenant on the cell today.golangci-lint run(CI's config): 0 issues.controlplane/admin+controlplane/configstorePostgres tests fail locally withcolumn "max_hot_idle_workers" does not exist— a stale local test container. Verified identical on cleanorigin/main.