feat(trino): let every duckgres login authenticate to Trino - #1139
Merged
Merged
Conversation
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
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.
Per-user logins authenticate as <database_name>.<username>, but usage
metering and the admin console still mapped a Trino principal to an org
by exact match on the bare database_name. A per-user login's queries
were silently dropped from usage events and shown with no org in the
console. Both now resolve through configstore.NewTrinoPrincipalOwners,
built from the same principals the auth files project, so a principal
outside the password file is never attributed to anyone. The usage
team resolver gets the duckgres username rather than the principal.
A Trino client URL may now use {database_name} as its leading host label.
The org detail then advertises <database_name>.<domain> with username
root: the Trino fork qualifies a login with the org its host names, so a
user connects with the same host and username on both engines.
The Trino e2e lane asserts per-user logins end to end: authentication
with the pgwire password, own-catalog reads, cross-tenant denial, org
attribution in the admin query list, and removal on disable. The
host-qualified login is asserted when TRINO_HOST_QUALIFIED_DOMAIN is set,
and logged as skipped until the lane's pinned image carries the fork
change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fuziontech
force-pushed
the
trino-per-user-auth
branch
from
September 17, 2026 00:18
8ecd760 to
b63e92f
Compare
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.Update: rebased on main, plus org attribution and per-org hosts
Rebased onto current
main. The conflicts were append-only test additions intrino_provisioner_test.goandpolicy_test.go. The second commit adds three things.trino_usage_collector.go) and the admin console both looked up a query's Trino user by exact match on the baredatabase_name. A query byacme.analystwas therefore silently left out of usage events and shown with no org in the console. Both now useconfigstore.NewTrinoPrincipalOwners, which indexes exactly the principals the auth files project. The team resolver now gets the duckgres username instead of the Trino principal.{database_name}as its leading host label, e.g.https://{database_name}.dw.us.postwh.com. The org detail then advertises that org's own host with usernameroot. This pairs with Qualify password logins with the tenant named by the host trino#49, which qualifies a login with the org its host names, so users type the same host and username on Duckgres and Trino. Nothing changes until a deployment sets the placeholder, and it should only be set once that host actually routes to Trino.tests/mw-dev/e2e/trino.sh). An org user authenticates as<db>.<user>with its pgwire password and reads its own catalog. It is denied cross-tenant, its queries are attributed to its org in the admin query list, and it stops authenticating once disabled. The host-qualified login assertion runs whenTRINO_HOST_QUALIFIED_DOMAINis set. Until the lane's pinned Trino image includes Fix psql catalog command compatibility tests #49, it logs an explicit skip, and the README says why.Pre-existing and unrelated:
TestPolicyDoesNotUseLinearScanalso fails on a cleanorigin/main, because of theeveryin the catalog-status rule.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.