Skip to content

feat(auth): domain default grants — auto-grant a per-app role at login - #2

Open
honzasladek wants to merge 2 commits into
mainfrom
feat/domain-default-grants
Open

feat(auth): domain default grants — auto-grant a per-app role at login#2
honzasladek wants to merge 2 commits into
mainfrom
feat/domain-default-grants

Conversation

@honzasladek

@honzasladek honzasladek commented Jul 12, 2026

Copy link
Copy Markdown
Member

What this solves

HUMAN_EMAIL_DOMAINS already admits anyone on a company domain (self-provisioning at /auth/callback), but the new principal is created with zero grants — so every app action (.require(action)) keeps returning 403 until an admin hands out a role manually.

This PR adds an opt-in deploy var PROPUSTKA_DOMAIN_DEFAULT_GRANTS:

[{ "domain": "contember.com", "app": "revizor", "role": "user" }]

On every successful login, a user whose email domain matches gets the corresponding role grants ensured idempotently (grant-if-missing) — "everyone on the company domain is a user of app X" without manual granting.

Behavior / deliberate tradeoffs

  • Ensured at EVERY login, not just the first → config additions reach existing principals on their next sign-in. Removing an entry stops future ensures but does not revoke already-granted rows (delete them in the admin UI, or disable the principal).
  • If the principal already holds the role for the app, no new row is created — and that includes a scoped grant of the same role: an admin who deliberately narrowed a user to one scope does not get an unscoped widening behind their back (pinned by a test). A cross-app grant (app IS NULL) shadows the default as well.
  • Concurrent logins are handled by the idx_grants_uq_global unique index from migration 0003 (it covers exactly this grant shape) — the second INSERT fails on the unique violation, the callback swallows it, and no duplicate can appear.
  • granted_by stays NULL — it is an FK to principals and this grant has no human grantor (same as migrated rows).
  • The role does not have to exist in roles yet — resolution simply skips an unknown role; it takes effect once the app reconciles its schema.
  • A grant failure never blocks the login itself (just a console.warn).
  • The grant is derived from the IdP-verified email; on deployments with OIDC_REQUIRE_VERIFIED_EMAIL='false' this feature raises the impact of an unverified email from "principal with zero grants" to "principal with a role" — the same exposure as HUMAN_EMAIL_DOMAINS, one step further.
  • Default '[]' everywhere (local dev, oblaka, workflow) = feature off; no behavior change for existing deployments.

Changes

  • env.ts / services.ts — new JSON-typed var + parser (DomainDefaultGrant), domains lowercased at parse time, malformed input → [] (fail-safe for deployments without the var too)
  • auth/routes.tsensureDomainDefaultGrants() in handleCallback, awaited before the 302 (the app's first request follows the redirect immediately)
  • oblaka.ts + .github/workflows/deploy.yml — thread PROPUSTKA_DOMAIN_DEFAULT_GRANTS through
  • tests: 8 new cases in auth-routes.test.ts (grant + attributes, idempotency, existing admin grant, scoped-grant shadowing, outside the domain, email-casing, multiple entries, empty config)

bun test 307/307, typecheck clean across all packages, dprint/biome unchanged.

🤖 Generated with Claude Code

honzasladek and others added 2 commits July 12, 2026 12:50
…n (DOMAIN_DEFAULT_GRANTS)

A domain-admitted human (HUMAN_EMAIL_DOMAINS) self-provisions with zero
grants, so every app action still 403s until an admin grants a role by
hand. New opt-in deploy var PROPUSTKA_DOMAIN_DEFAULT_GRANTS
([{domain, app, role}]) closes that gap: /auth/callback ensures the
configured role grants (grant-if-missing, unscoped, non-expiring) for
every entry matching the verified email's domain.

- Ensured at EVERY login, not just the first — config additions reach
  existing principals on their next sign-in. Removing an entry stops
  future ensures but does not revoke already-granted rows.
- Skips when the principal already holds the role for the app (granted
  here earlier or by an admin) — no duplicate rows.
- granted_by stays NULL (FK to principals; no human grantor).
- The role need not exist yet — resolution ignores unknown roles until
  the app reconciles its schema.
- A grant failure logs a warning but never blocks the login.
- Default '[]' everywhere = feature off; no behavior change for
  existing deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… shadowing

Two review follow-ups: the domain compare ignores email casing, and a
SCOPED grant of the same role deliberately shadows the default (the
ensure must not widen an admin-narrowed user to an unscoped grant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@honzasladek
honzasladek requested a review from matej21 July 12, 2026 11:08
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