Skip to content

feat(guard): add an organization-aware permission guard - #38

Merged
Bccorb merged 1 commit into
mainfrom
feat/require-permission
Nov 6, 2026
Merged

Bccorb merged 1 commit into
mainfrom
feat/require-permission

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Closes #32

Adds the permission guard from the wave 3 guard contract, on FastAPI and Django, for sessions and for verify-only tokens. Needs the auth API at v0.23 (seamless-auth-api #576, #577, both still open).

API

# FastAPI session
Depends(RequireUser(auth, permissions=["a", "b"], permission_mode="all"))
Depends(RequireUser(auth, permissions=["a"], organization=True))
Depends(RequireUser(auth, permissions=["a"], organization_param="organization_id"))

# Django session
@require_auth(permissions=["a"], permission_mode="any", organization=False, organization_param=None)

# Verify-only: RequireBearer(rs, guard), or under @require_bearer on Django
require_permission("a", "b", mode="any", organization=False, organization_param=None)

# Core
adapter.authenticate_request(method, headers, permissions=PermissionRule(("a",)), route_params={...})

User and VerifiedUser gain permissions, organization_roles, organization_permissions and permissions_omitted. New exports: PermissionRule, PermissionMode, InsufficientPermission, PermissionsUnavailable, require_permission, and seamless_auth.fastapi.PermissionHTTPException.

Decisions to review

  • Shape on FastAPI. A dependency can only raise, and FastAPI always wraps an HTTPException body in detail. A separate add_exception_handler step, once forgotten, would change the response shape, so on its first refusal the guard registers its handler in scope["starlette.exception_handlers"], a Starlette internal. A handler the application registered wins. If Starlette drops that key the status stays right and the body falls back to detail, and the tests fail on that upgrade.
  • The live read's body is stricter than a claim. A missing or non-list permissions or organizationPermissions, or an organizationId that is not a string or null, is the 503, since a missing list would otherwise read as holding nothing. Non-string entries in a list are ignored.
  • Omitted and route-bound. With permissions_omitted, the live read's organizationId decides, not the token's org_id, and the read happens even when the token's org_id already mismatches the route. An empty required list denies before any read.
  • Setup errors. An empty list denies at request time per the contract, unlike roles, which raises. A bare string, a name that is not a non-empty string, or the other options without permissions raise when the guard is built, because that guard would look scoped and check nothing.
  • No auth server URL case. Adapter refuses to construct without auth_server_url, so the contract's "no URL configured" 503 cannot occur on this adapter.

Existing FastAPI role guard

The divergence is real, documented in the README and pinned by tests/test_fastapi.py: RequireUser(auth, roles=[...]) answers {"detail":"insufficient_role"}. test_fastapi_the_session_role_guard_still_answers_under_detail records it. Not changed here. The same holds for RequireUser's unauthenticated, enrollment_required and cross_site_request_blocked, which the contract says stay as they are, so the permission guard's 401 and recovery refusal on FastAPI are {"detail":...}. The issue's "same 403 response shape as the existing role guard" does not hold on FastAPI; this follows the contract instead.

What was run

On Python 3.11:

  • uv run ruff check .: passed
  • uv run ruff format --check .: passed
  • uv run mypy: passed
  • uv run pytest: 1082 passed, 425 of them new. Also 1082 passed on Python 3.14
  • scripts/check-manifest.sh: matches
  • Hand-mutated eight checks in the new code (handler install, mode, organization scope, route binding, live 401, empty list, verify-only omitted, Django route params); each made tests fail
  • Started both reference apps under uvicorn against a local stub auth API (JWKS and the live endpoint) and requested the four routes with signed bearer tokens: the statuses and bodies matched the contract

What was not run

  • The conformance harness has not run against this branch. It needs seamless-cli's pending permission-guards specs and an API with #576 and #577. CI's conformance job does not set the contract and CI is unchanged, so its green result says nothing about these routes.
  • Cookie transport through the reference apps was not smoke tested by hand. The unit tests cover it on both frameworks.
  • Python 3.12 and 3.13 locally; CI covers them.

Release

release-please, from this feat commit. No version or changelog edit by hand. Hold the release until the API's v0.23 is out if the README's requirement should be true on publish.

RequireUser and require_auth take permissions, permission_mode, organization
and organization_param. Matching is exact. A token with permissions_omitted is
checked against a live read of /users/me/permissions, which fails closed with
503 permissions_unavailable. require_permission is the verify-only form, and
denies provider tokens until seamless-auth-oidc-provider#47.

User and VerifiedUser gain permissions, organization_roles,
organization_permissions and permissions_omitted. The reference apps gain the
four routes of the pending permission-guards contract.

Closes #32
@Bccorb Bccorb added this to the v0.23 milestone Nov 6, 2026
@Bccorb

Bccorb commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Conformance harness run locally against this branch and the API worktree on feat/organization-roles-in-token, with SEAMLESS_VERIFY_CONTRACTS=permission-guards,content-type-gate: full adapter suite, 39 on both FastAPI and Django passed. Spec is in the seamless-cli PR for the permission-guards contract.

@Bccorb
Bccorb merged commit 002ecb4 into main Nov 6, 2026
7 checks passed
@github-actions github-actions Bot mentioned this pull request Nov 6, 2026
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: requirePermission guard, organization aware

1 participant