Release v0.1.5
This commit is contained in:
351
src/govoplan_access/backend/auth/dependencies.py
Normal file
351
src/govoplan_access/backend/auth/dependencies.py
Normal file
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
PermissionEvaluator,
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ApiPrincipal:
|
||||
"""Compatibility wrapper around the platform Principal DTO.
|
||||
|
||||
Existing routers still expect ORM ``account`` and ``user`` objects. New
|
||||
platform/module code should use the stable ID fields or
|
||||
``to_platform_principal()`` instead.
|
||||
"""
|
||||
|
||||
principal: PrincipalRef
|
||||
account: Account
|
||||
user: User
|
||||
api_key: ApiKey | None = None
|
||||
auth_session: AuthSession | None = None
|
||||
permission_evaluator: PermissionEvaluator | None = None
|
||||
|
||||
@property
|
||||
def account_id(self) -> str:
|
||||
return self.principal.account_id
|
||||
|
||||
@property
|
||||
def membership_id(self) -> str | None:
|
||||
return self.principal.membership_id
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
if self.principal.tenant_id is None:
|
||||
raise RuntimeError("Tenant principal has no active tenant id.")
|
||||
return self.principal.tenant_id
|
||||
|
||||
@property
|
||||
def scopes(self) -> frozenset[str]:
|
||||
return self.principal.scopes
|
||||
|
||||
@property
|
||||
def group_ids(self) -> frozenset[str]:
|
||||
return self.principal.group_ids
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return self.principal.auth_method
|
||||
|
||||
@property
|
||||
def api_key_id(self) -> str | None:
|
||||
return self.principal.api_key_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> str | None:
|
||||
return self.principal.session_id
|
||||
|
||||
@property
|
||||
def email(self) -> str | None:
|
||||
return self.principal.email
|
||||
|
||||
@property
|
||||
def display_name(self) -> str | None:
|
||||
return self.principal.display_name
|
||||
|
||||
def has(self, required_scope: str) -> bool:
|
||||
if self.permission_evaluator is not None:
|
||||
return self.permission_evaluator.has_scope(self.principal, required_scope)
|
||||
# Keep legacy scope aliases alive while current routers still use the
|
||||
# pre-platform permission catalogue.
|
||||
return scopes_grant_compatible(self.scopes, required_scope)
|
||||
|
||||
def to_platform_principal(self) -> PrincipalRef:
|
||||
return self.principal
|
||||
|
||||
|
||||
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
|
||||
if x_api_key:
|
||||
return x_api_key.strip(), "api_key"
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip(), "bearer"
|
||||
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
if cookie_token:
|
||||
return cookie_token.strip(), "cookie"
|
||||
return None, "none"
|
||||
|
||||
|
||||
def _requires_csrf(request: Request) -> bool:
|
||||
return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
|
||||
|
||||
def _principal_group_ids(session: Session, user: User) -> frozenset[str]:
|
||||
return frozenset(group.id for group in collect_user_groups(session, user))
|
||||
|
||||
|
||||
def _build_principal_ref(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant_id: str,
|
||||
scopes: list[str],
|
||||
auth_method: str,
|
||||
api_key: ApiKey | None = None,
|
||||
auth_session: AuthSession | None = None,
|
||||
) -> PrincipalRef:
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=_principal_group_ids(session, user),
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
)
|
||||
|
||||
|
||||
def _api_principal_from_ref(
|
||||
session: Session,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
permission_evaluator: PermissionEvaluator | None = None,
|
||||
) -> ApiPrincipal:
|
||||
account = session.get(Account, principal.account_id)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||
if (
|
||||
not account
|
||||
or not user
|
||||
or not tenant
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API principal")
|
||||
|
||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||
return ApiPrincipal(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
auth_session=auth_session,
|
||||
permission_evaluator=permission_evaluator,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_legacy_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
) -> PrincipalRef:
|
||||
token, source = _extract_token(request, authorization, x_api_key)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
|
||||
# API keys remain supported for CLI/automation. Their permissions are the
|
||||
# intersection of the key grant and the owner's current tenant roles.
|
||||
api_key = authenticate_api_key(session, token)
|
||||
if api_key:
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
or not user.is_active or not account.is_active or not tenant.is_active
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
user_scopes = collect_user_scopes(session, user, include_system=False)
|
||||
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
return _build_principal_ref(
|
||||
session,
|
||||
api_key=api_key,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=api_key.tenant_id,
|
||||
scopes=effective_scopes,
|
||||
auth_method="api_key",
|
||||
)
|
||||
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
if auth_session:
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
or not user.is_active or not account.is_active or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
|
||||
if source == "cookie" and _requires_csrf(request):
|
||||
header_token = request.headers.get("x-csrf-token")
|
||||
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
|
||||
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
|
||||
scopes = collect_user_scopes(session, user, include_system=True)
|
||||
session.commit()
|
||||
return _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=user.tenant_id,
|
||||
scopes=scopes,
|
||||
auth_method="session",
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
|
||||
def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
return registry if isinstance(registry, PlatformRegistry) else None
|
||||
|
||||
|
||||
def _principal_resolver_from_request(request: Request) -> PrincipalResolver | None:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER)
|
||||
if not isinstance(capability, PrincipalResolver):
|
||||
raise HTTPException(status_code=500, detail=f"Invalid capability: {CAPABILITY_ACCESS_PRINCIPAL_RESOLVER}")
|
||||
return capability
|
||||
|
||||
|
||||
def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator | None:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR)
|
||||
if not isinstance(capability, PermissionEvaluator):
|
||||
raise HTTPException(status_code=500, detail=f"Invalid capability: {CAPABILITY_ACCESS_PERMISSION_EVALUATOR}")
|
||||
return capability
|
||||
|
||||
|
||||
class LegacyPrincipalResolver:
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
if not isinstance(request, Request):
|
||||
raise TypeError("LegacyPrincipalResolver requires a FastAPI request")
|
||||
authorization = request.headers.get("authorization")
|
||||
x_api_key = request.headers.get("x-api-key")
|
||||
if isinstance(session, Session):
|
||||
return _resolve_legacy_principal_ref(request, session, authorization=authorization, x_api_key=x_api_key)
|
||||
with get_database().session() as managed_session:
|
||||
return _resolve_legacy_principal_ref(request, managed_session, authorization=authorization, x_api_key=x_api_key)
|
||||
|
||||
|
||||
class LegacyPermissionEvaluator:
|
||||
def scopes_grant(self, scopes, required_scope: str) -> bool:
|
||||
return scopes_grant_compatible(scopes, required_scope)
|
||||
|
||||
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
|
||||
return self.scopes_grant(principal.scopes, required_scope)
|
||||
|
||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||
allowed = self.has_scope(principal, required_scope)
|
||||
return AccessDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else f"Missing scope: {required_scope}",
|
||||
requirements=(required_scope,),
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
resolver = _principal_resolver_from_request(request)
|
||||
permission_evaluator = _permission_evaluator_from_request(request)
|
||||
if resolver is not None:
|
||||
principal = resolver.resolve_request(request, session=session)
|
||||
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
principal = _resolve_legacy_principal_ref(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
|
||||
def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None:
|
||||
mode = saved_maintenance_mode(session)
|
||||
if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(mode),
|
||||
)
|
||||
|
||||
|
||||
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
|
||||
return principal.has(required_scope)
|
||||
|
||||
|
||||
def require_scope(required_scope: str):
|
||||
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
|
||||
if not has_scope(principal, required_scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
|
||||
return principal
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_any_scope(*required_scopes: str):
|
||||
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
|
||||
if not any(has_scope(principal, required) for required in required_scopes):
|
||||
joined = ", ".join(required_scopes)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}")
|
||||
return principal
|
||||
|
||||
return dependency
|
||||
@@ -2,7 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, GroupMembership, Membership, Role, RoleBinding
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.permissions.evaluator import expand_scopes
|
||||
from govoplan_core.core.modules import PermissionDefinition, SubjectType
|
||||
|
||||
@@ -10,10 +19,10 @@ from govoplan_core.core.modules import PermissionDefinition, SubjectType
|
||||
def membership_group_ids(session: Session, *, tenant_id: str, membership_id: str) -> frozenset[str]:
|
||||
rows = (
|
||||
session.query(Group.id)
|
||||
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.filter(
|
||||
GroupMembership.tenant_id == tenant_id,
|
||||
GroupMembership.membership_id == membership_id,
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id == membership_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
@@ -28,22 +37,44 @@ def roles_for_subject(
|
||||
subject_id: str,
|
||||
tenant_id: str | None,
|
||||
) -> list[Role]:
|
||||
query = (
|
||||
session.query(Role)
|
||||
.join(RoleBinding, RoleBinding.role_id == Role.id)
|
||||
.filter(
|
||||
RoleBinding.subject_type == subject_type,
|
||||
RoleBinding.subject_id == subject_id,
|
||||
if tenant_id is None and subject_type == "account":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == subject_id, Role.tenant_id.is_(None))
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
)
|
||||
if tenant_id is None:
|
||||
query = query.filter(RoleBinding.tenant_id.is_(None), Role.tenant_id.is_(None))
|
||||
else:
|
||||
query = query.filter(RoleBinding.tenant_id == tenant_id, Role.tenant_id == tenant_id)
|
||||
return query.order_by(Role.name.asc()).all()
|
||||
return []
|
||||
if subject_type == "membership":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == subject_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
if subject_type == "group":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
GroupRoleAssignment.tenant_id == tenant_id,
|
||||
GroupRoleAssignment.group_id == subject_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def membership_roles(session: Session, membership: Membership) -> list[Role]:
|
||||
def membership_roles(session: Session, membership: User) -> list[Role]:
|
||||
roles_by_id = {
|
||||
role.id: role
|
||||
for role in roles_for_subject(
|
||||
@@ -67,7 +98,7 @@ def principal_scopes(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
membership: Membership | None,
|
||||
membership: User | None,
|
||||
include_system: bool,
|
||||
catalog: dict[str, PermissionDefinition],
|
||||
) -> list[str]:
|
||||
|
||||
Reference in New Issue
Block a user