From 9776f862f8373c59666d4f3cee3cf91e5fb10a46 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 22:48:07 +0200 Subject: [PATCH] Add explicit acting-in-place context --- src/govoplan_access/backend/api/v1/auth.py | 161 ++++++++++++++++++ .../backend/auth/dependencies.py | 32 +++- src/govoplan_access/backend/db/models.py | 2 + .../c7e0a3d6f9b2_v0113_acting_context.py | 40 +++++ .../backend/security/sessions.py | 2 + tests/test_auth_dependencies.py | 104 +++++++++++ webui/src/api/actingContext.ts | 31 ++++ .../acting-context/ActingContextSelector.tsx | 73 ++++++++ webui/src/module.ts | 9 +- 9 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 src/govoplan_access/backend/migrations/versions/c7e0a3d6f9b2_v0113_acting_context.py create mode 100644 webui/src/api/actingContext.ts create mode 100644 webui/src/features/acting-context/ActingContextSelector.tsx diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 6ac039a..7c4bd3a 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -1,9 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from functools import lru_cache from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( @@ -21,6 +23,7 @@ from govoplan_core.api.v1.schemas import ( ProfileUpdateRequest, RoleInfo, SwitchTenantRequest, + SwitchActingContextRequest, TenantInfo, TenantMembershipInfo, UserInfo, @@ -29,6 +32,12 @@ from govoplan_core.api.v1.schemas import ( from govoplan_core.core.access import AuthMethod, PrincipalRef from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.principal_cache import invalidate_auth_principals +from govoplan_core.core.idm import ( + CAPABILITY_IDM_DIRECTORY, + IdmDirectory, + OrganizationFunctionAssignmentRef, +) from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_core.admin.settings import get_system_settings from govoplan_core.audit.logging import audit_event @@ -57,6 +66,7 @@ from govoplan_access.backend.security.login_throttle import ( LoginThrottleDecision, build_login_throttle, ) +from govoplan_access.backend.auth.principal_cache import principal_summary_cache from govoplan_access.backend.security.sessions import ( authenticate_session_token, collect_user_authorization_context, @@ -72,6 +82,72 @@ from govoplan_access.backend.security.sessions import ( router = APIRouter(prefix="/auth", tags=["auth"]) +class ActingContextInfo(BaseModel): + assignment_id: str + acting_for_account_id: str + function_id: str + organization_unit_id: str + valid_from: datetime | None = None + valid_until: datetime | None = None + + +class ActingContextListResponse(BaseModel): + contexts: list[ActingContextInfo] = Field(default_factory=list) + active_assignment_id: str | None = None + + +def _acting_assignments( + request: Request, + *, + principal: ApiPrincipal, +) -> tuple[OrganizationFunctionAssignmentRef, ...]: + registry = getattr(request.app.state, "govoplan_registry", None) + if not isinstance(registry, PlatformRegistry) or not registry.has_capability( + CAPABILITY_IDM_DIRECTORY + ): + return () + directory = registry.require_capability(CAPABILITY_IDM_DIRECTORY) + if not isinstance(directory, IdmDirectory): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}", + ) + return tuple( + item + for item in directory.organization_function_assignments_for_account( + principal.account_id, + tenant_id=principal.tenant_id, + ) + if item.source == "acting_for" + and item.acting_for_account_id + and item.status == "active" + ) + + +def _acting_context_response( + assignments: tuple[OrganizationFunctionAssignmentRef, ...], + *, + active_assignment_id: str | None, +) -> ActingContextListResponse: + available_ids = {item.id for item in assignments} + return ActingContextListResponse( + contexts=[ + ActingContextInfo( + assignment_id=item.id, + acting_for_account_id=str(item.acting_for_account_id), + function_id=item.function_id, + organization_unit_id=item.organization_unit_id, + valid_from=item.valid_from, + valid_until=item.valid_until, + ) + for item in assignments + ], + active_assignment_id=( + active_assignment_id if active_assignment_id in available_ids else None + ), + ) + + @dataclass(slots=True) class AuthContext: account: Account @@ -484,6 +560,12 @@ def _shell_response( 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, + acting_assignment_id=( + auth_session.acting_assignment_id if auth_session else None + ), + acting_for_account_id=( + auth_session.acting_for_account_id if auth_session else None + ), email=account.email, display_name=account.display_name or user.display_name, ), @@ -603,6 +685,8 @@ def _me_response( api_key_id: str | None = None, session_id: str | None = None, service_account_id: str | None = None, + acting_assignment_id: str | None = None, + acting_for_account_id: str | None = None, include_system: bool = True, include_all_memberships: bool = True, identity_directory: IdentityDirectory | None = None, @@ -675,6 +759,8 @@ def _me_response( api_key_id=api_key_id, session_id=session_id, service_account_id=service_account_id, + acting_assignment_id=acting_assignment_id, + acting_for_account_id=acting_for_account_id, email=account.email, display_name=account.display_name or user.display_name, ).to_dict() @@ -794,6 +880,8 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = api_key_id=principal.api_key_id, session_id=principal.session_id, service_account_id=principal.principal.service_account_id, + acting_assignment_id=principal.acting_assignment_id, + acting_for_account_id=principal.acting_for_account_id, include_system=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None, identity_id=principal.principal.identity_id, @@ -914,6 +1002,79 @@ def switch_tenant( ) +@router.get("/acting-contexts", response_model=ActingContextListResponse) +def list_acting_contexts( + request: Request, + principal: ApiPrincipal = Depends(get_api_principal), +) -> ActingContextListResponse: + if principal.auth_session is None: + return ActingContextListResponse() + assignments = _acting_assignments(request, principal=principal) + return _acting_context_response( + assignments, + active_assignment_id=principal.auth_session.acting_assignment_id, + ) + + +@router.post("/switch-acting-context", response_model=ActingContextListResponse) +def switch_acting_context( + payload: SwitchActingContextRequest, + request: Request, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> ActingContextListResponse: + if principal.auth_session is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="API keys cannot select an acting context.", + ) + assignments = _acting_assignments(request, principal=principal) + selected = next( + (item for item in assignments if item.id == payload.assignment_id), + None, + ) + if payload.assignment_id is not None and selected is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The acting assignment is not currently available to this account.", + ) + previous = principal.auth_session.acting_assignment_id + principal.auth_session.acting_assignment_id = selected.id if selected else None + principal.auth_session.acting_for_account_id = ( + selected.acting_for_account_id if selected else None + ) + principal.auth_session.last_seen_at = utc_now() + session.add(principal.auth_session) + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + action="access.acting_context.switched", + object_type="idm_function_assignment", + object_id=selected.id if selected else previous, + details={ + "previous_assignment_id": previous, + "selected_assignment_id": selected.id if selected else None, + "acting_for_account_id": ( + selected.acting_for_account_id if selected else None + ), + }, + ) + invalidate_auth_principals( + session, + tenant_id=principal.tenant_id, + source_module="access", + resource_type="acting_context", + resource_id=principal.auth_session.id, + ) + session.commit() + principal_summary_cache.clear() + return _acting_context_response( + assignments, + active_assignment_id=selected.id if selected else None, + ) + + @router.post("/logout") def logout( response: Response, diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index e37d1de..e429fb8 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -110,6 +110,10 @@ def _build_principal_ref( 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) + acting_assignment = next( + (item for item in idm_assignments if item.source == "acting_for"), + None, + ) return PrincipalRef( account_id=account.id, membership_id=user.id, @@ -123,6 +127,10 @@ def _build_principal_ref( 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, + acting_assignment_id=(acting_assignment.id if acting_assignment else None), + acting_for_account_id=( + acting_assignment.acting_for_account_id if acting_assignment else None + ), email=account.email, display_name=account.display_name or user.display_name, ) @@ -436,6 +444,7 @@ def _refresh_principal_context( tenant_id=context.tenant.id, idm_directory=idm_directory, organization_directory=organization_directory, + auth_session=context.auth_session, ) include_system = context.auth_session is not None authorization_context = collect_user_authorization_context( @@ -523,6 +532,7 @@ def _resolve_api_key_principal_context( tenant_id=api_key.tenant_id, idm_directory=idm_directory, organization_directory=organization_directory, + auth_session=None, ) authorization_context = collect_user_authorization_context( session, @@ -612,6 +622,7 @@ def _resolve_session_principal_context( tenant_id=user.tenant_id, idm_directory=idm_directory, organization_directory=organization_directory, + auth_session=auth_session, ) authorization_context = collect_user_authorization_context( session, @@ -657,8 +668,26 @@ def _principal_idm_context( tenant_id: str, idm_directory: IdmDirectory | None, organization_directory: OrganizationDirectory | None, + auth_session: AuthSession | None = None, ) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]: - idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id) + available = _idm_assignments_for_account( + idm_directory, + account.id, + tenant_id=tenant_id, + ) + selected_assignment_id = ( + auth_session.acting_assignment_id if auth_session is not None else None + ) + idm_assignments = tuple( + item + for item in available + if item.source != "acting_for" + or ( + selected_assignment_id == item.id + and auth_session is not None + and auth_session.acting_for_account_id == item.acting_for_account_id + ) + ) idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) return idm_assignments, idm_roles @@ -902,6 +931,7 @@ def _resolve_delegated_user_automation( tenant_id=request.tenant_id, idm_directory=idm_directory, organization_directory=organization_directory, + auth_session=None, ) authorization_context = collect_user_authorization_context( session, diff --git a/src/govoplan_access/backend/db/models.py b/src/govoplan_access/backend/db/models.py index 30fdb3b..e8fec73 100644 --- a/src/govoplan_access/backend/db/models.py +++ b/src/govoplan_access/backend/db/models.py @@ -427,6 +427,8 @@ class AuthSession(AccessBase, TimestampMixin): tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True) account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + acting_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True) token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) diff --git a/src/govoplan_access/backend/migrations/versions/c7e0a3d6f9b2_v0113_acting_context.py b/src/govoplan_access/backend/migrations/versions/c7e0a3d6f9b2_v0113_acting_context.py new file mode 100644 index 0000000..af1e14f --- /dev/null +++ b/src/govoplan_access/backend/migrations/versions/c7e0a3d6f9b2_v0113_acting_context.py @@ -0,0 +1,40 @@ +"""Persist explicit interactive acting-in-place context. + +Revision ID: c7e0a3d6f9b2 +Revises: b6d9f2a5c8e1 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "c7e0a3d6f9b2" +down_revision = "b6d9f2a5c8e1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "access_auth_sessions", + sa.Column("acting_assignment_id", sa.String(length=36), nullable=True), + ) + op.add_column( + "access_auth_sessions", + sa.Column("acting_for_account_id", sa.String(length=36), nullable=True), + ) + op.create_index( + op.f("ix_access_auth_sessions_acting_assignment_id"), + "access_auth_sessions", + ["acting_assignment_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_access_auth_sessions_acting_assignment_id"), + table_name="access_auth_sessions", + ) + op.drop_column("access_auth_sessions", "acting_for_account_id") + op.drop_column("access_auth_sessions", "acting_assignment_id") diff --git a/src/govoplan_access/backend/security/sessions.py b/src/govoplan_access/backend/security/sessions.py index f043c0d..89c0c62 100644 --- a/src/govoplan_access/backend/security/sessions.py +++ b/src/govoplan_access/backend/security/sessions.py @@ -165,6 +165,8 @@ def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tena raise LookupError("The account does not have an active membership in this tenant.") auth_session.tenant_id = membership.tenant_id auth_session.user_id = membership.id + auth_session.acting_assignment_id = None + auth_session.acting_for_account_id = None auth_session.last_seen_at = utc_now() session.add(auth_session) session.flush() diff --git a/tests/test_auth_dependencies.py b/tests/test_auth_dependencies.py index f8bc4aa..ea02779 100644 --- a/tests/test_auth_dependencies.py +++ b/tests/test_auth_dependencies.py @@ -11,6 +11,7 @@ from starlette.requests import Request from govoplan_access.backend.auth.dependencies import ( _extract_token, + _principal_idm_context, _requires_csrf, _resolve_legacy_principal_context, _resolve_legacy_principal_ref, @@ -21,6 +22,7 @@ from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor from govoplan_core.core.principal_cache import invalidate_auth_principals +from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.db.base import Base from govoplan_core.security.time import utc_now from govoplan_core.settings import settings @@ -156,6 +158,108 @@ class AuthDependencyTests(unittest.TestCase): ) engine.dispose() + def test_acting_assignment_requires_exact_session_selection(self) -> None: + class Directory: + def organization_function_assignments_for_account( + self, + account_id: str, + *, + tenant_id: str | None = None, + effective_at=None, + ): + del account_id, effective_at + return ( + OrganizationFunctionAssignmentRef( + id="direct-1", + tenant_id=str(tenant_id), + identity_id="identity-1", + account_id="account-1", + function_id="function-direct", + organization_unit_id="unit-1", + ), + OrganizationFunctionAssignmentRef( + id="acting-1", + tenant_id=str(tenant_id), + identity_id="identity-1", + account_id="account-1", + function_id="function-acting", + organization_unit_id="unit-1", + source="acting_for", + acting_for_account_id="represented-1", + ), + ) + + engine = create_engine("sqlite:///:memory:") + create_scope_tables(engine) + AccessBase.metadata.create_all(bind=engine) + SessionLocal = sessionmaker(bind=engine) + try: + with SessionLocal() as session: + tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1") + account = Account( + id="account-1", + email="actor@example.test", + normalized_email="actor@example.test", + ) + user = User( + id="user-1", + tenant_id=tenant.id, + account_id=account.id, + email=account.email, + ) + auth_session = AuthSession( + id="session-1", + tenant_id=tenant.id, + user_id=user.id, + account_id=account.id, + token_hash="token-hash", + expires_at=utc_now() + timedelta(hours=1), + ) + session.add_all((tenant, account, user, auth_session)) + session.flush() + + ordinary, _ = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=tenant.id, + idm_directory=Directory(), # type: ignore[arg-type] + organization_directory=None, + ) + self.assertEqual([item.id for item in ordinary], ["direct-1"]) + + auth_session.acting_assignment_id = "acting-1" + auth_session.acting_for_account_id = "represented-1" + selected, _ = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=tenant.id, + idm_directory=Directory(), # type: ignore[arg-type] + organization_directory=None, + auth_session=auth_session, + ) + self.assertEqual( + [item.id for item in selected], + ["direct-1", "acting-1"], + ) + + auth_session.acting_for_account_id = "wrong-account" + mismatched, _ = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=tenant.id, + idm_directory=Directory(), # type: ignore[arg-type] + organization_directory=None, + auth_session=auth_session, + ) + self.assertEqual([item.id for item in mismatched], ["direct-1"]) + finally: + AccessBase.metadata.drop_all(bind=engine) + scope_registry.metadata.drop_all(bind=engine) + engine.dispose() + if __name__ == "__main__": unittest.main() diff --git a/webui/src/api/actingContext.ts b/webui/src/api/actingContext.ts new file mode 100644 index 0000000..a926da0 --- /dev/null +++ b/webui/src/api/actingContext.ts @@ -0,0 +1,31 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type ActingContext = { + assignment_id: string; + acting_for_account_id: string; + function_id: string; + organization_unit_id: string; + valid_from?: string | null; + valid_until?: string | null; +}; + +export type ActingContextList = { + contexts: ActingContext[]; + active_assignment_id?: string | null; +}; + +export function fetchActingContexts(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/auth/acting-contexts", { + cache: "no-store" + }); +} + +export function switchActingContext( + settings: ApiSettings, + assignmentId: string | null +): Promise { + return apiFetch(settings, "/api/v1/auth/switch-acting-context", { + method: "POST", + body: JSON.stringify({ assignment_id: assignmentId }) + }); +} diff --git a/webui/src/features/acting-context/ActingContextSelector.tsx b/webui/src/features/acting-context/ActingContextSelector.tsx new file mode 100644 index 0000000..21cd260 --- /dev/null +++ b/webui/src/features/acting-context/ActingContextSelector.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { + fetchMe, + type ActingContextSelectorProps +} from "@govoplan/core-webui"; +import { + fetchActingContexts, + switchActingContext, + type ActingContext +} from "../../api/actingContext"; + +export default function ActingContextSelector({ + settings, + auth, + onAuthChange +}: ActingContextSelectorProps) { + const [contexts, setContexts] = useState([]); + const [activeId, setActiveId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + void fetchActingContexts(settings) + .then((response) => { + if (!active) return; + setContexts(response.contexts); + setActiveId(response.active_assignment_id ?? ""); + }) + .catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : "Acting context could not be loaded."); + }); + return () => { + active = false; + }; + }, [settings.apiBaseUrl, settings.accessToken, settings.apiKey, auth.active_tenant?.id, auth.tenant.id]); + + if (!contexts.length && !activeId) return null; + + async function selectContext(assignmentId: string) { + setBusy(true); + setError(""); + try { + const response = await switchActingContext(settings, assignmentId || null); + setContexts(response.contexts); + setActiveId(response.active_assignment_id ?? ""); + onAuthChange(await fetchMe(settings)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Acting context could not be changed."); + } finally { + setBusy(false); + } + } + + return ( + + ); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index c9644dc..85792b2 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,6 +1,7 @@ import { createElement, lazy } from "react"; -import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui"; +import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui"; import { adminReadScopes } from "@govoplan/core-webui"; +import ActingContextSelector from "./features/acting-context/ActingContextSelector"; import { generatedTranslations } from "./i18n/generatedTranslations"; const AdminPage = lazy(() => import("./features/admin/AdminPage")); @@ -42,8 +43,10 @@ export const accessModule: PlatformWebModule = { { to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }], routes: [ - { path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }] - + { path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }], + uiCapabilities: { + "access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability + } }; export default accessModule;