* feat(auth): internal roles + external→internal group mapping (foundation)
Two-layer authorization model: external Cloud Identity groups (org-managed)
get mapped onto internal Agnes-defined capabilities (app-managed) via an
admin-curated many-to-many table. Per-request permission checks read off
the session — no DB hit. Refresh requires re-login.
Schema v8 — new tables:
- internal_roles (id, key UNIQUE, display_name, description, owner_module, …)
— app-defined capabilities like 'context_admin'. Modules self-register at
import; the startup hook syncs the registry into this table (idempotent).
- group_mappings (id, external_group_id, internal_role_id FK, …)
— admin-managed bindings, UNIQUE(external_group_id, internal_role_id).
app/auth/role_resolver.py — new module:
- register_internal_role(key, display_name, description, owner_module)
Module-author entry point. lower_snake_case key, immutable, validated.
Same key + same fields = no-op (re-import safe); same key + different
fields = ValueError so two modules can't silently overwrite each other.
- sync_registered_roles_to_db(conn) — startup reconciliation. Inserts new
keys, updates drifted metadata, never deletes (preserves mappings).
- resolve_internal_roles(external_groups, conn) — joins group_mappings.
Sorted, deduplicated role-key list. Plugged into google_callback +
dev-bypass branch in get_current_user.
- require_internal_role('key') — FastAPI dependency factory; reads
session.internal_roles; 403 with explicit message when missing.
Resolution runs at sign-in only (Google callback + LOCAL_DEV_GROUPS change
in dev-bypass) — same semantics as session.google_groups. No admin UI yet;
mappings created via repository directly until follow-up PR ships UI.
21 new tests in tests/test_role_resolver.py: register/list, idempotency,
collision detection, key-format validation; sync insert/update/no-delete;
resolve empty/single/many-to-many/malformed-input; e2e via
LOCAL_DEV_GROUPS — gated endpoint allowed/denied + direct session-cookie
inspection. Full sweep: 178/178 passed across auth + db + repo tests.
(Two pre-existing test_catalog_export.py failures verified unrelated.)
* fix(auth): polish review feedback — first-request dev populate + PAT doc
Two follow-ups from a code-reviewer pass on the foundation commit before
opening the PR:
- Dev-bypass populates session["internal_roles"] on the first request
after sign-in, not just when external groups change. The previous
guard only resolved when groups_changed=True, which left a hole for
the LOCAL_DEV_GROUPS=`""` (explicit empty) flow: target=[],
current=None, neither write branch fires, internal_roles stays
unset, and require_internal_role then 403s with no roles to check
against. The OAuth callback writes session["internal_roles"]
unconditionally on sign-in (even []); dev-bypass now matches that
semantics. Adds a single-pass populate gated on the key being
absent from the session, so subsequent same-state requests still
no-op (cheap session lookup, no resolver call).
- Document that internal roles are session-scoped and PAT/headless
clients will get 403 from any require_internal_role(...) endpoint.
Same constraint already applies to session.google_groups (PAT JWTs
deliberately don't snapshot group memberships — they could change
after issuance with no way to re-sign), but the doc didn't surface
this — an operator pointing a CLI at a role-gated endpoint would
see 403 with no clue why. New "PAT and headless requests" section
spells out the constraint, the rationale, and the three escape
valves (use users.role for the gate; route through OAuth; wait for
the planned `da admin grant-role` CLI helper).
54 auth tests still pass locally (21 role-resolver + 33 existing
auth-provider).
* release(0.11.3): cut release for the internal-roles foundation
Bumps pyproject.toml 0.11.2 → 0.11.3 and renames CHANGELOG's
[Unreleased] section to [0.11.3] — 2026-04-26 (with a fresh
empty [Unreleased] skeleton appended). Adds the matching
[0.11.3] link reference at the bottom of CHANGELOG so the
section heading renders as a hyperlink to the GitHub release
page once the tag lands.
The bullet itself is unchanged content; the rephrasing of
"dev-bypass when external groups change" → "dev-bypass —
populates on first request and whenever external groups
change, mirroring the OAuth callback's always-write
semantics" reflects the polish committed in d590579, plus
the appended PAT/headless caveat pointing at the doc
section that landed in the same polish pass.
* fix(auth): address review feedback from Pavel — PAT-specific 403, audit logs, hardening
Round-2 polish over the internal-roles foundation, addressing Pavel's review
on PR #71. No behavior change for the happy path; tightens the safety rails
and makes the failure modes self-explanatory.
User-visible:
- require_internal_role now distinguishes "no session" (Bearer/PAT caller)
from "signed in but missing role" and surfaces a PAT-specific 403 detail
in the first case ("This endpoint needs an interactive (OAuth) session
— Bearer/PAT tokens do not carry session-resolved roles by design").
- docs/internal-roles.md documents deactivate+reactivate as the supported
"force re-resolve now" lever for users that can't be made to log out.
Internal hardening:
- INFO-level audit log on every successful resolve (OAuth callback +
dev-bypass) so a wrong-role complaint is debuggable from the log alone.
- Startup warning when SESSION_SECRET is shorter than 32 chars, matching
the existing JWT_SECRET_KEY gate — both HMAC surfaces sign trust-laden
state (session.internal_roles, session.google_groups, JWTs).
- _clear_registry_for_tests() now refuses to run unless TESTING=1 so a
stray import path in production can't drop the registered capabilities.
Tests:
- 4 new tests in tests/test_role_resolver.py covering: stale-session
contract after a mid-session mapping revoke (pin the documented
limitation), PAT 403 detail wording, OAuth pipeline data flow from
external groups to internal_roles, and the dev-bypass empty-list
fallback when the resolver raises.
CHANGELOG.md updated under [0.11.3] (### Changed + ### Internal).
CLAUDE.md schema doc bumped from v7 to v8.
---------
Co-authored-by: Claude <noreply@anthropic.com>
246 lines
9.7 KiB
Python
246 lines
9.7 KiB
Python
"""Google OAuth provider for FastAPI."""
|
|
|
|
import os
|
|
import logging
|
|
|
|
import httpx
|
|
from authlib.integrations.starlette_client import OAuth
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from starlette.config import Config as StarletteConfig
|
|
|
|
from app.auth.jwt import create_access_token
|
|
from app.auth._common import safe_next_path
|
|
from app.instance_config import get_allowed_domains
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/auth/google", tags=["auth"])
|
|
|
|
oauth = OAuth()
|
|
|
|
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
|
|
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
|
|
|
# Cloud Identity Groups API — requires the cloud-identity.groups.readonly scope
|
|
# AND an admin-enabled Cloud Identity / Google Workspace tenant.
|
|
#
|
|
# We use `groups/-/memberships:searchTransitiveGroups` (the "what groups does
|
|
# THIS USER belong to" endpoint), NOT `groups:search` (admin "find groups in
|
|
# org" endpoint, which requires Groups Reader admin role + 400s otherwise).
|
|
# The `-` in the path is a wildcard meaning "search across all groups in the
|
|
# caller's organization". Returns transitive memberships (incl. nested groups).
|
|
# Reference: https://cloud.google.com/identity/docs/reference/rest/v1/groups.memberships/searchTransitiveGroups
|
|
GROUPS_SEARCH_URL = (
|
|
"https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups"
|
|
)
|
|
|
|
|
|
def is_available() -> bool:
|
|
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
|
|
|
|
|
|
def _setup_oauth():
|
|
if not is_available():
|
|
return
|
|
oauth.register(
|
|
name="google",
|
|
client_id=GOOGLE_CLIENT_ID,
|
|
client_secret=GOOGLE_CLIENT_SECRET,
|
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
client_kwargs={
|
|
"scope": (
|
|
"openid email profile "
|
|
"https://www.googleapis.com/auth/cloud-identity.groups.readonly"
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
async def _fetch_google_groups(access_token: str, email: str) -> list[dict]:
|
|
"""Fetch Google Workspace groups the user belongs to.
|
|
|
|
Best-effort: returns [] on any failure (403 non-Workspace tenant, 401 expired
|
|
token, network error, etc.). Must never raise — callers rely on this to keep
|
|
the login flow working even when Cloud Identity is unavailable.
|
|
|
|
searchTransitiveGroups query syntax (CEL) requires:
|
|
- a `labels` membership predicate scoping the group type
|
|
- `member_key_id == '<email>'` for the user
|
|
Without `labels` Google returns 400 INVALID_ARGUMENT (silently — error
|
|
body just says "invalid argument").
|
|
Reference: https://cloud.google.com/identity/docs/reference/rest/v1/groups.memberships/searchTransitiveGroups
|
|
|
|
Why `security` label and not `discussion_forum`:
|
|
Empirically Keboola's Workspace lets a non-admin user read their own
|
|
group memberships ONLY for groups labelled as security groups
|
|
(`cloudidentity.googleapis.com/groups.security`). The same query with
|
|
`groups.discussion_forum` returns 403 "Insufficient permissions to
|
|
retrieve memberships" — the discussion_forum API needs admin scope.
|
|
In practice every Workspace group at Keboola carries BOTH labels, so
|
|
filtering on `security` returns the full membership list anyway.
|
|
Confirmed via scripts/debug/probe_google_groups.py.
|
|
"""
|
|
query = (
|
|
f"member_key_id == '{email}' "
|
|
f"&& 'cloudidentity.googleapis.com/groups.security' in labels"
|
|
)
|
|
params = {"query": query}
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
resp = await client.get(GROUPS_SEARCH_URL, params=params, headers=headers)
|
|
if resp.status_code >= 400:
|
|
# Log full body (not truncated) so future query-syntax / scope /
|
|
# tenant issues are diagnosable from one log line.
|
|
logger.warning(
|
|
"Google groups fetch returned %s for %s — query=%r — body=%s",
|
|
resp.status_code, email, query, resp.text,
|
|
)
|
|
return []
|
|
data = resp.json()
|
|
except Exception as e:
|
|
logger.warning("Google groups fetch failed for %s: %s", email, e)
|
|
return []
|
|
|
|
# searchTransitiveGroups returns `memberships`, not `groups`. Each membership
|
|
# carries the group identity in groupKey.id (email-shaped) + displayName.
|
|
groups = []
|
|
for m in data.get("memberships", []) or []:
|
|
group_key = (m.get("groupKey") or {}).get("id", "")
|
|
if not group_key:
|
|
continue
|
|
groups.append({
|
|
"id": group_key,
|
|
"name": m.get("displayName") or group_key,
|
|
})
|
|
return groups
|
|
|
|
|
|
_setup_oauth()
|
|
|
|
|
|
@router.get("/login")
|
|
async def google_login(request: Request):
|
|
"""Redirect to Google OAuth.
|
|
|
|
Honors `?next=<path>` by stashing the sanitized value in the session so the
|
|
callback can redirect there instead of the default /dashboard. The session
|
|
is the right stash — OAuth flow is stateful and the `state` param is
|
|
managed by Authlib.
|
|
"""
|
|
if not is_available():
|
|
return RedirectResponse(url="/login?error=google_not_configured")
|
|
next_path = safe_next_path(request.query_params.get("next"), default="")
|
|
if next_path:
|
|
request.session["login_next"] = next_path
|
|
else:
|
|
# Clear any stale value from an earlier aborted attempt.
|
|
request.session.pop("login_next", None)
|
|
redirect_uri = str(request.url_for("google_callback"))
|
|
return await oauth.google.authorize_redirect(request, redirect_uri)
|
|
|
|
|
|
@router.get("/callback")
|
|
async def google_callback(request: Request):
|
|
"""Handle Google OAuth callback."""
|
|
if not is_available():
|
|
return RedirectResponse(url="/login?error=google_not_configured")
|
|
|
|
try:
|
|
token = await oauth.google.authorize_access_token(request)
|
|
user_info = token.get("userinfo", {})
|
|
email = user_info.get("email", "")
|
|
name = user_info.get("name", "")
|
|
|
|
if not email:
|
|
return RedirectResponse(url="/login?error=no_email")
|
|
|
|
# Domain check
|
|
allowed = get_allowed_domains()
|
|
if allowed:
|
|
domain = email.split("@")[-1]
|
|
if domain not in allowed:
|
|
return RedirectResponse(url="/login?error=domain_not_allowed")
|
|
|
|
# Find or create user
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
import uuid
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
repo = UserRepository(conn)
|
|
user = repo.get_by_email(email)
|
|
if not user:
|
|
user_id = str(uuid.uuid4())
|
|
repo.create(id=user_id, email=email, name=name, role="analyst")
|
|
user = repo.get_by_email(email)
|
|
if not bool(user.get("active", True)):
|
|
return RedirectResponse(url="/login?error=deactivated")
|
|
finally:
|
|
conn.close()
|
|
|
|
# Fetch Google Workspace groups (best-effort — must not break login).
|
|
access_token = token.get("access_token", "")
|
|
if access_token:
|
|
try:
|
|
groups = await _fetch_google_groups(access_token, email)
|
|
request.session["google_groups"] = groups
|
|
except Exception as e:
|
|
logger.warning("Failed to store google_groups in session: %s", e)
|
|
request.session["google_groups"] = []
|
|
else:
|
|
request.session["google_groups"] = []
|
|
|
|
# Resolve external groups into internal role keys at sign-in. Cached
|
|
# on the session for the lifetime of this login — refresh requires
|
|
# re-login, same as the google_groups list itself.
|
|
try:
|
|
from app.auth.role_resolver import resolve_internal_roles
|
|
from src.db import get_system_db
|
|
conn = get_system_db()
|
|
try:
|
|
resolved = resolve_internal_roles(
|
|
request.session.get("google_groups", []) or [],
|
|
conn,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
request.session["internal_roles"] = resolved
|
|
# INFO-level audit so a wrong-role complaint is debuggable from
|
|
# the log alone — admin can correlate "user X claims they lost
|
|
# access" with the resolver output without replaying the request.
|
|
logger.info(
|
|
"Resolved %d internal role(s) for %s: %s",
|
|
len(resolved), email, resolved or "<none>",
|
|
)
|
|
except Exception as e:
|
|
# Resolver errors must not break login — fall back to no roles.
|
|
logger.warning("Failed to resolve internal_roles for %s: %s", email, e)
|
|
request.session["internal_roles"] = []
|
|
|
|
# Issue JWT
|
|
jwt_token = create_access_token(user["id"], user["email"], user["role"])
|
|
|
|
# Redirect to the post-login target. Prefer the value stashed by
|
|
# google_login() — re-sanitize defensively in case of session tampering.
|
|
target = safe_next_path(
|
|
request.session.pop("login_next", None), default="/dashboard"
|
|
)
|
|
|
|
# Redirect to target with token in cookie. Match password/email providers:
|
|
# Secure only when DOMAIN is set (production with TLS), so the cookie is
|
|
# actually sent over plain HTTP in dev.
|
|
use_secure = os.environ.get("DOMAIN", "") != ""
|
|
response = RedirectResponse(url=target, status_code=302)
|
|
response.set_cookie(
|
|
key="access_token", value=jwt_token,
|
|
httponly=True, max_age=86400, samesite="lax",
|
|
secure=use_secure,
|
|
)
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Google OAuth error: {e}")
|
|
return RedirectResponse(url="/login?error=oauth_failed")
|