Add explicit acting-in-place context

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent e58dcbdd7b
commit 9776f862f8
9 changed files with 450 additions and 4 deletions
+161
View File
@@ -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,
@@ -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,
+2
View File
@@ -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)
@@ -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")
@@ -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()