Add explicit acting-in-place context
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import (
|
from govoplan_core.api.v1.schemas import (
|
||||||
@@ -21,6 +23,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
ProfileUpdateRequest,
|
ProfileUpdateRequest,
|
||||||
RoleInfo,
|
RoleInfo,
|
||||||
SwitchTenantRequest,
|
SwitchTenantRequest,
|
||||||
|
SwitchActingContextRequest,
|
||||||
TenantInfo,
|
TenantInfo,
|
||||||
TenantMembershipInfo,
|
TenantMembershipInfo,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
@@ -29,6 +32,12 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
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_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
@@ -57,6 +66,7 @@ from govoplan_access.backend.security.login_throttle import (
|
|||||||
LoginThrottleDecision,
|
LoginThrottleDecision,
|
||||||
build_login_throttle,
|
build_login_throttle,
|
||||||
)
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
collect_user_authorization_context,
|
collect_user_authorization_context,
|
||||||
@@ -72,6 +82,72 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
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)
|
@dataclass(slots=True)
|
||||||
class AuthContext:
|
class AuthContext:
|
||||||
account: Account
|
account: Account
|
||||||
@@ -484,6 +560,12 @@ def _shell_response(
|
|||||||
auth_method=auth_method, # type: ignore[arg-type]
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
api_key_id=api_key.id if api_key else None,
|
api_key_id=api_key.id if api_key else None,
|
||||||
session_id=auth_session.id if auth_session 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,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
),
|
),
|
||||||
@@ -603,6 +685,8 @@ def _me_response(
|
|||||||
api_key_id: str | None = None,
|
api_key_id: str | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
service_account_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_system: bool = True,
|
||||||
include_all_memberships: bool = True,
|
include_all_memberships: bool = True,
|
||||||
identity_directory: IdentityDirectory | None = None,
|
identity_directory: IdentityDirectory | None = None,
|
||||||
@@ -675,6 +759,8 @@ def _me_response(
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
service_account_id=service_account_id,
|
service_account_id=service_account_id,
|
||||||
|
acting_assignment_id=acting_assignment_id,
|
||||||
|
acting_for_account_id=acting_for_account_id,
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
).to_dict()
|
).to_dict()
|
||||||
@@ -794,6 +880,8 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
|||||||
api_key_id=principal.api_key_id,
|
api_key_id=principal.api_key_id,
|
||||||
session_id=principal.session_id,
|
session_id=principal.session_id,
|
||||||
service_account_id=principal.principal.service_account_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_system=principal.auth_session is not None,
|
||||||
include_all_memberships=principal.auth_session is not None,
|
include_all_memberships=principal.auth_session is not None,
|
||||||
identity_id=principal.principal.identity_id,
|
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")
|
@router.post("/logout")
|
||||||
def logout(
|
def logout(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ def _build_principal_ref(
|
|||||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||||
if include_system_roles:
|
if include_system_roles:
|
||||||
role_ids.extend(role.id for role in authorization_context.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(
|
return PrincipalRef(
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
membership_id=user.id,
|
membership_id=user.id,
|
||||||
@@ -123,6 +127,10 @@ def _build_principal_ref(
|
|||||||
auth_method=auth_method, # type: ignore[arg-type]
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
api_key_id=api_key.id if api_key else None,
|
api_key_id=api_key.id if api_key else None,
|
||||||
session_id=auth_session.id if auth_session 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,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
)
|
)
|
||||||
@@ -436,6 +444,7 @@ def _refresh_principal_context(
|
|||||||
tenant_id=context.tenant.id,
|
tenant_id=context.tenant.id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=context.auth_session,
|
||||||
)
|
)
|
||||||
include_system = context.auth_session is not None
|
include_system = context.auth_session is not None
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
@@ -523,6 +532,7 @@ def _resolve_api_key_principal_context(
|
|||||||
tenant_id=api_key.tenant_id,
|
tenant_id=api_key.tenant_id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=None,
|
||||||
)
|
)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
@@ -612,6 +622,7 @@ def _resolve_session_principal_context(
|
|||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=auth_session,
|
||||||
)
|
)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
@@ -657,8 +668,26 @@ def _principal_idm_context(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
idm_directory: IdmDirectory | None,
|
idm_directory: IdmDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
auth_session: AuthSession | None = None,
|
||||||
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
) -> 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))
|
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||||
return idm_assignments, idm_roles
|
return idm_assignments, idm_roles
|
||||||
|
|
||||||
@@ -902,6 +931,7 @@ def _resolve_delegated_user_automation(
|
|||||||
tenant_id=request.tenant_id,
|
tenant_id=request.tenant_id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=None,
|
||||||
)
|
)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ class AuthSession(AccessBase, TimestampMixin):
|
|||||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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.")
|
raise LookupError("The account does not have an active membership in this tenant.")
|
||||||
auth_session.tenant_id = membership.tenant_id
|
auth_session.tenant_id = membership.tenant_id
|
||||||
auth_session.user_id = membership.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()
|
auth_session.last_seen_at = utc_now()
|
||||||
session.add(auth_session)
|
session.add(auth_session)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from starlette.requests import Request
|
|||||||
|
|
||||||
from govoplan_access.backend.auth.dependencies import (
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
_extract_token,
|
_extract_token,
|
||||||
|
_principal_idm_context,
|
||||||
_requires_csrf,
|
_requires_csrf,
|
||||||
_resolve_legacy_principal_context,
|
_resolve_legacy_principal_context,
|
||||||
_resolve_legacy_principal_ref,
|
_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_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
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.db.base import Base
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
@@ -156,6 +158,108 @@ class AuthDependencyTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
engine.dispose()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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<ActingContextList> {
|
||||||
|
return apiFetch<ActingContextList>(settings, "/api/v1/auth/acting-contexts", {
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchActingContext(
|
||||||
|
settings: ApiSettings,
|
||||||
|
assignmentId: string | null
|
||||||
|
): Promise<ActingContextList> {
|
||||||
|
return apiFetch<ActingContextList>(settings, "/api/v1/auth/switch-acting-context", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ assignment_id: assignmentId })
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<ActingContext[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
|
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 (
|
||||||
|
<label className="acting-context-selector" title={error || "Select whose authority is represented by this session."}>
|
||||||
|
<span>Acting as</span>
|
||||||
|
<select
|
||||||
|
aria-label="Acting context"
|
||||||
|
value={activeId}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => void selectContext(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Own account</option>
|
||||||
|
{contexts.map((context) => (
|
||||||
|
<option key={context.assignment_id} value={context.assignment_id}>
|
||||||
|
{context.function_id} / {context.organization_unit_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-3
@@ -1,6 +1,7 @@
|
|||||||
import { createElement, lazy } from "react";
|
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 { adminReadScopes } from "@govoplan/core-webui";
|
||||||
|
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
|
||||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
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 }],
|
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
||||||
|
|
||||||
routes: [
|
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;
|
export default accessModule;
|
||||||
|
|||||||
Reference in New Issue
Block a user