Skip to content

Authentication & role-based access control#321

Merged
robinskil merged 9 commits into
mainfrom
features/auth
Jun 27, 2026
Merged

Authentication & role-based access control#321
robinskil merged 9 commits into
mainfrom
features/auth

Conversation

@robinskil

Copy link
Copy Markdown
Collaborator

Summary

Adds authentication and role-based access control to Beacon: roles with grants on tables and dataset paths (globs), a single config-defined super-user, and pluggable authentication (local username/password + OIDC). All enforcement is opt-in and off by default, so existing deployments are unaffected until BEACON_AUTH_ENFORCE=true.

Built from scratch, modeled on the old #221 but adapted to the current codebase.

Model

  • One super-user, config-only. Defined solely by BEACON_ADMIN_USERNAME / BEACON_ADMIN_PASSWORD, checked directly at auth time (constant-time, Basic-only). It is not a stored user or role, so it cannot be created, changed, duplicated, or spoofed (OIDC bearer is never super) through SQL.
  • All SQL-created roles/users are read-only and never super. GRANT/DENY accept only SELECT; write + auth-management is reserved to the super-user.
  • Roles hold grant/deny rules (Privilege × Table | Path-glob | All), evaluated deny-wins, default-deny, with segment-aware path globs (example/*example_2/..., ** crosses /).
  • Beacon owns authorization; the IdP only supplies identity + role names.

What's included

  • beacon-auth crate: role model, Credential/AuthProvider/UserDirectory traits, AuthContext/AuthIdentity, argon2 passwords, providers — BasicAuthProvider (in-memory), SqliteAuthProvider/SqliteStore (persistent), OidcAuthProvider (JWT/JWKS, configurable claims), CompositeAuthProvider (Basic→local, Bearer→OIDC).
  • Runtime: run_query/explain_* take an AuthIdentity; read enforcement in statement_plan/authz.rs (walks TableScans → table/path targets, requires SELECT); writes/DDL keep the super-user gate.
  • SQL surface: CREATE/DROP USER/ROLE, GRANT ROLE … TO USER, GRANT/DENY <priv> [ON TABLE t | ON PATH 'glob' | ON ALL] TO ROLE, REVOKE [DENY] … FROM ROLE (super-user only).
  • Transports: resolve_identity HTTP middleware (Basic/Bearer → identity), super-user-gated admin routes; Flight SQL resolves identity via the runtime.
  • Config: AuthConfig{anonymous_enabled, enforce}, OidcConfig. New pinned deps: rusqlite, jsonwebtoken, reqwest (rustls), argon2.

Config

BEACON_AUTH_ENFORCE=true            # enable read authorization (default false)
BEACON_AUTH_ANONYMOUS_ENABLED=true  # seed the anonymous (read-only) user
BEACON_OIDC_ENABLED=true            # + _ISSUER / _JWKS_URL / _AUDIENCE / _ROLES_CLAIM / _USERNAME_CLAIM

Example:

CREATE ROLE reader;
GRANT SELECT ON PATH 'argo/**/*.nc' TO ROLE reader;
DENY  SELECT ON PATH 'argo/restricted/*' TO ROLE reader;
CREATE USER alice WITH PASSWORD '';
GRANT ROLE reader TO USER alice;

Tests

Full workspace suite green (671 passed, 0 failed). New coverage:

  • beacon-auth unit tests — role eval, deny-wins, segment-aware globs, argon2, SQLite persist/reload, OIDC claim mapping, composite routing, super-user-is-config-only.
  • beacon-core/tests/auth.rs — runtime end-to-end: authn (admin/anon/wrong-password/bearer), lifecycle via SQL, enforcement matrix with BEACON_AUTH_ENFORCE=true (table + path-glob grants, deny-wins, revoke, anonymous denial, super-user bypass, enforce-off compat, information_schema exemption), escalation/DDL gating, reserved super-user.
  • beacon-api HTTP transport tests — admin gate (401/403/200), anonymous read vs write, enforcement over HTTP.

The path-glob tests caught (and fixed) a real gap: read_* scans (SuperListingTable) were bypassing path enforcement.

Notes

  • OIDC validates bearer tokens (resource-server pattern); it does not perform the interactive login redirect. For Keycloak, point BEACON_OIDC_* at the realm (leave audience empty, roles claim realm_access.roles) and map admin users to roles you grant locally.

Introduce a new `beacon-auth` crate and wire it through the runtime, query
pipeline, and both transports (HTTP + Flight SQL), adding role-based access
to tables and dataset paths. All enforcement is opt-in and off by default,
so existing deployments are unaffected.

beacon-auth:
- Role model: Privilege x PrivilegeTarget{Table,Path,All}, deny-wins /
  default-deny evaluator, segment-aware path globs.
- Credential-typed AuthProvider trait; providers: BasicAuthProvider (argon2,
  in-memory), SqliteAuthProvider/SqliteStore (persistent), OidcAuthProvider
  (JWT/JWKS, configurable claims), CompositeAuthProvider (Basic->local,
  Bearer->OIDC). Beacon owns grants; the IdP supplies identity + role names.

Runtime/core:
- Runtime owns an AuthContext (bootstraps admin super-user + anonymous from
  config); run_query/explain_* take an AuthIdentity instead of is_super_user.
- Read enforcement (statement_plan/authz.rs): walks TableScans and requires
  SELECT on each table/path; super-user / enforce=off bypass.
- SQL surface: CREATE/DROP USER/ROLE, GRANT ROLE .. TO USER, GRANT/DENY
  <priv> [ON TABLE|PATH|ALL] TO ROLE, REVOKE [DENY] .. FROM ROLE; lowered to
  an Extension node so it inherits the super-user gate.

Transports:
- resolve_identity axum middleware (Basic/Bearer -> AuthIdentity); admin
  routes require a super-user. Flight SQL resolves identity via the runtime.

Config: AuthConfig{anonymous_enabled, enforce} and OidcConfig. New pinned
deps: rusqlite, jsonwebtoken, reqwest (rustls), argon2.
The `admin` role is the built-in super-user role (global ALL grant), now
treated like a Postgres bootstrap superuser: it is re-seeded on every startup
and protected against removal at runtime. `DROP ROLE admin` and revoking its
global ALL grant are rejected, so any principal holding it — local or from an
external IdP — is always a super-user. Add `SUPERUSER_ROLE` constant and use
it in bootstrap.
- AuthContext::create_role rejects the reserved super-user role name
  ('admin'); bootstrap seeds it through the raw RoleProvider instead.
- Add a runtime test proving a non-super-user cannot escalate: every
  auth-management statement (CREATE ROLE/USER, GRANT, GRANT ROLE) is
  rejected for a role-limited caller, a super-user can run them, and
  re-creating the reserved 'admin' role is rejected even for a super-user.
beacon-core/tests/auth.rs (17 tests): drive the real Runtime end to end —
authentication (admin/anonymous/wrong-password/bearer-without-oidc), user and
role lifecycle via SQL, the reserved super-user role, and the full read-
enforcement matrix with BEACON_AUTH_ENFORCE=true (table grants, deny-wins,
revoke, anonymous denial, super-user bypass, enforce-off compatibility,
information_schema exemption) plus privilege-escalation/DDL gating.

beacon-api axum::auth_http_tests (3 tests): drive the real router via
tower oneshot — the admin gate (401 / 403 / 200), anonymous read vs. write,
and read enforcement over HTTP (granted 200, ungranted 400).

Enable the test-util feature for these via a self dev-dependency
(beacon-core) and add tower (dev) for oneshot.
The path-glob integration tests surfaced a real enforcement gap: an ad-hoc
`read_*('<glob>')` scan resolves to a `SuperListingTable` (and external-table
scans to an `ExternalTable`), neither of which the authz resolver recognized —
so PATH grants/denies were silently a no-op for the most common dataset-query
pattern. Teach `scan_targets` about both wrappers (via new `table_paths()`
accessors) so path enforcement actually applies.

Tests (beacon-core/tests/auth.rs): copy a parquet fixture into the datasets
dir and query it via read_parquet under BEACON_AUTH_ENFORCE=true —
- a PATH grant matches only its prefix (other prefixes denied),
- globs are segment-aware (`*` stops at `/`, `**` recurses),
- DENY ON PATH wins over a broad SELECT grant.
…-only

There is now exactly ONE super-user, defined solely by BEACON_ADMIN_USERNAME /
BEACON_ADMIN_PASSWORD. It cannot be created, changed, or duplicated through SQL.

- AuthContext holds the super-user credential (set at bootstrap) and checks it
  first in `authenticate` (constant-time, Basic-only), returning a super
  identity without touching the provider/store. Everyone else — local users and
  OIDC principals — is always non-super.
- The super-user is no longer a role: the reserved `admin` role and the global-
  ALL -> super inference are removed. `bootstrap_auth` just sets the credential
  and seeds the (read-only) anonymous user.
- Roles are strictly read-only: `grant`/`deny` reject any non-SELECT privilege,
  so there is no way to mint another super-user or grant write access via a role.
- The super-user username is reserved: CREATE/DROP USER on it is rejected.

Tests updated across beacon-auth, beacon-core (runtime + integration), and
adjusted for the new model; full workspace suite green (671 passed).
…signature

PR #318 (merged into main) added this test using the old run_query(query, bool)
signature; this branch changed run_query to take an AuthIdentity. Update the
test's super-user calls to AuthIdentity::system().
@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.20455% with 209 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.30%. Comparing base (d972b8a) to head (5172775).

Files with missing lines Patch % Lines
beacon-auth/src/oidc.rs 53.71% 56 Missing ⚠️
beacon-auth/src/sqlite.rs 89.87% 33 Missing ⚠️
beacon-core/src/runtime.rs 83.63% 27 Missing ⚠️
beacon-core/src/parser/beacon_parser.rs 90.98% 21 Missing ⚠️
beacon-core/src/statement_plan/authz.rs 87.28% 15 Missing ⚠️
beacon-auth/src/context.rs 94.73% 13 Missing ⚠️
beacon-api/src/flight_sql/auth.rs 68.96% 9 Missing ⚠️
beacon-core/src/parser/statement.rs 80.00% 7 Missing ⚠️
beacon-auth/src/role.rs 97.74% 6 Missing ⚠️
beacon-auth/src/basic.rs 97.63% 3 Missing ⚠️
... and 7 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #321      +/-   ##
==========================================
+ Coverage   72.96%   74.30%   +1.33%     
==========================================
  Files         269      280      +11     
  Lines       34503    36298    +1795     
==========================================
+ Hits        25176    26972    +1796     
+ Misses       9327     9326       -1     
Files with missing lines Coverage Δ
beacon-api/src/auth/mod.rs 95.00% <ø> (+0.40%) ⬆️
beacon-api/src/axum/admin/mod.rs 82.60% <ø> (+82.60%) ⬆️
beacon-api/src/axum/client/mod.rs 95.00% <ø> (ø)
beacon-api/src/axum/router.rs 74.44% <100.00%> (+74.44%) ⬆️
beacon-api/src/flight_sql/service.rs 76.51% <100.00%> (-0.16%) ⬇️
beacon-auth/src/composite.rs 100.00% <100.00%> (ø)
beacon-auth/src/credential.rs 100.00% <100.00%> (ø)
beacon-auth/src/password.rs 100.00% <100.00%> (ø)
beacon-common/src/super_table.rs 52.87% <100.00%> (+5.25%) ⬆️
beacon-config/src/lib.rs 82.97% <100.00%> (+0.82%) ⬆️
... and 19 more

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robinskil robinskil merged commit ddf8414 into main Jun 27, 2026
5 checks passed
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.

1 participant