Skip to content

feat: first-party role guard, optional user and organization on User - #31

Merged
Bccorb merged 2 commits into
mainfrom
feat/first-party-role-guard
Nov 1, 2026
Merged

Bccorb merged 2 commits into
mainfrom
feat/first-party-role-guard

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Closes #28.

What

  • First-party role check. roles=[...] on FastAPI RequireUser(adapter, roles=...), Django @require_auth(roles=...), and the core Adapter.authenticate / authenticate_request. Any listed role granted by any of the session's roles passes, matched with the existing role_grants_access (the port of roleGrantsAccess in @seamless-auth/types). Roles come from the verified access token only. A refusal raises the new InsufficientRole and answers 403 insufficient_role (FastAPI under detail, Django under error, as the existing guard codes are), without naming any roles. An empty list, a bare string or an empty entry is refused when the guard is built. Checks run in order: session (401), recovery (403), cross-site for cookie sessions (403), role (403). has_any_role(user, roles) is exported for checks inside a view.
  • User.organization_id: the org_id claim of the verified access token, for cookie and bearer sessions alike. The cookie's unsigned organizationId copy is not read.
  • Optional user. Adapter.optional_user(method, headers) returns the user exactly when authenticate_request with its defaults would, otherwise None (recovery sessions included). FastAPI OptionalUser(adapter) wraps it. Django user_of is now in __all__; it reads what require_auth attached and is documented as such.
  • Origin check skipped for bearer-transport /auth requests. Bearer transport reads no cookies, so there is no ambient session for a cross-site request to use, and the custom transport header needs a CORS preflight. This matches the JS, Go and Rust adapters. Cookie transport and cookie sessions on the guard keep the check.
  • README: a Roles section, OptionalUser, user_of, organization_id, and the bearer origin rule.

The same guard and field are landing in the Go and Rust adapters and in @seamless-auth/nextjs, with the same responses.

Checks

  • uv run ruff check .: all checks passed. uv run ruff format --check .: 39 files already formatted.
  • uv run mypy: no issues in 14 source files.
  • uv run pytest: 526 passed.
  • scripts/check-manifest.sh passes.

RequireUser(adapter, roles=[...]) and @require_auth(roles=[...]) admit a
session when any of its roles grants any listed role by role_grants_access,
and answer 403 insufficient_role otherwise without naming the roles. The
roles come from the verified access token. An empty roles list raises at
setup. The core check is Adapter.authenticate_request(roles=...), raising
InsufficientRole, with has_any_role for the match on its own.

User.organization_id comes from the verified access token's org_id claim,
never from the cookie copy.

FastAPI gains OptionalUser(adapter), which returns the User exactly when
RequireUser with its defaults would and None otherwise, backed by
Adapter.optional_user. Django exports user_of, which stays an accessor for
guarded views.

Refs #28
Bearer transport reads no cookies, so a cross-site request has no session
to ride on. The JS, Go and Rust adapters apply the check to cookie
sessions only; this matches them. Cookie sessions on the guard keep it.

Closes #28
@Bccorb
Bccorb merged commit 171babb into main Nov 1, 2026
7 checks passed
@Bccorb
Bccorb deleted the feat/first-party-role-guard branch November 1, 2026 21:29
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.

feat: first-party role guard, optional user and organization on User

1 participant