intermittent commit
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -21,20 +23,29 @@ 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, Role, Tenant, User
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||
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_roles,
|
||||
collect_user_scopes,
|
||||
collect_user_authorization_context,
|
||||
UserAuthorizationContext,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, intersect_api_key_scopes
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResolvedPrincipalContext:
|
||||
principal: PrincipalRef
|
||||
account: Account
|
||||
user: User
|
||||
tenant: Tenant
|
||||
api_key: ApiKey | None = None
|
||||
auth_session: AuthSession | None = None
|
||||
|
||||
|
||||
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"
|
||||
@@ -50,10 +61,6 @@ 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,
|
||||
*,
|
||||
@@ -67,22 +74,32 @@ def _build_principal_ref(
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
extra_roles: tuple[Role, ...] = (),
|
||||
authorization_context: UserAuthorizationContext | None = None,
|
||||
include_system_roles: bool = False,
|
||||
) -> PrincipalRef:
|
||||
function_assignment_ids = list(collect_function_assignment_ids(session, user))
|
||||
if authorization_context is None:
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=include_system_roles,
|
||||
extra_roles=extra_roles,
|
||||
)
|
||||
function_assignment_ids = list(authorization_context.function_assignment_ids)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
roles = collect_user_roles(session, user)
|
||||
role_ids = [role.id for role in roles]
|
||||
role_ids.extend(role.id for role in extra_roles)
|
||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||
if include_system_roles:
|
||||
role_ids.extend(role.id for role in authorization_context.system_roles)
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=_principal_group_ids(session, user),
|
||||
group_ids=frozenset(group.id for group in authorization_context.groups),
|
||||
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
|
||||
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
|
||||
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
|
||||
delegation_ids=frozenset(authorization_context.function_delegation_ids),
|
||||
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,
|
||||
@@ -124,6 +141,21 @@ def _api_principal_from_ref(
|
||||
)
|
||||
|
||||
|
||||
def _api_principal_from_context(
|
||||
context: ResolvedPrincipalContext,
|
||||
*,
|
||||
permission_evaluator: PermissionEvaluator | None = None,
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=context.principal,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
api_key=context.api_key,
|
||||
auth_session=context.auth_session,
|
||||
permission_evaluator=permission_evaluator,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_legacy_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
@@ -134,76 +166,232 @@ def _resolve_legacy_principal_ref(
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> PrincipalRef:
|
||||
return _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
).principal
|
||||
|
||||
|
||||
def _resolve_legacy_principal_context(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> ResolvedPrincipalContext:
|
||||
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")
|
||||
|
||||
if source != "cookie":
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
context = _resolve_session_principal_context(
|
||||
request,
|
||||
session,
|
||||
token=token,
|
||||
source=source,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
|
||||
def _resolve_api_key_principal_ref(
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> PrincipalRef | None:
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
return context.principal if context is not None else None
|
||||
|
||||
|
||||
def _resolve_api_key_principal_context(
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
# 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")
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles)
|
||||
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",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
if api_key is None:
|
||||
return None
|
||||
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")
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=api_key.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
principal = _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",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
)
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||
|
||||
|
||||
def _resolve_session_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
source: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> PrincipalRef | None:
|
||||
context = _resolve_session_principal_context(
|
||||
request,
|
||||
session,
|
||||
token=token,
|
||||
source=source,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
return context.principal if context is not None else None
|
||||
|
||||
|
||||
def _resolve_session_principal_context(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
source: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
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")
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles)
|
||||
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",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
if auth_session is None:
|
||||
return None
|
||||
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":
|
||||
_verify_session_csrf(request, auth_session)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=user.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=True,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=user.tenant_id,
|
||||
scopes=scopes,
|
||||
auth_method="session",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=True,
|
||||
)
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
def _verify_session_csrf(request: Request, auth_session: AuthSession) -> None:
|
||||
if not _requires_csrf(request):
|
||||
return
|
||||
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")
|
||||
|
||||
|
||||
def _principal_idm_context(
|
||||
session: Session,
|
||||
*,
|
||||
user: User,
|
||||
account: Account,
|
||||
tenant_id: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
return idm_assignments, idm_roles
|
||||
|
||||
|
||||
def _idm_assignments_for_account(
|
||||
@@ -217,13 +405,6 @@ def _idm_assignments_for_account(
|
||||
return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id))
|
||||
|
||||
|
||||
def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]:
|
||||
merged = set(scopes)
|
||||
for role in roles:
|
||||
merged.update(role.permissions or [])
|
||||
return expand_scopes(merged)
|
||||
|
||||
|
||||
def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
return registry if isinstance(registry, PlatformRegistry) else None
|
||||
@@ -326,19 +507,32 @@ def resolve_api_principal(
|
||||
) -> ApiPrincipal:
|
||||
resolver = _principal_resolver_from_request(request)
|
||||
permission_evaluator = _permission_evaluator_from_request(request)
|
||||
if isinstance(resolver, LegacyPrincipalResolver):
|
||||
context = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=resolver._idm_directory,
|
||||
identity_directory=resolver._identity_directory,
|
||||
organization_directory=resolver._organization_directory,
|
||||
)
|
||||
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
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(
|
||||
context = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
|
||||
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
|
||||
Reference in New Issue
Block a user