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 __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,
+2
View File
@@ -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()
+104
View File
@@ -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()
+31
View File
@@ -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
View File
@@ -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;