Skip to content

Let every duckgres login authenticate to Trino - #1139

Open
fuziontech wants to merge 1 commit into
mainfrom
trino-per-user-auth
Open

Let every duckgres login authenticate to Trino#1139
fuziontech wants to merge 1 commit into
mainfrom
trino-per-user-auth

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Why

A tenant's only Trino credential was its org root password. ListTrinoEnabledOrgs joined duckgres_org_users on username = 'root' and wrote one password.db line per org, so giving someone Trino access meant handing them a shared production credential — the wart docs/design/trino-data-governance.md (#1125) names as the actual gap.

Everything downstream was already shaped for this: group.db is <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 (Go bcrypt.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:

  • Usernames go through an allowlist. duckgres validates a username as little more than "not empty", while password.db is <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 by TestBuildTrinoAuthFiles_RefusesUsernamesThatCouldInjectLines.
  • rejectPrincipalCollisions now also holds back orgs deriving the same Trino username. Valid database_names make this unreachable (DNS labels have no dot), but grandfathered rows may hold one, 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 — 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_user 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 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. TestScopedGroupStillCannotCrossTenants pins 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

  • Scoped logins get no write authority at all. duckgres has a read-only project login and a read/write one; only the read-only half is expressible in the policy today. This narrows project_user rather than widening project_reader. Making it writable means gating writes per-schema, not per-catalog.
  • The kill switch lags on Trino. A disabled user leaves password.db only when the projected Secret is re-read (kubelet sync + the group provider's file.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.
  • Service credentials (duckgres_service_grants) are deliberately not projected. Their TTL/revocation semantics deserve their own change and their own tests.
  • Document how Trino data governance works without a proxy #1125's Phases 1–2 are partly implemented here (via the password file rather than OIDC); that doc should be updated when it merges.

Review focus

controlplane/provisioner/opa/policy.rego is the tenant-isolation boundary. The new "Project scopes" section is the part to read closely — specifically that granting_groups is drawn from data.group_catalogs before any scope is consulted.

Testing

  • 10 new provisioner tests, 10 new policy/builder tests, 2 new Postgres-backed configstore tests — all green.
  • Scope tests mutation-checked: neutering scoped_group fails 5 of them, so they have teeth.
  • Existing tests pass unchanged, including TestBuildTrinoAuthFiles_UnchangedForOrgsWithoutUsers as the regression guard for every tenant on the cell today.
  • golangci-lint run (CI's config): 0 issues.
  • Pre-existing and unrelated: some controlplane/admin + controlplane/configstore Postgres tests fail locally with column "max_hot_idle_workers" does not exist — a stale local test container. Verified identical on clean origin/main.

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.
@fuziontech
fuziontech requested a review from a team September 1, 2026 01:27
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 0 5 0
E2E/journey files 0 0 0
Workflow files 0 0 0

Signals

  • Test cases: +25 / -0
  • Assertions: +91 / -2
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: neutral or increased

No coverage-reduction warnings detected.

@bill-ph bill-ph left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants