5 Commits
Author SHA1 Message Date
zemion f2afcaa09c Release v0.1.8 2026-07-11 16:49:00 +02:00
zemion d08a41fb56 Release v0.1.7 2026-07-11 02:34:55 +02:00
zemion 002d12e417 Prefer canonical identity and organization directories 2026-07-11 01:13:53 +02:00
zemion 5b8baa6cde Make tenancy an optional access integration 2026-07-11 00:40:09 +02:00
zemion d6a0d6241d Expose resource access explanation API 2026-07-11 00:39:39 +02:00
23 changed files with 797 additions and 560 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
# GovOPlaN Access # GovOPlaN Access
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-access` is the platform module for GovOPlaN identity, `govoplan-access` is the platform module for GovOPlaN identity,
authentication, sessions, API keys, RBAC, groups, users, and access authentication, sessions, API keys, RBAC, groups, users, and access
administration. administration.
@@ -98,7 +102,7 @@ available:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply /mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
``` ```
## Development Install ## Development Install
+21
View File
@@ -64,6 +64,27 @@ Access declares tenancy as an optional module integration. It uses the
core-owned `core_scopes` table as the scope table, but it must not import core-owned `core_scopes` table as the scope table, but it must not import
`govoplan_tenancy` or require the tenancy package to start. `govoplan_tenancy` or require the tenancy package to start.
## Core-Only Startup Contract
A core-only installation must be able to start far enough to expose process
health, module metadata, and the unauthenticated shell needed for installation
or recovery work. It is not a usable authenticated product installation.
Authenticated product use requires the `access` module or another module that
provides the same kernel auth capabilities:
- `auth.apiPrincipalProvider`
- `auth.principalResolver`
- `auth.permissionEvaluator`
- `auth.tenantContextSwitcher`
Access contributes the default implementations for those capabilities plus the
interactive `/api/v1/auth/*` routes. Product modules should express auth needs
as required capabilities or route permission requirements instead of importing
access internals. Runtime configurations that intentionally omit access should
hide authenticated navigation and return capability errors for authenticated
product routes rather than failing process startup.
## Principal Context Contract ## Principal Context Contract
The stable runtime principal is `govoplan_core.core.access.PrincipalRef`. The stable runtime principal is `govoplan_core.core.access.PrincipalRef`.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+3 -4
View File
@@ -4,15 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.6" version = "0.1.8"
description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.8",
"govoplan-tenancy>=0.1.6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]
@@ -43,6 +43,7 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
) )
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
@@ -143,13 +144,22 @@ def _user_item(
*, *,
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
organization_directory: OrganizationDirectory | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
account = session.get(Account, user.account_id) account = session.get(Account, user.account_id)
if account is None: if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user) groups = collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user) roles = collect_direct_user_roles(session, user)
external_roles = collect_external_function_roles(session, user, idm_assignments) if idm_assignments else [] external_roles = (
collect_external_function_roles(
session,
user,
idm_assignments,
organization_directory=organization_directory,
)
if idm_assignments else []
)
effective_scopes = set(collect_user_scopes(session, user, include_system=False)) effective_scopes = set(collect_user_scopes(session, user, include_system=False))
for role in external_roles: for role in external_roles:
effective_scopes.update(role.permissions or []) effective_scopes.update(role.permissions or [])
@@ -486,6 +486,15 @@ class AccessScopeExplanationItem(BaseModel):
sources: list[AccessRoleSourceItem] = Field(default_factory=list) sources: list[AccessRoleSourceItem] = Field(default_factory=list)
class AccessDecisionProvenanceItem(BaseModel):
kind: str
id: str | None = None
label: str | None = None
tenant_id: str | None = None
source: str | None = None
details: dict[str, object] = Field(default_factory=dict)
class FunctionFactExplanationItem(BaseModel): class FunctionFactExplanationItem(BaseModel):
source_module: str source_module: str
assignment_id: str assignment_id: str
@@ -512,6 +521,14 @@ class UserAccessExplanationResponse(BaseModel):
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list) function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
class ResourceAccessExplanationResponse(BaseModel):
user: UserAdminItem
resource_type: str
resource_id: str
action: str
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
class UserListResponse(BaseModel): class UserListResponse(BaseModel):
users: list[UserAdminItem] users: list[UserAdminItem]
+21 -2
View File
@@ -18,6 +18,8 @@ from govoplan_core.api.v1.schemas import (
UserUiPreferences, UserUiPreferences,
) )
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.registry import PlatformRegistry
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_from_principal from govoplan_core.audit.logging import audit_from_principal
@@ -176,6 +178,16 @@ def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[s
} }
def _identity_directory_from_request(request: Request) -> IdentityDirectory | None:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry) or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return capability
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]: def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = ( account = (
session.query(Account) session.query(Account)
@@ -215,6 +227,8 @@ def _me_response(
service_account_id: str | None = None, service_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_id: str | None = None,
) -> MeResponse: ) -> MeResponse:
tenant_roles = collect_user_roles(session, user) tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else [] system_roles = collect_system_roles(session, account) if include_system else []
@@ -248,7 +262,7 @@ def _me_response(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant.id, tenant_id=tenant.id,
identity_id=identity_id_for_account(session, account.id), identity_id=identity_id or identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups), group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles), role_ids=frozenset(role.id for role in tenant_roles + system_roles),
@@ -271,7 +285,8 @@ def _me_response(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_login_user(session, payload)
me_payload = _me_response(session, account=account, user=user, tenant=tenant) identity_directory = _identity_directory_from_request(request)
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory)
maintenance_mode = saved_maintenance_mode(session) maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException( raise HTTPException(
@@ -300,6 +315,7 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
effective_scopes=me_payload.scopes, effective_scopes=me_payload.scopes,
auth_method="session", auth_method="session",
session_id=created.model.id, session_id=created.model.id,
identity_directory=identity_directory,
).model_dump(), ).model_dump(),
) )
@@ -321,6 +337,7 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
service_account_id=principal.principal.service_account_id, service_account_id=principal.principal.service_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,
) )
@@ -401,6 +418,7 @@ def update_profile(
session_id=principal.session_id, session_id=principal.session_id,
include_system=True, include_system=True,
include_all_memberships=True, include_all_memberships=True,
identity_id=principal.principal.identity_id,
) )
@@ -430,6 +448,7 @@ def switch_tenant(
tenant=tenant, tenant=tenant,
auth_method="session", auth_method="session",
session_id=principal.session_id, session_id=principal.session_id,
identity_id=principal.principal.identity_id,
) )
+141 -10
View File
@@ -110,6 +110,7 @@ from govoplan_access.backend.api.v1.admin_schemas import (
RoleListResponse, RoleListResponse,
RoleSummary, RoleSummary,
RoleUpdateRequest, RoleUpdateRequest,
ResourceAccessExplanationResponse,
SystemAccountCreateRequest, SystemAccountCreateRequest,
SystemAccountCreateResponse, SystemAccountCreateResponse,
SystemAccountItem, SystemAccountItem,
@@ -154,6 +155,8 @@ from govoplan_core.core.configuration_control import (
record_configuration_change_applied, record_configuration_change_applied,
) )
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
@@ -185,7 +188,8 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import identity_id_for_account from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_access.backend.permissions.catalog import ( from govoplan_access.backend.permissions.catalog import (
normalize_email, normalize_email,
@@ -504,6 +508,16 @@ def _optional_organization_directory() -> OrganizationDirectory | None:
return capability return capability
def _optional_identity_directory() -> IdentityDirectory | None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return capability
def _optional_idm_directory() -> IdmDirectory | None: def _optional_idm_directory() -> IdmDirectory | None:
registry = get_registry() registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY): if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
@@ -514,6 +528,22 @@ def _optional_idm_directory() -> IdmDirectory | None:
return capability return capability
def _access_explanation_service_or_error() -> AccessExplanationService:
registry = get_registry()
if registry is not None and registry.has_capability(CAPABILITY_ACCESS_EXPLANATION):
capability = registry.require_capability(CAPABILITY_ACCESS_EXPLANATION)
if not isinstance(capability, AccessExplanationService):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ACCESS_EXPLANATION}")
return capability
from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService(
identity_directory=_optional_identity_directory(),
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_directory(),
)
def _idm_assignments_for_user( def _idm_assignments_for_user(
idm_directory: IdmDirectory | None, idm_directory: IdmDirectory | None,
user: User, user: User,
@@ -523,6 +553,46 @@ def _idm_assignments_for_user(
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)) return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
def _principal_ref_for_user(
session: Session,
user: User,
*,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef:
account = session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
idm_assignments = _idm_assignments_for_user(idm_directory, user)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
scopes = set(collect_user_scopes(session, user, include_system=True))
for role in idm_roles:
scopes.update(role.permissions or [])
role_ids = [role.id for role in collect_user_roles(session, user)]
role_ids.extend(role.id for role in idm_roles)
function_assignment_ids = list(collect_function_assignment_ids(session, user))
function_assignment_ids.extend(item.id for item in idm_assignments)
return PrincipalRef(
account_id=account.id,
membership_id=user.id,
tenant_id=user.tenant_id,
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(sorted(scopes)),
group_ids=frozenset(group.id for group in collect_user_groups(session, user)),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
auth_method="session",
email=account.email,
display_name=account.display_name or user.display_name,
)
def _provenance_payload(items: Iterable[AccessDecisionProvenance]) -> list[dict[str, object]]:
return [item.to_dict() for item in items]
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None: def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
if source_module != ORGANIZATIONS_MODULE_ID: if source_module != ORGANIZATIONS_MODULE_ID:
raise HTTPException( raise HTTPException(
@@ -1886,8 +1956,9 @@ def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDelt
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
owner_ids = tenant_owner_user_ids(session, tenant.id) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users], users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users],
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
has_more=False, has_more=False,
@@ -1909,13 +1980,14 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
} }
owner_ids = tenant_owner_user_ids(session, tenant.id) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
deleted = [ deleted = [
_delta_deleted_item(entry) _delta_deleted_item(entry)
for entry in entries for entry in entries
if entry.resource_type == "access_user" and entry.resource_id not in visible if entry.resource_type == "access_user" and entry.resource_id not in visible
] ]
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()], users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in visible.values()],
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -1929,9 +2001,16 @@ def _user_item_for_response(
*, *,
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
idm_assignments = _idm_assignments_for_user(idm_directory, user) idm_assignments = _idm_assignments_for_user(idm_directory, user)
return _user_item(session, user, owner_ids=owner_ids, idm_assignments=idm_assignments) return _user_item(
session,
user,
owner_ids=owner_ids,
idm_assignments=idm_assignments,
organization_directory=organization_directory,
)
@router.get("/users/delta", response_model=UserListDeltaResponse) @router.get("/users/delta", response_model=UserListDeltaResponse)
@@ -1958,7 +2037,8 @@ def list_users(
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
owner_ids = tenant_owner_user_ids(session, tenant.id) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users]) organization_directory = _optional_organization_directory()
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users])
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse) @router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
@@ -1973,21 +2053,61 @@ def get_user_access_explanation(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
explanation = build_user_access_explanation( explanation = build_user_access_explanation(
session, session,
user, user,
idm_directory=idm_directory, idm_directory=idm_directory,
organization_directory=_optional_organization_directory(), organization_directory=organization_directory,
include_system=False, include_system=False,
) )
return UserAccessExplanationResponse( return UserAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory), user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
role_sources=[source.to_dict() for source in explanation.role_sources], role_sources=[source.to_dict() for source in explanation.role_sources],
scopes=[scope.to_dict() for scope in explanation.scopes], scopes=[scope.to_dict() for scope in explanation.scopes],
function_facts=[fact.to_dict() for fact in explanation.function_facts], function_facts=[fact.to_dict() for fact in explanation.function_facts],
) )
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
def get_resource_access_explanation(
user_id: str = Query(...),
resource_type: str = Query(..., min_length=1, max_length=100),
resource_id: str = Query(..., min_length=1, max_length=2048),
action: str = Query(..., min_length=1, max_length=255),
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory()
identity_directory = _optional_identity_directory()
organization_directory = _optional_organization_directory()
target_principal = _principal_ref_for_user(
session,
user,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
provenance = _access_explanation_service_or_error().explain_resource_provenance(
target_principal,
resource_type=resource_type,
resource_id=resource_id,
action=action,
)
return ResourceAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
resource_type=resource_type,
resource_id=resource_id,
action=action,
provenance=_provenance_payload(provenance),
)
@router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED) @router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
def create_user( def create_user(
payload: UserCreateRequest, payload: UserCreateRequest,
@@ -2070,8 +2190,9 @@ def create_user(
) )
session.commit() session.commit()
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
return UserCreateResponse( return UserCreateResponse(
user=_user_item_for_response(session, result.user, idm_directory=idm_directory), user=_user_item_for_response(session, result.user, idm_directory=idm_directory, organization_directory=organization_directory),
account_created=result.account_created, account_created=result.account_created,
temporary_password=result.temporary_password, temporary_password=result.temporary_password,
) )
@@ -2152,7 +2273,12 @@ def update_user(
details=payload.model_dump(exclude_none=True), details=payload.model_dump(exclude_none=True),
) )
session.commit() session.commit()
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory()) return _user_item_for_response(
session,
user,
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_directory(),
)
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
@@ -3229,7 +3355,12 @@ def create_tenant_api_key(
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none() user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
user_scopes = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes user_scopes = _user_item_for_response(
session,
user,
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_directory(),
).effective_scopes
requested = payload.scopes or ["campaign:read"] requested = payload.scopes or ["campaign:read"]
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)] invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
if invalid: if invalid:
@@ -13,7 +13,9 @@ from govoplan_core.core.access import (
PrincipalRef, PrincipalRef,
PrincipalResolver, PrincipalResolver,
) )
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory
from govoplan_core.core.modules import AccessDecision from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
@@ -63,6 +65,7 @@ def _build_principal_ref(
api_key: ApiKey | None = None, api_key: ApiKey | None = None,
auth_session: AuthSession | None = None, auth_session: AuthSession | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
identity_directory: IdentityDirectory | None = None,
extra_roles: tuple[Role, ...] = (), extra_roles: tuple[Role, ...] = (),
) -> PrincipalRef: ) -> PrincipalRef:
function_assignment_ids = list(collect_function_assignment_ids(session, user)) function_assignment_ids = list(collect_function_assignment_ids(session, user))
@@ -74,7 +77,7 @@ def _build_principal_ref(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant_id, tenant_id=tenant_id,
identity_id=identity_id_for_account(session, account.id), identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user), group_ids=_principal_group_ids(session, user),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))), role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
@@ -128,6 +131,8 @@ def _resolve_legacy_principal_ref(
authorization: str | None, authorization: str | None,
x_api_key: str | None, x_api_key: str | None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef: ) -> PrincipalRef:
token, source = _extract_token(request, authorization, x_api_key) token, source = _extract_token(request, authorization, x_api_key)
if not token: if not token:
@@ -147,7 +152,7 @@ def _resolve_legacy_principal_ref(
): ):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments)) idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles)
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
session.commit() session.commit()
@@ -160,6 +165,7 @@ def _resolve_legacy_principal_ref(
scopes=effective_scopes, scopes=effective_scopes,
auth_method="api_key", auth_method="api_key",
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles, extra_roles=idm_roles,
) )
@@ -181,7 +187,7 @@ def _resolve_legacy_principal_ref(
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments)) idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles)
session.commit() session.commit()
return _build_principal_ref( return _build_principal_ref(
@@ -193,6 +199,7 @@ def _resolve_legacy_principal_ref(
scopes=scopes, scopes=scopes,
auth_method="session", auth_method="session",
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles, extra_roles=idm_roles,
) )
@@ -257,8 +264,15 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator
class LegacyPrincipalResolver: class LegacyPrincipalResolver:
def __init__(self, idm_directory: IdmDirectory | None = None) -> None: def __init__(
self,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> None:
self._idm_directory = idm_directory self._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
if not isinstance(request, Request): if not isinstance(request, Request):
@@ -272,6 +286,8 @@ class LegacyPrincipalResolver:
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
identity_directory=self._identity_directory,
organization_directory=self._organization_directory,
) )
with get_database().session() as managed_session: with get_database().session() as managed_session:
return _resolve_legacy_principal_ref( return _resolve_legacy_principal_ref(
@@ -280,6 +296,8 @@ class LegacyPrincipalResolver:
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
identity_directory=self._identity_directory,
organization_directory=self._organization_directory,
) )
+121 -1
View File
@@ -13,7 +13,14 @@ from govoplan_core.core.access import (
OrganizationUnitRef, OrganizationUnitRef,
UserRef, UserRef,
) )
from govoplan_core.core.identity import IdentityDirectory, IdentityRef as DirectoryIdentityRef
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
from govoplan_core.core.organizations import (
ORGANIZATIONS_MODULE_ID,
OrganizationDirectory,
OrganizationFunctionRef as DirectoryFunctionRef,
OrganizationUnitRef as DirectoryOrganizationUnitRef,
)
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
Function, Function,
@@ -73,6 +80,16 @@ def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink])
) )
def _directory_identity_ref(identity: DirectoryIdentityRef) -> IdentityRef:
return IdentityRef(
id=identity.id,
display_name=identity.display_name,
primary_account_id=identity.primary_account_id,
account_ids=tuple(identity.account_ids),
status=identity.status, # type: ignore[arg-type]
)
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef: def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
return OrganizationUnitRef( return OrganizationUnitRef(
id=item.id, id=item.id,
@@ -83,6 +100,16 @@ def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
) )
def _directory_organization_unit_ref(item: DirectoryOrganizationUnitRef) -> OrganizationUnitRef:
return OrganizationUnitRef(
id=item.id,
tenant_id=item.tenant_id,
name=item.name,
parent_id=item.parent_id,
status=item.status, # type: ignore[arg-type]
)
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef: def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
return FunctionRef( return FunctionRef(
id=function.id, id=function.id,
@@ -97,6 +124,20 @@ def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
) )
def _directory_function_ref(function: DirectoryFunctionRef, role_ids: Iterable[str]) -> FunctionRef:
return FunctionRef(
id=function.id,
tenant_id=function.tenant_id,
organization_unit_id=function.organization_unit_id,
slug=function.slug,
name=function.name,
role_ids=tuple(role_ids),
delegable=function.delegable,
act_in_place_allowed=function.act_in_place_allowed,
status=function.status, # type: ignore[arg-type]
)
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef: def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
return FunctionAssignmentRef( return FunctionAssignmentRef(
id=item.id, id=item.id,
@@ -134,8 +175,15 @@ def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id:
class SqlAccessDirectory(AccessSemanticDirectory): class SqlAccessDirectory(AccessSemanticDirectory):
def __init__(self, idm_directory: IdmDirectory | None = None) -> None: def __init__(
self,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> None:
self._idm_directory = idm_directory self._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def get_account(self, account_id: str) -> AccountRef | None: def get_account(self, account_id: str) -> AccountRef | None:
with get_database().session() as session: with get_database().session() as session:
@@ -230,6 +278,10 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return subject.label or subject.id return subject.label or subject.id
def get_identity(self, identity_id: str) -> IdentityRef | None: def get_identity(self, identity_id: str) -> IdentityRef | None:
if self._identity_directory is not None:
identity = self._identity_directory.get_identity(identity_id)
if identity is not None:
return _directory_identity_ref(identity)
with get_database().session() as session: with get_database().session() as session:
identity = session.get(Identity, identity_id) identity = session.get(Identity, identity_id)
if identity is None: if identity is None:
@@ -243,6 +295,16 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return _identity_ref(identity, links) return _identity_ref(identity, links)
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]: def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
if self._identity_directory is not None:
links = tuple(self._identity_directory.accounts_for_identity(identity_id))
if links:
account_ids = [link.account_id for link in links]
with get_database().session() as session:
accounts = {
account.id: account
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
}
return tuple(_account_ref(accounts[account_id]) for account_id in account_ids if account_id in accounts)
with get_database().session() as session: with get_database().session() as session:
accounts = ( accounts = (
session.query(Account) session.query(Account)
@@ -254,11 +316,19 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return tuple(_account_ref(account) for account in accounts) return tuple(_account_ref(account) for account in accounts)
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
if self._organization_directory is not None:
item = self._organization_directory.get_organization_unit(organization_unit_id)
if item is not None:
return _directory_organization_unit_ref(item)
with get_database().session() as session: with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id) item = session.get(OrganizationUnit, organization_unit_id)
return _organization_unit_ref(item) if item is not None else None return _organization_unit_ref(item) if item is not None else None
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]: def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
if self._organization_directory is not None:
items = tuple(self._organization_directory.organization_units_for_tenant(tenant_id))
if items:
return tuple(_directory_organization_unit_ref(item) for item in items)
with get_database().session() as session: with get_database().session() as session:
items = ( items = (
session.query(OrganizationUnit) session.query(OrganizationUnit)
@@ -269,6 +339,10 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return tuple(_organization_unit_ref(item) for item in items) return tuple(_organization_unit_ref(item) for item in items)
def get_function(self, function_id: str) -> FunctionRef | None: def get_function(self, function_id: str) -> FunctionRef | None:
if self._organization_directory is not None:
function = self._organization_directory.get_function(function_id)
if function is not None:
return _directory_function_ref(function, self._external_function_role_ids(function.id, tenant_id=function.tenant_id))
with get_database().session() as session: with get_database().session() as session:
function = session.get(Function, function_id) function = session.get(Function, function_id)
if function is None: if function is None:
@@ -288,6 +362,22 @@ class SqlAccessDirectory(AccessSemanticDirectory):
*, *,
include_subunits: bool = False, include_subunits: bool = False,
) -> tuple[FunctionRef, ...]: ) -> tuple[FunctionRef, ...]:
if self._organization_directory is not None:
functions = tuple(
self._organization_directory.functions_for_organization_unit(
organization_unit_id,
include_subunits=include_subunits,
)
)
if functions:
role_ids_by_function = self._external_function_role_ids_by_function(
[item.id for item in functions],
tenant_id=functions[0].tenant_id,
)
return tuple(
_directory_function_ref(item, role_ids_by_function.get(item.id, ()))
for item in functions
)
with get_database().session() as session: with get_database().session() as session:
unit_ids = {organization_unit_id} unit_ids = {organization_unit_id}
if include_subunits: if include_subunits:
@@ -345,3 +435,33 @@ class SqlAccessDirectory(AccessSemanticDirectory):
) )
deduped = {item.id: item for item in assignments} deduped = {item.id: item for item in assignments}
return tuple(deduped.values()) return tuple(deduped.values())
def _external_function_role_ids(self, function_id: str, *, tenant_id: str) -> tuple[str, ...]:
return self._external_function_role_ids_by_function([function_id], tenant_id=tenant_id).get(function_id, ())
def _external_function_role_ids_by_function(
self,
function_ids: Iterable[str],
*,
tenant_id: str,
) -> dict[str, tuple[str, ...]]:
ids = sorted({str(function_id) for function_id in function_ids if function_id})
if not ids:
return {}
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment
with get_database().session() as session:
rows = (
session.query(ExternalFunctionRoleAssignment.function_id, ExternalFunctionRoleAssignment.role_id)
.filter(
ExternalFunctionRoleAssignment.tenant_id == tenant_id,
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
ExternalFunctionRoleAssignment.function_id.in_(ids),
)
.order_by(ExternalFunctionRoleAssignment.created_at.asc())
.all()
)
result: dict[str, list[str]] = {}
for function_id, role_id in rows:
result.setdefault(function_id, []).append(role_id)
return {function_id: tuple(role_ids) for function_id, role_ids in result.items()}
+53 -6
View File
@@ -26,9 +26,10 @@ from govoplan_access.backend.semantic import (
active_function_delegations_for_account, active_function_delegations_for_account,
identity_id_for_account, identity_id_for_account,
) )
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef, ResourceAccessExplanationProvider
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant
@@ -159,11 +160,15 @@ class SqlAccessExplanationService(AccessExplanationService):
def __init__( def __init__(
self, self,
*, *,
identity_directory: IdentityDirectory | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (),
) -> None: ) -> None:
self._identity_directory = identity_directory
self._idm_directory = idm_directory self._idm_directory = idm_directory
self._organization_directory = organization_directory self._organization_directory = organization_directory
self._resource_explanation_providers = tuple(resource_explanation_providers)
def explain_scope_provenance( def explain_scope_provenance(
self, self,
@@ -176,6 +181,7 @@ class SqlAccessExplanationService(AccessExplanationService):
session, session,
principal, principal,
required_scope, required_scope,
identity_directory=self._identity_directory,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
organization_directory=self._organization_directory, organization_directory=self._organization_directory,
) )
@@ -189,8 +195,29 @@ class SqlAccessExplanationService(AccessExplanationService):
resource_id: str, resource_id: str,
action: str, action: str,
) -> tuple[AccessDecisionProvenance, ...]: ) -> tuple[AccessDecisionProvenance, ...]:
del resource_type, resource_id with get_database().session() as session:
return self.explain_scope_provenance(principal, action) items = _scope_provenance(
session,
principal,
action,
identity_directory=self._identity_directory,
idm_directory=self._idm_directory,
organization_directory=self._organization_directory,
)
for provider in self._resource_explanation_providers:
provider_items = tuple(
provider.explain_resource_provenance(
session,
principal,
resource_type=resource_type,
resource_id=resource_id,
action=action,
)
)
if provider_items:
items.extend(provider_items)
break
return tuple(_dedupe_provenance(items))
def build_user_access_explanation( def build_user_access_explanation(
@@ -228,12 +255,17 @@ def _scope_provenance(
principal: PrincipalRef, principal: PrincipalRef,
required_scope: str, required_scope: str,
*, *,
identity_directory: IdentityDirectory | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
) -> list[AccessDecisionProvenance]: ) -> list[AccessDecisionProvenance]:
items: list[AccessDecisionProvenance] = [] items: list[AccessDecisionProvenance] = []
account = session.get(Account, principal.account_id) account = session.get(Account, principal.account_id)
identity_id = principal.identity_id or identity_id_for_account(session, principal.account_id) identity_id = principal.identity_id or identity_id_for_account(
session,
principal.account_id,
identity_directory=identity_directory,
)
if identity_id: if identity_id:
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link")) items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
items.append( items.append(
@@ -399,6 +431,12 @@ def _idm_function_role_sources(
for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id) for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)
if assignment.tenant_id == user.tenant_id and assignment.status == "active" if assignment.tenant_id == user.tenant_id and assignment.status == "active"
] ]
if organization_directory is not None:
assignments = [
assignment
for assignment in assignments
if _organization_function_active(organization_directory, assignment)
]
if not assignments: if not assignments:
return [], [] return [], []
function_ids = sorted({assignment.function_id for assignment in assignments}) function_ids = sorted({assignment.function_id for assignment in assignments})
@@ -407,6 +445,7 @@ def _idm_function_role_sources(
.join(Role, ExternalFunctionRoleAssignment.role_id == Role.id) .join(Role, ExternalFunctionRoleAssignment.role_id == Role.id)
.filter( .filter(
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id, ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
ExternalFunctionRoleAssignment.function_id.in_(function_ids), ExternalFunctionRoleAssignment.function_id.in_(function_ids),
Role.tenant_id == user.tenant_id, Role.tenant_id == user.tenant_id,
) )
@@ -424,7 +463,7 @@ def _idm_function_role_sources(
mappings = mappings_by_function.get(assignment.function_id, []) mappings = mappings_by_function.get(assignment.function_id, [])
function_facts.append( function_facts.append(
FunctionFactExplanation( FunctionFactExplanation(
source_module="organizations", source_module=ORGANIZATIONS_MODULE_ID,
assignment_id=assignment.id, assignment_id=assignment.id,
tenant_id=assignment.tenant_id, tenant_id=assignment.tenant_id,
identity_id=assignment.identity_id, identity_id=assignment.identity_id,
@@ -479,6 +518,14 @@ def _organization_labels(
return (function.name if function is not None else None, unit.name if unit is not None else None) return (function.name if function is not None else None, unit.name if unit is not None else None)
def _organization_function_active(
organization_directory: OrganizationDirectory,
assignment: OrganizationFunctionAssignmentRef,
) -> bool:
function = organization_directory.get_function(assignment.function_id)
return function is not None and function.tenant_id == assignment.tenant_id and function.status == "active"
def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]: def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]:
roles = ( roles = (
session.query(Role) session.query(Role)
+42 -5
View File
@@ -17,7 +17,11 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
ResourceAccessExplanationProvider,
) )
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
@@ -452,8 +456,11 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
def _legacy_principal_resolver(context: ModuleContext) -> object: def _legacy_principal_resolver(context: ModuleContext) -> object:
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
idm_directory = _optional_idm_directory(context) return LegacyPrincipalResolver(
return LegacyPrincipalResolver(idm_directory=idm_directory) idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _legacy_permission_evaluator(context: ModuleContext) -> object: def _legacy_permission_evaluator(context: ModuleContext) -> object:
@@ -480,13 +487,30 @@ def _tenant_context_switcher(context: ModuleContext) -> object:
def _access_directory(context: ModuleContext) -> object: def _access_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context)) return SqlAccessDirectory(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _access_semantic_directory(context: ModuleContext) -> object: def _access_semantic_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context)) return SqlAccessDirectory(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None
capability = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return capability
def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None: def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
@@ -507,12 +531,25 @@ def _optional_organization_directory(context: ModuleContext) -> OrganizationDire
return capability return capability
def _resource_explanation_providers(context: ModuleContext) -> tuple[ResourceAccessExplanationProvider, ...]:
providers: list[ResourceAccessExplanationProvider] = []
for capability_name in (CAPABILITY_FILES_ACCESS, CAPABILITY_CAMPAIGNS_ACCESS):
if not context.registry.has_capability(capability_name):
continue
capability = context.registry.require_capability(capability_name)
if isinstance(capability, ResourceAccessExplanationProvider):
providers.append(capability)
return tuple(providers)
def _access_explanation_service(context: ModuleContext) -> object: def _access_explanation_service(context: ModuleContext) -> object:
from govoplan_access.backend.explanation import SqlAccessExplanationService from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService( return SqlAccessExplanationService(
identity_directory=_optional_identity_directory(context),
idm_directory=_optional_idm_directory(context), idm_directory=_optional_idm_directory(context),
organization_directory=_optional_organization_directory(context), organization_directory=_optional_organization_directory(context),
resource_explanation_providers=_resource_explanation_providers(context),
) )
@@ -563,7 +600,7 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.6", version="0.1.8",
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
@@ -0,0 +1,192 @@
"""v0.1.7 access baseline
Revision ID: 4a5b6c7d8e9f
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '4a5b6c7d8e9f'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('access_identities',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('external_subject', sa.String(length=255), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identities'))
)
op.create_index(op.f('ix_access_identities_external_subject'), 'access_identities', ['external_subject'], unique=False)
op.create_table('access_organization_units',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('parent_id', sa.String(length=36), nullable=True),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['access_organization_units.id'], name=op.f('fk_access_organization_units_parent_id_access_organization_units'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_organization_units_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_organization_units')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organization_units_tenant_slug')
)
op.create_index(op.f('ix_access_organization_units_parent_id'), 'access_organization_units', ['parent_id'], unique=False)
op.create_index(op.f('ix_access_organization_units_tenant_id'), 'access_organization_units', ['tenant_id'], unique=False)
op.create_table('access_external_function_role_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('source_module', sa.String(length=50), nullable=False),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('role_id', sa.String(length=36), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_external_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_external_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_external_function_role_assignments')),
sa.UniqueConstraint('tenant_id', 'source_module', 'function_id', 'role_id', name='uq_external_function_role_assignments')
)
op.create_index(op.f('ix_access_external_function_role_assignments_function_id'), 'access_external_function_role_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_role_id'), 'access_external_function_role_assignments', ['role_id'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_source_module'), 'access_external_function_role_assignments', ['source_module'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_tenant_id'), 'access_external_function_role_assignments', ['tenant_id'], unique=False)
op.create_table('access_functions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('delegable', sa.Boolean(), nullable=False),
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_functions_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_functions_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_functions')),
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_functions_tenant_ou_slug')
)
op.create_index(op.f('ix_access_functions_organization_unit_id'), 'access_functions', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_access_functions_tenant_id'), 'access_functions', ['tenant_id'], unique=False)
op.create_table('access_identity_account_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_identity_account_links_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_identity_account_links_identity_id_access_identities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identity_account_links')),
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_account_links_identity_account')
)
op.create_index(op.f('ix_access_identity_account_links_account_id'), 'access_identity_account_links', ['account_id'], unique=False)
op.create_index(op.f('ix_access_identity_account_links_identity_id'), 'access_identity_account_links', ['identity_id'], unique=False)
op.create_index('uq_identity_account_links_primary_account', 'access_identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
op.create_index('uq_identity_account_links_primary_identity', 'access_identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
op.create_table('access_function_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=True),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['acting_for_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_acting_for_account_id_access_accounts'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_assignments_function_id_access_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_function_assignments_identity_id_access_identities'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_function_assignments_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_assignments')),
sa.UniqueConstraint('tenant_id', 'account_id', 'function_id', 'organization_unit_id', name='uq_function_assignments_account_scope')
)
op.create_index(op.f('ix_access_function_assignments_account_id'), 'access_function_assignments', ['account_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_acting_for_account_id'), 'access_function_assignments', ['acting_for_account_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_delegated_from_assignment_id'), 'access_function_assignments', ['delegated_from_assignment_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_function_id'), 'access_function_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_identity_id'), 'access_function_assignments', ['identity_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_organization_unit_id'), 'access_function_assignments', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_tenant_id'), 'access_function_assignments', ['tenant_id'], unique=False)
op.create_table('access_function_role_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('role_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_role_assignments_function_id_access_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_role_assignments')),
sa.UniqueConstraint('tenant_id', 'function_id', 'role_id', name='uq_function_role_assignments')
)
op.create_index(op.f('ix_access_function_role_assignments_function_id'), 'access_function_role_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_function_role_assignments_role_id'), 'access_function_role_assignments', ['role_id'], unique=False)
op.create_index(op.f('ix_access_function_role_assignments_tenant_id'), 'access_function_role_assignments', ['tenant_id'], unique=False)
op.create_table('access_function_delegations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('function_assignment_id', sa.String(length=36), nullable=False),
sa.Column('delegator_account_id', sa.String(length=36), nullable=False),
sa.Column('delegate_account_id', sa.String(length=36), nullable=False),
sa.Column('mode', sa.String(length=30), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['delegate_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegate_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['delegator_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegator_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['function_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_delegations_function_assignment_id_access_function_assignments'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_delegations_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_delegations')),
sa.UniqueConstraint('tenant_id', 'function_assignment_id', 'delegate_account_id', 'mode', name='uq_function_delegations_assignment_delegate_mode')
)
op.create_index(op.f('ix_access_function_delegations_delegate_account_id'), 'access_function_delegations', ['delegate_account_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_delegator_account_id'), 'access_function_delegations', ['delegator_account_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_function_assignment_id'), 'access_function_delegations', ['function_assignment_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_revoked_at'), 'access_function_delegations', ['revoked_at'], unique=False)
op.create_index(op.f('ix_access_function_delegations_tenant_id'), 'access_function_delegations', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('access_function_delegations')
op.drop_table('access_function_role_assignments')
op.drop_table('access_function_assignments')
op.drop_table('access_identity_account_links')
op.drop_table('access_functions')
op.drop_table('access_external_function_role_assignments')
op.drop_table('access_organization_units')
op.drop_table('access_identities')
+30 -2
View File
@@ -17,7 +17,9 @@ from govoplan_access.backend.db.models import (
Role, Role,
User, User,
) )
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@@ -35,7 +37,16 @@ def primary_identity_for_account(session: Session, account_id: str) -> Identity
) )
def identity_id_for_account(session: Session, account_id: str) -> str | None: def identity_id_for_account(
session: Session,
account_id: str,
*,
identity_directory: IdentityDirectory | None = None,
) -> str | None:
if identity_directory is not None:
identity = identity_directory.identity_for_account(account_id)
if identity is not None and identity.status == "active":
return identity.id
identity = primary_identity_for_account(session, account_id) identity = primary_identity_for_account(session, account_id)
return identity.id if identity is not None else None return identity.id if identity is not None else None
@@ -163,13 +174,20 @@ def collect_external_function_roles(
user: User, user: User,
assignments: Iterable[OrganizationFunctionAssignmentRef], assignments: Iterable[OrganizationFunctionAssignmentRef],
*, *,
source_module: str = "organizations", source_module: str = ORGANIZATIONS_MODULE_ID,
organization_directory: OrganizationDirectory | None = None,
) -> list[Role]: ) -> list[Role]:
function_ids = sorted({ function_ids = sorted({
assignment.function_id assignment.function_id
for assignment in assignments for assignment in assignments
if assignment.tenant_id == user.tenant_id and assignment.status == "active" if assignment.tenant_id == user.tenant_id and assignment.status == "active"
}) })
if organization_directory is not None and source_module == ORGANIZATIONS_MODULE_ID:
function_ids = [
function_id
for function_id in function_ids
if _organization_function_active(organization_directory, function_id, tenant_id=user.tenant_id)
]
if not function_ids: if not function_ids:
return [] return []
return ( return (
@@ -184,3 +202,13 @@ def collect_external_function_roles(
.order_by(Role.name.asc()) .order_by(Role.name.asc())
.all() .all()
) )
def _organization_function_active(
organization_directory: OrganizationDirectory,
function_id: str,
*,
tenant_id: str,
) -> bool:
function = organization_directory.get_function(function_id)
return function is not None and function.tenant_id == tenant_id and function.status == "active"
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import ast
import pathlib
import tomllib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
class OptionalTenancyContractTests(unittest.TestCase):
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
manifest_path = ROOT / "src" / "govoplan_access" / "backend" / "manifest.py"
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
manifest_call = next(
node.value
for node in ast.walk(tree)
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "manifest" for target in node.targets)
and isinstance(node.value, ast.Call)
)
optional_dependencies = next(
keyword.value
for keyword in manifest_call.keywords
if keyword.arg == "optional_dependencies"
)
self.assertIsInstance(optional_dependencies, ast.Tuple)
self.assertIn(
"tenancy",
{
item.value
for item in optional_dependencies.elts
if isinstance(item, ast.Constant) and isinstance(item.value, str)
},
)
def test_access_source_does_not_import_tenancy_module_internals(self) -> None:
offenders: list[str] = []
for path in (ROOT / "src" / "govoplan_access").rglob("*.py"):
source = path.read_text(encoding="utf-8")
if "govoplan_tenancy" in source:
offenders.append(str(path.relative_to(ROOT)))
self.assertEqual([], offenders)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,7 +13,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+31 -125
View File
@@ -132,6 +132,15 @@ export type AccessScopeExplanationItem = {
sources: AccessRoleSourceItem[]; sources: AccessRoleSourceItem[];
}; };
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details: Record<string, unknown>;
};
export type FunctionFactExplanationItem = { export type FunctionFactExplanationItem = {
source_module: string; source_module: string;
assignment_id: string; assignment_id: string;
@@ -158,6 +167,14 @@ export type UserAccessExplanationResponse = {
function_facts: FunctionFactExplanationItem[]; function_facts: FunctionFactExplanationItem[];
}; };
export type ResourceAccessExplanationResponse = {
user: UserAdminItem;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type SystemAccountItem = { export type SystemAccountItem = {
account_id: string; account_id: string;
email: string; email: string;
@@ -188,7 +205,6 @@ export type PrivacyRetentionPolicyFieldKey =
| "audit_detail_level"; | "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>; export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = { export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean; store_raw_campaign_json: boolean;
@@ -201,29 +217,6 @@ export type PrivacyRetentionPolicy = {
allow_lower_level_limits: PrivacyRetentionLimitPermissions; allow_lower_level_limits: PrivacyRetentionLimitPermissions;
}; };
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type SystemSettingsItem = { export type SystemSettingsItem = {
default_locale: string; default_locale: string;
allow_tenant_custom_groups: boolean; allow_tenant_custom_groups: boolean;
@@ -259,16 +252,6 @@ export type TenantSettingsDeltaSections = Partial<{
settings: Pick<TenantSettingsItem, "settings">["settings"]; settings: Pick<TenantSettingsItem, "settings">["settings"];
}>; }>;
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = { export type GovernanceAssignment = {
tenant_id: string; tenant_id: string;
mode: "available" | "required"; mode: "available" | "required";
@@ -312,18 +295,6 @@ export type ExternalFunctionRoleMappingItem = {
updated_at: string; updated_at: string;
}; };
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
type DeltaResponseFields = { type DeltaResponseFields = {
deleted: DeltaDeletedItem[]; deleted: DeltaDeletedItem[];
watermark?: string | null; watermark?: string | null;
@@ -344,15 +315,6 @@ export type TenantSettingsDeltaResponse = {
sections: TenantSettingsDeltaSections; sections: TenantSettingsDeltaSections;
changed_sections: string[]; changed_sections: string[];
} & DeltaResponseFields; } & DeltaResponseFields;
export type AuditAdminDeltaResponse = {
items: AuditAdminItem[];
total: number;
page: number;
page_size: number;
pages: number;
cursor?: string | null;
next_cursor?: string | null;
} & DeltaResponseFields;
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -463,6 +425,20 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
return apiFetch(settings, `/api/v1/admin/users/${userId}/access-explanation`); return apiFetch(settings, `/api/v1/admin/users/${userId}/access-explanation`);
} }
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
): Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups; return response.groups;
@@ -640,58 +616,6 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" }); return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
} }
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
cursor?: string | null;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number; cursor?: string | null; next_cursor?: string | null }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export async function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
if (options.since) params.set("since", options.since);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit/delta${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: { export function createSystemAccount(settings: ApiSettings, payload: {
email: string; email: string;
display_name?: string | null; display_name?: string | null;
@@ -728,24 +652,6 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
} }
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> { export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
@@ -1,181 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui";
type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant";
const DEFAULT_QUERY: DataGridQueryState = {
sort: { columnId: "time", direction: "desc" },
filters: {}
};
export default function AdminAuditPanel({
settings,
auth,
systemMode = false
}: {settings: ApiSettings;auth: AuthInfo;systemMode?: boolean;}) {
const [items, setItems] = useState<AuditAdminItem[]>([]);
const itemsRef = useRef<AuditAdminItem[]>([]);
const pageItemsRef = useRef<Record<string, AuditAdminItem[]>>({});
const pageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const sortColumn = query.sort?.columnId;
const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) ?
sortColumn as AuditSortBy :
"time";
const sortDirection = query.sort?.direction ?? "desc";
const filters = query.filters;
const pageCursor = page === 1 ? null : pageCursorsRef.current[page];
const deltaMode = page === 1 || pageCursor !== undefined;
const requestOptions = {
scope: systemMode ? "system" : "tenant",
page,
pageSize,
cursor: pageCursor,
sortBy,
sortDirection,
filters
};
const deltaKey = `access:audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`;
const response = deltaMode
? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) })
: await fetchAdminAudit(settings, requestOptions);
const baseItems = pageItemsRef.current[deltaKey] ?? [];
const nextItems = "full" in response && !response.full
? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize)
: response.items;
pageItemsRef.current[deltaKey] = nextItems;
itemsRef.current = nextItems;
setItems(nextItems);
setTotal(response.total);
if (!deltaMode && response.page !== page) setPage(response.page);
if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor;
else delete pageCursorsRef.current[page + 1];
}
if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) {
pageCursorsRef.current = { 1: null };
}
if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark);
if (!deltaMode) resetDeltaWatermark(deltaKey);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => {
itemsRef.current = [];
pageItemsRef.current = {};
pageCursorsRef.current = { 1: null };
resetDeltaWatermark();
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, pageSize, query, resetDeltaWatermark]);
useEffect(() => {void load();}, [load]);
const handleQueryChange = useCallback((next: DataGridQueryState) => {
setQuery((current) => {
if (JSON.stringify(current) === JSON.stringify(next)) return current;
setPage(1);
return next;
});
}, []);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: "i18n:govoplan-access.time.6c82e6dd", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: "i18n:govoplan-access.actor.cbd19b5c", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "i18n:govoplan-access.system.bc0792d8" },
{ id: "action", header: "i18n:govoplan-access.action.97c89a4d", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "i18n:govoplan-access.object.2883f191", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
...(systemMode ? [{ id: "tenant", header: "i18n:govoplan-access.tenant_context.b401a2ad", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="i18n:govoplan-access.inspect_audit_event.5776c1b2" icon={<Search />} onClick={() => setSelected(row)} /></div> }],
[systemMode]);
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastShown = Math.min(total, page * pageSize);
return (
<>
<AdminPageLayout
title={systemMode ? "i18n:govoplan-access.system_audit.69c6b424" : "i18n:govoplan-access.tenant_audit.492b9138"}
description={systemMode ? i18nMessage("i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1", { value0:
firstShown, value1: lastShown, value2: total }) : i18nMessage("i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff", { value0:
firstShown, value1: lastShown, value2: total })}
loading={loading}
error={error}
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>}>
<div className="admin-table-surface">
<DataGrid
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
rows={items}
columns={columns}
initialFit="container" getRowKey={(row) => row.id}
emptyText="i18n:govoplan-access.no_administrative_audit_records_found.8d128767"
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
mode: "server",
page,
pageSize,
totalRows: total,
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => {setPageSize(next);setPage(1);}
}}
onQueryChange={handleQueryChange} />
</div>
</AdminPageLayout>
<Dialog open={Boolean(selected)} title="i18n:govoplan-access.audit_event_details.3749b52d" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{selected && <><dl className="admin-details-grid"><div><dt>i18n:govoplan-access.scope.4651a34e</dt><dd>{selected.scope}</dd></div><div><dt>i18n:govoplan-access.action.97c89a4d</dt><dd>{selected.action}</dd></div><div><dt>i18n:govoplan-access.actor.cbd19b5c</dt><dd>{selected.actor_email || "i18n:govoplan-access.system.bc0792d8"}</dd></div><div><dt>i18n:govoplan-access.object.2883f191</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>i18n:govoplan-access.tenant_context.b401a2ad</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>i18n:govoplan-access.time.6c82e6dd</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
</Dialog>
</>);
}
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
return (left, right) => {
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
const directed = sortDirection === "asc" ? primary : -primary;
return directed || right.id.localeCompare(left.id);
};
}
function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number {
if (sortBy === "time") return new Date(item.created_at).getTime();
if (sortBy === "actor") return item.actor_email || "System";
if (sortBy === "action") return item.action;
if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`;
return item.tenant_id || "";
}
function compareAuditValues(left: string | number, right: string | number): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right));
}
+3 -33
View File
@@ -22,11 +22,9 @@ import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel"; import RolesPanel from "./RolesPanel";
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel"; import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
import ApiKeysPanel from "./ApiKeysPanel"; import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel";
import FileConnectorsPanel from "./FileConnectorsPanel"; import FileConnectorsPanel from "./FileConnectorsPanel";
import MailProfilesPanel from "./MailProfilesPanel"; import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
type AdminSection = string; type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number }; type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
@@ -37,7 +35,6 @@ const handledAdminSectionIds = new Set<string>([
"system-configuration-changes", "system-configuration-changes",
"system-configuration-packages", "system-configuration-packages",
"system-modules", "system-modules",
"system-audit",
"system-tenants", "system-tenants",
"system-roles", "system-roles",
"system-role-templates", "system-role-templates",
@@ -45,7 +42,6 @@ const handledAdminSectionIds = new Set<string>([
"system-users", "system-users",
"system-file-connectors", "system-file-connectors",
"system-mail-servers", "system-mail-servers",
"system-retention",
"tenant-settings", "tenant-settings",
"tenant-roles", "tenant-roles",
"tenant-function-role-mappings", "tenant-function-role-mappings",
@@ -54,14 +50,10 @@ const handledAdminSectionIds = new Set<string>([
"tenant-file-connectors", "tenant-file-connectors",
"tenant-mail-servers", "tenant-mail-servers",
"tenant-api-keys", "tenant-api-keys",
"tenant-retention",
"tenant-audit",
"tenant-group-file-connectors", "tenant-group-file-connectors",
"tenant-group-mail-servers", "tenant-group-mail-servers",
"tenant-group-retention",
"tenant-user-file-connectors", "tenant-user-file-connectors",
"tenant-user-mail-servers", "tenant-user-mail-servers"
"tenant-user-retention"
]); ]);
export default function AdminPage({ export default function AdminPage({
@@ -79,8 +71,6 @@ export default function AdminPage({
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections"); const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const mailProfilesAvailable = Boolean(mailProfilesUi); const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi); const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const auditAvailable = usePlatformModuleInstalled("audit");
const policyAvailable = usePlatformModuleInstalled("policy");
const contributedSections = useMemo( const contributedSections = useMemo(
() => () =>
adminSectionCapabilities adminSectionCapabilities
@@ -102,13 +92,11 @@ export default function AdminPage({
if (canUseContributedSection(auth, section)) sections.add(section.id); if (canUseContributedSection(auth, section)) sections.add(section.id);
} }
if (hasScope(auth, "system:settings:read")) { if (hasScope(auth, "system:settings:read")) {
if (policyAvailable) sections.add("system-retention");
if (mailProfilesAvailable) sections.add("system-mail-servers"); if (mailProfilesAvailable) sections.add("system-mail-servers");
} }
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles"); if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
@@ -123,15 +111,9 @@ export default function AdminPage({
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
} }
if (policyAvailable && hasScope(auth, "admin:policies:read")) {
sections.add("tenant-retention");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention");
}
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
return sections; return sections;
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, policyAvailable]); }, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null; const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -176,7 +158,6 @@ export default function AdminPage({
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20), visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30), visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40), visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
visibleNavItem(available, "system-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
]) ])
) )
@@ -192,7 +173,6 @@ export default function AdminPage({
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50), visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60), visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70), visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
visibleNavItem(available, "system-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds), ...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
]) ])
@@ -209,9 +189,7 @@ export default function AdminPage({
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50), visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60), visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70), visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90), visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 100),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
]) ])
) )
@@ -222,7 +200,6 @@ export default function AdminPage({
sortNavItems([ sortNavItems([
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-group-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
]) ])
) )
@@ -233,7 +210,6 @@ export default function AdminPage({
sortNavItems([ sortNavItems([
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-user-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
]) ])
) )
@@ -248,7 +224,6 @@ export default function AdminPage({
<section className="workspace-content"> <section className="workspace-content">
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
{contributedSection && contributedSection.render(contributionContext)} {contributedSection && contributedSection.render(contributionContext)}
{!contributedSection && active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />}
{!contributedSection && active === "system-mail-servers" && ( {!contributedSection && active === "system-mail-servers" && (
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} /> <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
)} )}
@@ -267,7 +242,6 @@ export default function AdminPage({
/> />
)} )}
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />}
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
@@ -278,11 +252,7 @@ export default function AdminPage({
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />} {!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
</div> </div>
</section> </section>
</div> </div>
@@ -1,161 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchGroupsDelta, fetchUsersDelta, runRetentionPolicy, type GroupSummary, type PrivacyRetentionPolicyScope, type RetentionRunResponse, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
canWrite: boolean;
};
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;policyTitle: string;policyDescription: string;}> = {
system: {
title: "i18n:govoplan-access.system_retention.4191e7f7",
description: "i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224",
policyTitle: "i18n:govoplan-access.system_retention_policy.7027f6ba",
policyDescription: "i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13"
},
tenant: {
title: "i18n:govoplan-access.tenant_retention.95b35db0",
description: "i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c",
policyTitle: "i18n:govoplan-access.tenant_retention_policy.f10893d7",
policyDescription: "i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77"
},
user: {
title: "i18n:govoplan-access.user_retention.f0966bbf",
description: "i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
policyTitle: "i18n:govoplan-access.user_retention_policy.2776f485",
policyDescription: "i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0"
},
group: {
title: "i18n:govoplan-access.group_retention.57cdcdaa",
description: "i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04",
targetLabel: "i18n:govoplan-access.group.171a0606",
policyTitle: "i18n:govoplan-access.group_retention_policy.ad941c0b",
policyDescription: "i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f"
}
};
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
const [targetError, setTargetError] = useState("");
const [busy, setBusy] = useState(false);
const [success, setSuccess] = useState("");
const [runError, setRunError] = useState("");
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows(usersRef.current, "access:retention-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
usersRef.current = users;
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await loadDeltaRows(groupsRef.current, "access:retention-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
groupsRef.current = groups;
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
})));
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
async function runRetention(dryRun: boolean) {
setBusy(true);
setRunError("");
setSuccess("");
try {
const response = await runRetentionPolicy(settings, dryRun);
setRetentionResult(response);
setSuccess(dryRun ? "i18n:govoplan-access.retention_dry_run_completed.91895aee" : "i18n:govoplan-access.retention_policy_applied.7fa4e050");
setConfirmRetentionRun(false);
} catch (err) {setRunError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const labels = copy[scopeType];
return (
<>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
<RetentionPolicyScopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={labels.targetLabel}
title={labels.policyTitle}
description={labels.policyDescription}
canWrite={canWrite} />
{scopeType === "system" &&
<div className="retention-run-card">
<Card title="i18n:govoplan-access.retention_execution.84b7105d">
<p className="muted small-note">i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>i18n:govoplan-access.dry_run.485a3d15</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>i18n:govoplan-access.apply_retention.5b991811</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
</div>
}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="i18n:govoplan-access.apply_retention_policy.9e5d32b4"
message="i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715"
confirmLabel="i18n:govoplan-access.apply_retention.5b991811"
tone="danger"
busy={busy}
onCancel={() => setConfirmRetentionRun(false)}
onConfirm={() => void runRetention(false)} />
</>);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}
+20 -20
View File
@@ -17,7 +17,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.add_api_key.725d9988": "Add API key", "i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account", "i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Add group", "i18n:govoplan-access.add_group.2fca464f": "Add group",
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function role mapping", "i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function mapping",
"i18n:govoplan-access.add_role.d8d5d55c": "Add role", "i18n:govoplan-access.add_role.d8d5d55c": "Add role",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role", "i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user", "i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
@@ -55,7 +55,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key", "i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account", "i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Create group", "i18n:govoplan-access.create_group.5a0b1c17": "Create group",
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function role mapping", "i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function mapping",
"i18n:govoplan-access.create_key.e028cb09": "Create key", "i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Create role", "i18n:govoplan-access.create_role.db859bad": "Create role",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role", "i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
@@ -80,7 +80,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}", "i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Default locale", "i18n:govoplan-access.default_locale.b99d021f": "Default locale",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role", "i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function role mapping", "i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function mapping",
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.", "i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.",
"i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping", "i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
@@ -95,7 +95,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.dry_run.485a3d15": "Dry run", "i18n:govoplan-access.dry_run.485a3d15": "Dry run",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group", "i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function role mapping", "i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function mapping",
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role", "i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role", "i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user", "i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
@@ -114,11 +114,11 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact", "i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
"i18n:govoplan-access.function_facts.848b32cc": "Function facts", "i18n:govoplan-access.function_facts.848b32cc": "Function facts",
"i18n:govoplan-access.function_id.e5e08937": "Function ID", "i18n:govoplan-access.function_id.e5e08937": "Function ID",
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function role mapping created.", "i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function mapping created.",
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function role mapping deleted.", "i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function mapping deleted.",
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.", "i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function role mapping updated.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function role mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
"i18n:govoplan-access.general.9239ee2c": "General", "i18n:govoplan-access.general.9239ee2c": "General",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details", "i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.", "i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
@@ -186,7 +186,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.", "i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.", "i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry", "i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function role mappings found.", "i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function mappings found.",
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.", "i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.", "i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.", "i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
@@ -301,7 +301,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles", "i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy", "i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention", "i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Tenant role templates", "i18n:govoplan-access.tenant_role_templates": "Role templates",
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles", "i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.", "i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details", "i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
@@ -365,7 +365,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.add_api_key.725d9988": "Add API key", "i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account", "i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen", "i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktions-Rollenzuordnung hinzufügen", "i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktionszuordnung hinzufügen",
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen", "i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role", "i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen", "i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
@@ -403,7 +403,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen", "i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account", "i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen", "i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktions-Rollenzuordnung erstellen", "i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktionszuordnung erstellen",
"i18n:govoplan-access.create_key.e028cb09": "Create key", "i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen", "i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role", "i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
@@ -428,7 +428,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}", "i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache", "i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role", "i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktions-Rollenzuordnung löschen", "i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktionszuordnung löschen",
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.", "i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.",
"i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen", "i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
@@ -443,7 +443,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf", "i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten", "i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktions-Rollenzuordnung bearbeiten", "i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktionszuordnung bearbeiten",
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten", "i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role", "i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten", "i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
@@ -462,11 +462,11 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt", "i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
"i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten", "i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten",
"i18n:govoplan-access.function_id.e5e08937": "Funktions-ID", "i18n:govoplan-access.function_id.e5e08937": "Funktions-ID",
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktions-Rollenzuordnung erstellt.", "i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktionszuordnung erstellt.",
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktions-Rollenzuordnung gelöscht.", "i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktionszuordnung gelöscht.",
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.", "i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktions-Rollenzuordnung aktualisiert.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktions-Rollenzuordnungen", "i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
"i18n:govoplan-access.general.9239ee2c": "Allgemein", "i18n:govoplan-access.general.9239ee2c": "Allgemein",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details", "i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.", "i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
@@ -534,7 +534,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.", "i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.", "i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry", "i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktions-Rollenzuordnungen gefunden.", "i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktionszuordnungen gefunden.",
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.", "i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.", "i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.", "i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
@@ -649,7 +649,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles", "i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy", "i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention", "i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Mandantenrollenvorlagen", "i18n:govoplan-access.tenant_role_templates": "Rollenvorlagen",
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen", "i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.", "i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails", "i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",