Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:20 +02:00
parent c71de86a1f
commit a7c486788e
18 changed files with 1527 additions and 137 deletions

265
.gitignore vendored
View File

@@ -8,3 +8,268 @@ dist/
.venv/ .venv/
node_modules/ node_modules/
*.tsbuildinfo *.tsbuildinfo
# GovOPlaN shared ignore rules from govoplan-core
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
develop-eggs/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -45,9 +45,10 @@ importing access internals:
`UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`, `UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`,
`OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`, `OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`,
`FunctionDelegationRef`, `AccessDecisionProvenance`, `FunctionDelegationRef`, `AccessDecisionProvenance`,
`PrincipalResolver`, `AccessDirectory`, `AccessSemanticDirectory`, `ApiPrincipalProvider`, `TenantContextSwitcher`, `PrincipalResolver`,
`PermissionEvaluator`, `AccessExplanationService`, `AccessDirectory`, `AccessSemanticDirectory`, `PermissionEvaluator`,
`TenantAccessProvisioner`, `AccessAdministration`, and `AccessExplanationService`, `TenantAccessProvisioner`,
`AccessAdministration`, and
`AccessGovernanceMaterializer` `AccessGovernanceMaterializer`
- health, platform metadata, and module startup ordering - health, platform metadata, and module startup ordering
- generic security helpers that are not access-state semantics, such as - generic security helpers that are not access-state semantics, such as
@@ -81,12 +82,21 @@ contracts. New module code should pass around `PrincipalRef` or primitive IDs.
- optional `acting_for_account_id` for acting-in-place flows - optional `acting_for_account_id` for acting-in-place flows
- optional display fields `email` and `display_name` - optional display fields `email` and `display_name`
`/api/v1/auth/me`, `/api/v1/auth/login`, profile refreshes, and tenant switches `govoplan_core.auth` is now backed by the `auth.apiPrincipalProvider`
include this payload as `principal` alongside the existing compatibility capability. The access module provides that capability; core no longer imports
fields. Modules that need current user context should prefer access auth dependencies directly. `/api/v1/auth/me`, `/api/v1/auth/login`,
`auth.principal`/`AuthInfo.principal` in the WebUI and profile refreshes, and tenant switches include this payload as `principal`
alongside the existing compatibility fields. Modules that need current user
context should prefer `auth.principal`/`AuthInfo.principal` in the WebUI and
`principal.to_platform_principal()` in backend request handlers. `principal.to_platform_principal()` in backend request handlers.
Interactive tenant context switching is exposed through
`auth.tenantContextSwitcher`. The existing `/api/v1/auth/switch-tenant` route
remains for API compatibility; `govoplan-tenancy` also contributes
`/api/v1/tenancy/switch-tenant`. Both delegate to the same access-owned session
switch behavior. Lifecycle code must use the capability instead of importing
`govoplan_access.backend.security.sessions`.
## Identity And Function Boundary ## Identity And Function Boundary
The full semantic model is documented in The full semantic model is documented in

View File

@@ -5,11 +5,21 @@ from collections.abc import Iterable, Mapping, Sequence
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, ApiKey, Role, User from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
from govoplan_core.core.access import AccessAdministration from govoplan_core.core.access import AccessAdministration
class SqlAccessAdministration(AccessAdministration): class SqlAccessAdministration(AccessAdministration):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
db = _session(session)
return {
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
}
def system_account_count(self, session: object) -> int: def system_account_count(self, session: object) -> int:
db = _session(session) db = _session(session)
return db.query(Account).count() return db.query(Account).count()
@@ -50,6 +60,50 @@ class SqlAccessAdministration(AccessAdministration):
) )
return tuple(user_id for (user_id,) in rows) return tuple(user_id for (user_id,) in rows)
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
user = _session(session).get(User, user_id)
if user is None or user.tenant_id != tenant_id:
return None
return dict(user.settings or {})
def set_user_settings(
self,
session: object,
user_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
db = _session(session)
user = db.get(User, user_id)
if user is None or user.tenant_id != tenant_id:
return None
user.settings = dict(settings)
db.add(user)
return dict(user.settings or {})
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
group = _session(session).get(Group, group_id)
if group is None or group.tenant_id != tenant_id:
return None
return dict(group.settings or {})
def set_group_settings(
self,
session: object,
group_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
db = _session(session)
group = db.get(Group, group_id)
if group is None or group.tenant_id != tenant_id:
return None
group.settings = dict(settings)
db.add(group)
return dict(group.settings or {})
def _session(session: object) -> Session: def _session(session: object) -> Session:
if not isinstance(session, Session): if not isinstance(session, Session):

View File

@@ -260,6 +260,44 @@ class FunctionListResponse(BaseModel):
functions: list[FunctionAdminItem] functions: list[FunctionAdminItem]
class ExternalFunctionRoleMappingItem(BaseModel):
id: str
tenant_id: str
source_module: str
function_id: str
role_id: str
settings: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class ExternalFunctionRoleMappingListResponse(BaseModel):
mappings: list[ExternalFunctionRoleMappingItem]
class ExternalFunctionRoleMappingListDeltaResponse(ExternalFunctionRoleMappingListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = True
class ExternalFunctionRoleMappingCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
function_id: str
role_id: str
source_module: str = Field(default="organizations", max_length=50)
settings: dict[str, Any] = Field(default_factory=dict)
class ExternalFunctionRoleMappingUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role_id: str | None = None
settings: dict[str, Any] | None = None
class FunctionCreateRequest(BaseModel): class FunctionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -38,6 +38,7 @@ from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
collect_system_roles, collect_system_roles,
@@ -46,7 +47,6 @@ from govoplan_access.backend.security.sessions import (
collect_user_roles, collect_user_roles,
collect_user_scopes, collect_user_scopes,
create_auth_session, create_auth_session,
switch_auth_session_tenant,
) )
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -413,12 +413,15 @@ def switch_tenant(
if principal.auth_session is None: if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try: try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id) switched = AccessTenantContextSwitcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id)
except LookupError as exc: except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id) tenant = session.get(Tenant, switched.tenant_id)
membership = session.get(User, switched.membership_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if membership is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
session.commit() session.commit()
return _me_response( return _me_response(
session, session,

View File

@@ -63,6 +63,11 @@ from govoplan_access.backend.api.v1.admin_schemas import (
GroupListResponse, GroupListResponse,
GroupSummary, GroupSummary,
GroupUpdateRequest, GroupUpdateRequest,
ExternalFunctionRoleMappingCreateRequest,
ExternalFunctionRoleMappingItem,
ExternalFunctionRoleMappingListDeltaResponse,
ExternalFunctionRoleMappingListResponse,
ExternalFunctionRoleMappingUpdateRequest,
FunctionAdminItem, FunctionAdminItem,
FunctionAssignmentAdminItem, FunctionAssignmentAdminItem,
FunctionAssignmentCreateRequest, FunctionAssignmentCreateRequest,
@@ -148,6 +153,7 @@ 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.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
from govoplan_core.core.change_sequence import ( from govoplan_core.core.change_sequence import (
decode_sequence_watermark, decode_sequence_watermark,
@@ -160,6 +166,7 @@ from govoplan_core.core.change_sequence import (
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
ApiKey, ApiKey,
ExternalFunctionRoleAssignment,
Function, Function,
FunctionAssignment, FunctionAssignment,
FunctionDelegation, FunctionDelegation,
@@ -190,6 +197,7 @@ ACCESS_ROLES_COLLECTION = "access.admin.roles"
ACCESS_IDENTITIES_COLLECTION = "access.admin.identities" ACCESS_IDENTITIES_COLLECTION = "access.admin.identities"
ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units" ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units"
ACCESS_FUNCTIONS_COLLECTION = "access.admin.functions" ACCESS_FUNCTIONS_COLLECTION = "access.admin.functions"
ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION = "access.admin.external_function_role_mappings"
ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION = "access.admin.function_assignments" ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION = "access.admin.function_assignments"
ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations" ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations"
ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles" ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles"
@@ -439,6 +447,60 @@ def _set_function_roles(session: Session, *, function: Function, role_ids: list[
session.flush() session.flush()
def _external_function_role_mapping_item(item: ExternalFunctionRoleAssignment) -> ExternalFunctionRoleMappingItem:
return ExternalFunctionRoleMappingItem(
id=item.id,
tenant_id=item.tenant_id,
source_module=item.source_module,
function_id=item.function_id,
role_id=item.role_id,
settings=item.settings or {},
created_at=item.created_at,
updated_at=item.updated_at,
)
def _validate_external_function_role(
session: Session,
*,
tenant_id: str,
role_id: str,
) -> Role:
role = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id == role_id, Role.is_assignable.is_(True)).one_or_none()
if role is None:
raise AdminValidationError("Selected role is invalid or not assignable.")
return role
def _organization_directory_or_error() -> OrganizationDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "organizations_required",
"message": "External function role mappings require the Organizations module.",
},
)
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(capability, OrganizationDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
return capability
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
if source_module != ORGANIZATIONS_MODULE_ID:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported function source module: {source_module}",
)
function = _organization_directory_or_error().get_function(function_id)
if function is None or function.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function not found.")
if function.status != "active":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function is not active.")
def _function_assignment_item(item: FunctionAssignment) -> FunctionAssignmentAdminItem: def _function_assignment_item(item: FunctionAssignment) -> FunctionAssignmentAdminItem:
return FunctionAssignmentAdminItem( return FunctionAssignmentAdminItem(
id=item.id, id=item.id,
@@ -1292,6 +1354,206 @@ def deactivate_function(
return None return None
@router.get("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingListResponse)
def list_external_function_role_mappings(
tenant_id: str | None = None,
source_module: str | None = None,
function_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
query = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
if source_module:
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
if function_id:
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
items = (
session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
.all()
)
return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in items],
deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
has_more=False,
full=True,
)
def _external_function_role_mappings_delta_response(
session: Session,
tenant: Tenant,
*,
since: str,
limit: int,
) -> ExternalFunctionRoleMappingListDeltaResponse:
entries, has_more = _access_delta_entries(
session,
tenant_id=tenant.id,
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
since=since,
limit=limit,
)
if entries is None:
return _full_external_function_role_mappings_delta_response(session, tenant)
changed_ids = _changed_ids(entries, "access_external_function_role_mapping")
visible = {
item.id: item
for item in (
session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id, ExternalFunctionRoleAssignment.id.in_(changed_ids))
.all()
if changed_ids
else []
)
}
deleted = [
_delta_deleted_item(entry)
for entry in entries
if entry.resource_type == "access_external_function_role_mapping" and entry.resource_id not in visible
]
return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in visible.values()],
deleted=deleted,
watermark=_access_delta_response_watermark(
session,
tenant_id=tenant.id,
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
entries=entries,
has_more=has_more,
),
has_more=has_more,
full=False,
)
@router.get("/external-function-role-mappings/delta", response_model=ExternalFunctionRoleMappingListDeltaResponse)
def list_external_function_role_mappings_delta(
tenant_id: str | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
if since is None:
return _full_external_function_role_mappings_delta_response(session, tenant)
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
@router.post("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingItem, status_code=status.HTTP_201_CREATED)
def create_external_function_role_mapping(
payload: ExternalFunctionRoleMappingCreateRequest,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
source_module = payload.source_module.strip() or ORGANIZATIONS_MODULE_ID
function_id = payload.function_id.strip()
if not function_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function ID is required.")
_validate_external_function_source(tenant_id=tenant.id, source_module=source_module, function_id=function_id)
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
item = ExternalFunctionRoleAssignment(
tenant_id=tenant.id,
source_module=source_module,
function_id=function_id,
role_id=payload.role_id,
settings=payload.settings,
)
session.add(item)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=item.id,
operation="created",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": source_module, "function_id": function_id, "role_id": payload.role_id},
)
session.commit()
return _external_function_role_mapping_item(item)
@router.patch("/external-function-role-mappings/{mapping_id}", response_model=ExternalFunctionRoleMappingItem)
def update_external_function_role_mapping(
mapping_id: str,
payload: ExternalFunctionRoleMappingUpdateRequest,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
_validate_external_function_source(tenant_id=tenant.id, source_module=item.source_module, function_id=item.function_id)
if payload.role_id is not None:
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
item.role_id = payload.role_id
if payload.settings is not None:
item.settings = payload.settings
session.add(item)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=item.id,
operation="updated",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
)
session.commit()
return _external_function_role_mapping_item(item)
@router.delete("/external-function-role-mappings/{mapping_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_external_function_role_mapping(
mapping_id: str,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
session.delete(item)
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=mapping_id,
operation="deleted",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
)
session.commit()
return None
@router.get("/function-assignments", response_model=FunctionAssignmentListResponse) @router.get("/function-assignments", response_model=FunctionAssignmentListResponse)
def list_function_assignments( def list_function_assignments(
tenant_id: str | None = None, tenant_id: str | None = None,

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import ( from govoplan_core.core.access import (
CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
@@ -14,12 +13,13 @@ from govoplan_core.core.access import (
PrincipalRef, PrincipalRef,
PrincipalResolver, PrincipalResolver,
) )
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
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
from govoplan_core.db.session import get_database, get_session from govoplan_core.db.session import get_database, get_session
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Tenant, User from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, 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.api_keys import authenticate_api_key from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token, authenticate_session_token,
@@ -29,99 +29,10 @@ from govoplan_access.backend.security.sessions import (
verify_auth_session_csrf, verify_auth_session_csrf,
) )
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import intersect_api_key_scopes from govoplan_core.security.permissions import expand_scopes, intersect_api_key_scopes
from govoplan_core.settings import settings from govoplan_core.settings import settings
@dataclass(slots=True)
class ApiPrincipal:
"""Compatibility wrapper around the platform Principal DTO.
Existing routers still expect ORM ``account`` and ``user`` objects. New
platform/module code should use the stable ID fields or
``to_platform_principal()`` instead.
"""
principal: PrincipalRef
account: Account
user: User
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
permission_evaluator: PermissionEvaluator | None = None
@property
def account_id(self) -> str:
return self.principal.account_id
@property
def membership_id(self) -> str | None:
return self.principal.membership_id
@property
def tenant_id(self) -> str:
if self.principal.tenant_id is None:
raise RuntimeError("Tenant principal has no active tenant id.")
return self.principal.tenant_id
@property
def scopes(self) -> frozenset[str]:
return self.principal.scopes
@property
def group_ids(self) -> frozenset[str]:
return self.principal.group_ids
@property
def role_ids(self) -> frozenset[str]:
return self.principal.role_ids
@property
def function_assignment_ids(self) -> frozenset[str]:
return self.principal.function_assignment_ids
@property
def delegation_ids(self) -> frozenset[str]:
return self.principal.delegation_ids
@property
def identity_id(self) -> str | None:
return self.principal.identity_id
@property
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
@property
def api_key_id(self) -> str | None:
return self.principal.api_key_id
@property
def session_id(self) -> str | None:
return self.principal.session_id
@property
def email(self) -> str | None:
return self.principal.email
@property
def display_name(self) -> str | None:
return self.principal.display_name
def has(self, required_scope: str) -> bool:
if self.permission_evaluator is not None:
return self.permission_evaluator.has_scope(self.principal, required_scope)
# Keep legacy scope aliases alive while current routers still use the
# pre-platform permission catalogue.
return scopes_grant_compatible(self.scopes, required_scope)
def to_platform_principal(self) -> PrincipalRef:
return self.principal
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key: if x_api_key:
return x_api_key.strip(), "api_key" return x_api_key.strip(), "api_key"
@@ -151,7 +62,14 @@ def _build_principal_ref(
auth_method: str, auth_method: str,
api_key: ApiKey | None = None, api_key: ApiKey | None = None,
auth_session: AuthSession | None = None, auth_session: AuthSession | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
extra_roles: tuple[Role, ...] = (),
) -> PrincipalRef: ) -> PrincipalRef:
function_assignment_ids = list(collect_function_assignment_ids(session, user))
function_assignment_ids.extend(item.id for item in idm_assignments)
roles = collect_user_roles(session, user)
role_ids = [role.id for role in roles]
role_ids.extend(role.id for role in extra_roles)
return PrincipalRef( return PrincipalRef(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
@@ -159,8 +77,8 @@ def _build_principal_ref(
identity_id=identity_id_for_account(session, account.id), identity_id=identity_id_for_account(session, account.id),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user), group_ids=_principal_group_ids(session, user),
role_ids=frozenset(role.id for role in collect_user_roles(session, user)), role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)), delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
auth_method=auth_method, # type: ignore[arg-type] auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None, api_key_id=api_key.id if api_key else None,
@@ -209,6 +127,7 @@ 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,
) -> 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:
@@ -227,7 +146,9 @@ def _resolve_legacy_principal_ref(
or user.tenant_id != api_key.tenant_id or user.tenant_id != api_key.tenant_id
): ):
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")
user_scopes = collect_user_scopes(session, user, include_system=False) 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))
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()
return _build_principal_ref( return _build_principal_ref(
@@ -238,6 +159,8 @@ def _resolve_legacy_principal_ref(
tenant_id=api_key.tenant_id, tenant_id=api_key.tenant_id,
scopes=effective_scopes, scopes=effective_scopes,
auth_method="api_key", auth_method="api_key",
idm_assignments=idm_assignments,
extra_roles=idm_roles,
) )
auth_session = authenticate_session_token(session, token) auth_session = authenticate_session_token(session, token)
@@ -257,7 +180,9 @@ def _resolve_legacy_principal_ref(
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
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")
scopes = collect_user_scopes(session, user, include_system=True) 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))
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(
session, session,
@@ -267,11 +192,31 @@ def _resolve_legacy_principal_ref(
tenant_id=user.tenant_id, tenant_id=user.tenant_id,
scopes=scopes, scopes=scopes,
auth_method="session", auth_method="session",
idm_assignments=idm_assignments,
extra_roles=idm_roles,
) )
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _idm_assignments_for_account(
idm_directory: IdmDirectory | None,
account_id: str,
*,
tenant_id: str,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
if idm_directory is None:
return ()
return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id))
def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]:
merged = set(scopes)
for role in roles:
merged.update(role.permissions or [])
return expand_scopes(merged)
def _registry_from_request(request: Request) -> PlatformRegistry | None: def _registry_from_request(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None return registry if isinstance(registry, PlatformRegistry) else None
@@ -312,15 +257,30 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator
class LegacyPrincipalResolver: class LegacyPrincipalResolver:
def __init__(self, idm_directory: IdmDirectory | None = None) -> None:
self._idm_directory = idm_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):
raise TypeError("LegacyPrincipalResolver requires a FastAPI request") raise TypeError("LegacyPrincipalResolver requires a FastAPI request")
authorization = request.headers.get("authorization") authorization = request.headers.get("authorization")
x_api_key = request.headers.get("x-api-key") x_api_key = request.headers.get("x-api-key")
if isinstance(session, Session): if isinstance(session, Session):
return _resolve_legacy_principal_ref(request, session, authorization=authorization, x_api_key=x_api_key) return _resolve_legacy_principal_ref(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=self._idm_directory,
)
with get_database().session() as managed_session: with get_database().session() as managed_session:
return _resolve_legacy_principal_ref(request, managed_session, authorization=authorization, x_api_key=x_api_key) return _resolve_legacy_principal_ref(
request,
managed_session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=self._idm_directory,
)
class LegacyPermissionEvaluator: class LegacyPermissionEvaluator:
@@ -339,11 +299,12 @@ class LegacyPermissionEvaluator:
) )
def get_api_principal( def resolve_api_principal(
request: Request, request: Request,
session: Session = Depends(get_session), session: Session,
authorization: str | None = Header(default=None), *,
x_api_key: str | None = Header(default=None, alias="X-API-Key"), authorization: str | None = None,
x_api_key: str | None = None,
) -> ApiPrincipal: ) -> ApiPrincipal:
resolver = _principal_resolver_from_request(request) resolver = _principal_resolver_from_request(request)
permission_evaluator = _permission_evaluator_from_request(request) permission_evaluator = _permission_evaluator_from_request(request)
@@ -364,6 +325,41 @@ def get_api_principal(
return api_principal return api_principal
class AccessApiPrincipalProvider:
def resolve_api_principal(
self,
request: object,
session: object,
*,
authorization: str | None = None,
x_api_key: str | None = None,
) -> ApiPrincipal:
if not isinstance(request, Request):
raise TypeError("AccessApiPrincipalProvider requires a FastAPI request")
if not isinstance(session, Session):
raise TypeError("AccessApiPrincipalProvider requires a SQLAlchemy session")
return resolve_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
def get_api_principal(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
return resolve_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None: def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None:
mode = saved_maintenance_mode(session) mode = saved_maintenance_mode(session)
if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE): if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE):

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import TenantContextSwitchRef
from govoplan_access.backend.db.models import AuthSession
from govoplan_access.backend.security.sessions import switch_auth_session_tenant
class AccessTenantContextSwitcher:
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
if not isinstance(session, Session):
raise TypeError("AccessTenantContextSwitcher requires a SQLAlchemy session")
if not isinstance(principal, ApiPrincipal):
raise TypeError("AccessTenantContextSwitcher requires an API principal")
if not isinstance(principal.auth_session, AuthSession):
raise ValueError("API keys cannot switch tenant context")
membership = switch_auth_session_tenant(session, principal.auth_session, tenant_id)
return TenantContextSwitchRef(
account_id=principal.account_id,
membership_id=membership.id,
tenant_id=membership.tenant_id,
session_id=principal.session_id,
)

View File

@@ -191,6 +191,26 @@ class FunctionRoleAssignment(AccessBase, TimestampMixin):
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ExternalFunctionRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "access_external_function_role_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"source_module",
"function_id",
"role_id",
name="uq_external_function_role_assignments",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_module: Mapped[str] = mapped_column(String(50), default="organizations", nullable=False, index=True)
function_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class FunctionAssignment(AccessBase, TimestampMixin): class FunctionAssignment(AccessBase, TimestampMixin):
__tablename__ = "access_function_assignments" __tablename__ = "access_function_assignments"
__table_args__ = ( __table_args__ = (
@@ -331,6 +351,7 @@ __all__ = [
"FunctionAssignment", "FunctionAssignment",
"FunctionDelegation", "FunctionDelegation",
"FunctionRoleAssignment", "FunctionRoleAssignment",
"ExternalFunctionRoleAssignment",
"Group", "Group",
"GroupRoleAssignment", "GroupRoleAssignment",
"Identity", "Identity",

View File

@@ -13,6 +13,7 @@ from govoplan_core.core.access import (
OrganizationUnitRef, OrganizationUnitRef,
UserRef, UserRef,
) )
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
Function, Function,
@@ -114,7 +115,28 @@ def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
) )
def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id: str) -> FunctionAssignmentRef:
return FunctionAssignmentRef(
id=item.id,
tenant_id=item.tenant_id,
account_id=item.account_id or account_id,
identity_id=item.identity_id,
function_id=item.function_id,
organization_unit_id=item.organization_unit_id,
applies_to_subunits=item.applies_to_subunits,
source=item.source, # type: ignore[arg-type]
delegated_from_assignment_id=item.delegated_from_assignment_id,
acting_for_account_id=item.acting_for_account_id,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=item.status, # type: ignore[arg-type]
)
class SqlAccessDirectory(AccessSemanticDirectory): class SqlAccessDirectory(AccessSemanticDirectory):
def __init__(self, idm_directory: IdmDirectory | None = None) -> None:
self._idm_directory = idm_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:
account = session.get(Account, account_id) account = session.get(Account, account_id)
@@ -312,4 +334,14 @@ class SqlAccessDirectory(AccessSemanticDirectory):
) -> tuple[FunctionAssignmentRef, ...]: ) -> tuple[FunctionAssignmentRef, ...]:
with get_database().session() as session: with get_database().session() as session:
items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id) items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id)
return tuple(_function_assignment_ref(item) for item in items) assignments = [_function_assignment_ref(item) for item in items]
if self._idm_directory is not None:
assignments.extend(
_idm_function_assignment_ref(item, account_id=account_id)
for item in self._idm_directory.organization_function_assignments_for_account(
account_id,
tenant_id=tenant_id,
)
)
deduped = {item.id: item for item in assignments}
return tuple(deduped.values())

View File

@@ -13,9 +13,12 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
) )
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition, DocumentationCondition,
@@ -318,14 +321,51 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
], ],
}, },
), ),
DocumentationTopic(
id="access.reference.external-function-role-mappings",
title="Function facts and access roles",
summary="Access maps accepted organization function facts to roles and permissions. IDM assignment ownership remains separate from authorization.",
body=(
"When IDM is installed, Access can consume identity-to-organization-function assignments through the IDM directory capability. "
"Organizations must be installed because Access validates mappings against the Organizations function directory instead of accepting arbitrary function identifiers. "
"An assignment does not grant rights by itself. Access grants rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
"Tenant administrators manage these mappings under Admin > Function role mappings, and the same records are exposed through the admin API. "
"This keeps the audit trail clear: IDM records who holds a function, while Access records which function facts produce role-derived permissions."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
order=32,
conditions=(
DocumentationCondition(
required_modules=("access", "organizations"),
any_scopes=("access:function:read", "access:role:read", "admin:roles:read"),
),
),
links=(
DocumentationLink(label="Function role mappings", href="/admin?section=tenant-function-role-mappings", kind="runtime"),
DocumentationLink(label="External function role mappings API", href="/api/v1/admin/external-function-role-mappings", kind="api"),
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
),
metadata={
"kind": "reference",
"route": "/admin",
"api_path": "/api/v1/admin/external-function-role-mappings",
"permission_scopes": ["access:function:write", "access:role:assign"],
"related_topic_ids": [
"idm.workflow.assign-function-to-identity",
"docs.reference.organization-identity-idm-access-boundary",
],
},
),
) )
def _legacy_principal_resolver(context: ModuleContext) -> object: def _legacy_principal_resolver(context: ModuleContext) -> object:
del context
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
return LegacyPrincipalResolver() idm_directory = _optional_idm_directory(context)
return LegacyPrincipalResolver(idm_directory=idm_directory)
def _legacy_permission_evaluator(context: ModuleContext) -> object: def _legacy_permission_evaluator(context: ModuleContext) -> object:
@@ -335,18 +375,39 @@ def _legacy_permission_evaluator(context: ModuleContext) -> object:
return LegacyPermissionEvaluator() return LegacyPermissionEvaluator()
def _access_directory(context: ModuleContext) -> object: def _api_principal_provider(context: ModuleContext) -> object:
del context del context
from govoplan_access.backend.auth.dependencies import AccessApiPrincipalProvider
return AccessApiPrincipalProvider()
def _tenant_context_switcher(context: ModuleContext) -> object:
del context
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
return AccessTenantContextSwitcher()
def _access_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory() return SqlAccessDirectory(idm_directory=_optional_idm_directory(context))
def _access_semantic_directory(context: ModuleContext) -> object: def _access_semantic_directory(context: ModuleContext) -> object:
del context
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory() return SqlAccessDirectory(idm_directory=_optional_idm_directory(context))
def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
if not context.registry.has_capability(CAPABILITY_IDM_DIRECTORY):
return None
capability = context.registry.require_capability(CAPABILITY_IDM_DIRECTORY)
if not isinstance(capability, IdmDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
return capability
def _access_explanation_service(context: ModuleContext) -> object: def _access_explanation_service(context: ModuleContext) -> object:
@@ -404,7 +465,7 @@ manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.6", version="0.1.6",
optional_dependencies=("identity", "organizations", "tenancy"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
route_factory=_route_factory, route_factory=_route_factory,
@@ -424,6 +485,7 @@ manifest = ModuleManifest(
access_models.OrganizationUnit, access_models.OrganizationUnit,
access_models.Function, access_models.Function,
access_models.FunctionRoleAssignment, access_models.FunctionRoleAssignment,
access_models.ExternalFunctionRoleAssignment,
access_models.FunctionAssignment, access_models.FunctionAssignment,
access_models.FunctionDelegation, access_models.FunctionDelegation,
access_models.SystemRoleAssignment, access_models.SystemRoleAssignment,
@@ -443,8 +505,10 @@ manifest = ModuleManifest(
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),), nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
), ),
capability_factories={ capability_factories={
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_ACCESS_DIRECTORY: _access_directory, CAPABILITY_ACCESS_DIRECTORY: _access_directory,

View File

@@ -204,6 +204,38 @@ def upgrade() -> None:
_create_index_if_missing(op.f("ix_access_function_role_assignments_role_id"), "access_function_role_assignments", ["role_id"]) _create_index_if_missing(op.f("ix_access_function_role_assignments_role_id"), "access_function_role_assignments", ["role_id"])
_create_index_if_missing(op.f("ix_access_function_role_assignments_tenant_id"), "access_function_role_assignments", ["tenant_id"]) _create_index_if_missing(op.f("ix_access_function_role_assignments_tenant_id"), "access_function_role_assignments", ["tenant_id"])
tables = _tables()
if "access_external_function_role_assignments" not in tables:
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"],
[scope_fk_target],
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"),
)
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_function_id"), "access_external_function_role_assignments", ["function_id"])
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_role_id"), "access_external_function_role_assignments", ["role_id"])
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_source_module"), "access_external_function_role_assignments", ["source_module"])
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_tenant_id"), "access_external_function_role_assignments", ["tenant_id"])
tables = _tables() tables = _tables()
if "access_function_assignments" not in tables: if "access_function_assignments" not in tables:
op.create_table( op.create_table(
@@ -353,6 +385,15 @@ def downgrade() -> None:
op.f("ix_access_function_assignments_account_id"), op.f("ix_access_function_assignments_account_id"),
), ),
), ),
(
"access_external_function_role_assignments",
(
op.f("ix_access_external_function_role_assignments_tenant_id"),
op.f("ix_access_external_function_role_assignments_source_module"),
op.f("ix_access_external_function_role_assignments_role_id"),
op.f("ix_access_external_function_role_assignments_function_id"),
),
),
( (
"access_function_role_assignments", "access_function_role_assignments",
( (

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
ExternalFunctionRoleAssignment,
Function, Function,
FunctionAssignment, FunctionAssignment,
FunctionDelegation, FunctionDelegation,
@@ -16,6 +17,7 @@ from govoplan_access.backend.db.models import (
Role, Role,
User, User,
) )
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@@ -154,3 +156,31 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
.order_by(Role.name.asc()) .order_by(Role.name.asc())
.all() .all()
) )
def collect_external_function_roles(
session: Session,
user: User,
assignments: Iterable[OrganizationFunctionAssignmentRef],
*,
source_module: str = "organizations",
) -> list[Role]:
function_ids = sorted({
assignment.function_id
for assignment in assignments
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
})
if not function_ids:
return []
return (
session.query(Role)
.join(ExternalFunctionRoleAssignment, ExternalFunctionRoleAssignment.role_id == Role.id)
.filter(
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
ExternalFunctionRoleAssignment.source_module == source_module,
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)

View File

@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ensure_default_roles from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
from govoplan_access.backend.db.models import Account, User, UserRoleAssignment from govoplan_access.backend.db.models import Account, SystemRoleAssignment, User, UserRoleAssignment
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.passwords import hash_password
from govoplan_core.admin.common import AdminValidationError from govoplan_core.admin.common import AdminValidationError
from govoplan_core.core.access import TenantAccessProvisioner, TenantOwnerCandidateRef from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef
class LegacyTenantAccessProvisioner(TenantAccessProvisioner): class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
@@ -75,6 +77,97 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
db.flush() db.flush()
return membership.id return membership.id
def ensure_development_admin(
self,
session: object,
*,
tenant: object,
user_email: str,
user_password: str,
api_key_secret: str | None,
scopes: Sequence[str],
) -> DevelopmentBootstrapRef:
db = _session(session)
tenant_id = getattr(tenant, "id", None)
if not tenant_id:
raise AdminValidationError("Development bootstrap requires a persisted tenant.")
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
system_roles = ensure_default_roles(db, None)
account, _, _ = get_or_create_account(
db,
email=user_email,
display_name="Development Admin",
password=user_password,
password_reset_required=False,
)
if not account.password_hash:
account.password_hash = hash_password(user_password)
account.is_active = True
db.add(account)
user = db.query(User).filter(User.tenant_id == tenant_id, User.account_id == account.id).one_or_none()
if user is None:
user = User(
tenant_id=tenant_id,
account_id=account.id,
email=account.email,
display_name="Development Admin",
is_tenant_admin=True,
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
db.add(user)
db.flush()
else:
user.email = account.email
user.password_hash = account.password_hash
user.is_active = True
db.add(user)
owner_role = tenant_roles["owner"]
existing_assignment = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.role_id == owner_role.id,
).one_or_none()
if existing_assignment is None:
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=user.id, role_id=owner_role.id))
system_owner = system_roles["system_owner"]
existing_system_assignment = db.query(SystemRoleAssignment).filter(
SystemRoleAssignment.account_id == account.id,
SystemRoleAssignment.role_id == system_owner.id,
).one_or_none()
if existing_system_assignment is None:
db.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
created_api_key = None
if api_key_secret:
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
if not existing:
api_key = create_api_key(
db,
user=user,
name="Development API key",
scopes=list(scopes),
secret=api_key_secret,
)
created_api_key = CreatedApiKeyRef(id=api_key.model.id, secret=api_key.secret)
db.flush()
return DevelopmentBootstrapRef(
user=UserRef(
id=user.id,
account_id=account.id,
tenant_id=tenant_id,
email=user.email,
display_name=user.display_name,
status="active" if user.is_active else "inactive",
),
created_api_key=created_api_key,
)
def _session(session: object) -> Session: def _session(session: object) -> Session:
if not isinstance(session, Session): if not isinstance(session, Session):

View File

@@ -242,6 +242,17 @@ export type ApiKeyAdminItem = {
created_at: string; created_at: string;
}; };
export type ExternalFunctionRoleMappingItem = {
id: string;
tenant_id: string;
source_module: string;
function_id: string;
role_id: string;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AuditAdminItem = { export type AuditAdminItem = {
id: string; id: string;
scope: "tenant" | "system"; scope: "tenant" | "system";
@@ -268,6 +279,7 @@ export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; ro
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields; export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields; export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields; export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
export type TenantSettingsDeltaResponse = { export type TenantSettingsDeltaResponse = {
item?: TenantSettingsItem | null; item?: TenantSettingsItem | null;
sections: TenantSettingsDeltaSections; sections: TenantSettingsDeltaSections;
@@ -451,6 +463,31 @@ export function deleteRole(settings: ApiSettings, roleId: string): Promise<void>
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
} }
export function fetchExternalFunctionRoleMappingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<ExternalFunctionRoleMappingListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/delta${suffix}`);
}
export function createExternalFunctionRoleMapping(settings: ApiSettings, payload: {
source_module: string;
function_id: string;
role_id: string;
settings?: Record<string, unknown>;
}): Promise<ExternalFunctionRoleMappingItem> {
return apiFetch(settings, "/api/v1/admin/external-function-role-mappings", { method: "POST", body: JSON.stringify(payload) });
}
export function updateExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string, payload: {
role_id?: string;
settings?: Record<string, unknown>;
}): Promise<ExternalFunctionRoleMappingItem> {
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles; return response.roles;

View File

@@ -6,7 +6,8 @@ import type {
ApiSettings, ApiSettings,
AuthInfo, AuthInfo,
FilesConnectorsUiCapability, FilesConnectorsUiCapability,
MailProfilesUiCapability MailProfilesUiCapability,
OrganizationFunctionPickerUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui"; import { fetchMe } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
@@ -19,6 +20,7 @@ import TenantsPanel from "./TenantsPanel";
import UsersPanel from "./UsersPanel"; import UsersPanel from "./UsersPanel";
import GroupsPanel from "./GroupsPanel"; import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel"; import RolesPanel from "./RolesPanel";
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
import ApiKeysPanel from "./ApiKeysPanel"; import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel"; import AdminAuditPanel from "./AdminAuditPanel";
import FileConnectorsPanel from "./FileConnectorsPanel"; import FileConnectorsPanel from "./FileConnectorsPanel";
@@ -46,6 +48,7 @@ const handledAdminSectionIds = new Set<string>([
"system-retention", "system-retention",
"tenant-settings", "tenant-settings",
"tenant-roles", "tenant-roles",
"tenant-function-role-mappings",
"tenant-groups", "tenant-groups",
"tenant-users", "tenant-users",
"tenant-file-connectors", "tenant-file-connectors",
@@ -72,6 +75,7 @@ export default function AdminPage({
}) { }) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
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);
@@ -108,6 +112,7 @@ export default function AdminPage({
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");
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys"); if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) { if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
sections.add("tenant-mail-servers"); sections.add("tenant-mail-servers");
@@ -126,7 +131,7 @@ export default function AdminPage({
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"); if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
return sections; return sections;
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]); }, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, policyAvailable]);
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");
@@ -198,14 +203,15 @@ export default function AdminPage({
items: asSubnavItems( items: asSubnavItems(
sortNavItems([ sortNavItems([
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10), visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20), visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30), visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40), visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50), visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60), visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70), visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80), visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 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)
]) ])
) )
@@ -265,6 +271,7 @@ export default function AdminPage({
{!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} />}
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />} {!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />} {!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" 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-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}

View File

@@ -0,0 +1,377 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import {
createExternalFunctionRoleMapping,
deleteExternalFunctionRoleMapping,
fetchExternalFunctionRoleMappingsDelta,
fetchRolesDelta,
updateExternalFunctionRoleMapping,
type ExternalFunctionRoleMappingItem,
type RoleSummary
} from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
sourceModule: "organizations",
functionId: "",
functionLabel: "",
organizationUnitLabel: "",
roleId: ""
};
export default function ExternalFunctionRoleMappingsPanel({
settings,
auth,
functionPicker,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
auth: AuthInfo;
functionPicker: OrganizationFunctionPickerUiCapability;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [mappings, setMappings] = useState<ExternalFunctionRoleMappingItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const mappingsRef = useRef<ExternalFunctionRoleMappingItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<ExternalFunctionRoleMappingItem | "new" | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [deleting, setDeleting] = useState<ExternalFunctionRoleMappingItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextMappings, nextRoles] = await Promise.all([
loadDeltaRows(
mappingsRef.current,
"access:external-function-role-mappings",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchExternalFunctionRoleMappingsDelta(settings, { since }),
(response) => response.mappings,
(mapping) => mapping.id,
"access_external_function_role_mapping",
sortMappings
),
loadDeltaRows(
rolesRef.current,
"access:roles-for-external-function-mappings",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchRolesDelta(settings, { since }),
(response) => response.roles,
(role) => role.id,
"access_role",
sortRoles
)
]);
mappingsRef.current = nextMappings;
rolesRef.current = nextRoles;
setMappings(nextMappings);
setRoles(nextRoles.filter((role) => role.level === "tenant"));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
mappingsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, tenantId, resetDeltaWatermark]);
const roleById = useMemo(() => new Map(roles.map((role) => [role.id, role])), [roles]);
const assignableRoles = useMemo(() => roles.filter((role) => role.is_assignable), [roles]);
function openCreate() {
const initial = { ...emptyDraft, roleId: assignableRoles[0]?.id ?? "" };
setDraft(initial);
setSavedDraftKey(draftKey(initial));
setEditing("new");
setError("");
}
function openEdit(mapping: ExternalFunctionRoleMappingItem) {
const nextDraft = {
sourceModule: mapping.source_module,
functionId: mapping.function_id,
functionLabel: "",
organizationUnitLabel: "",
roleId: mapping.role_id
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(mapping);
setError("");
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createExternalFunctionRoleMapping(settings, {
source_module: functionPicker.sourceModule,
function_id: draft.functionId.trim(),
role_id: draft.roleId
});
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_created.7a25eb5a"));
} else if (editing) {
await updateExternalFunctionRoleMapping(settings, editing.id, { role_id: draft.roleId });
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_updated.76020443"));
}
closeEditor();
await onAuthRefresh();
mappingsRef.current = [];
resetDeltaWatermark("access:external-function-role-mappings");
await load();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function remove() {
if (!deleting) return;
setBusy(true);
setError("");
try {
await deleteExternalFunctionRoleMapping(settings, deleting.id);
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_deleted.fb180786"));
setDeleting(null);
await onAuthRefresh();
mappingsRef.current = [];
resetDeltaWatermark("access:external-function-role-mappings");
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const columns = useMemo<DataGridColumn<ExternalFunctionRoleMappingItem>[]>(
() => [
{
id: "function",
header: "i18n:govoplan-access.function_fact.4f7435e4",
width: "minmax(260px, 1fr)",
minWidth: 220,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.source_module} ${row.function_id}`,
render: (row) => (
<>
{functionPicker.renderLabel?.({
settings,
auth,
sourceModule: row.source_module,
functionId: row.function_id,
fallback: (
<div>
<strong>{row.source_module}</strong>
<div className="muted small-note">{row.function_id}</div>
</div>
)
}) ?? (
<div>
<strong>{row.source_module}</strong>
<div className="muted small-note">{row.function_id}</div>
</div>
)}
</>
)
},
{
id: "role",
header: "i18n:govoplan-access.role.c3f104d1",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sortable: true,
filterable: true,
value: (row) => roleById.get(row.role_id)?.name ?? row.role_id,
render: (row) => {
const role = roleById.get(row.role_id);
return (
<div>
<strong>{role?.name ?? row.role_id}</strong>
{role && <div className="muted small-note">{role.slug}</div>}
</div>
);
}
},
{
id: "updated",
header: "i18n:govoplan-access.updated.f2f8570d",
width: 180,
minWidth: 150,
resizable: true,
sortable: true,
value: (row) => row.updated_at,
render: (row) => formatDateTime(row.updated_at)
},
{
id: "actions",
header: "i18n:govoplan-access.actions.c3cd636a",
width: 130,
sticky: "end",
resizable: false,
align: "right",
render: (row) => (
<div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
</div>
)
}
],
[auth, canWrite, functionPicker, roleById, settings]
);
return (
<>
<AdminPageLayout
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
loading={loading}
error={error}
success={success}
actions={
<>
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} />
</>
}
>
<div className="admin-table-surface">
<DataGrid
id="admin-external-function-role-mappings-v1"
rows={mappings}
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54"
/>
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
onClose={() => !busy && closeEditor()}
className="admin-dialog"
footer={
<>
<Button onClick={closeEditor} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.functionId.trim() || !draft.roleId}>
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
</Button>
</>
}
>
<div className="admin-form-grid">
<FormField label="i18n:govoplan-access.function_id.e5e08937">
{functionPicker.renderPicker({
settings,
auth,
disabled: editing !== "new",
value: draft.functionId ? draftSelection(draft) : null,
onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) })
})}
</FormField>
<FormField label="i18n:govoplan-access.role.c3f104d1">
<select value={draft.roleId} onChange={(event) => setDraft({ ...draft, roleId: event.target.value })}>
<option value="">i18n:govoplan-access.select_role.c543f191</option>
{assignableRoles.map((role) => (
<option key={role.id} value={role.id}>{role.name} ({role.slug})</option>
))}
</select>
</FormField>
</div>
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
</Dialog>
<ConfirmDialog
open={Boolean(deleting)}
title="i18n:govoplan-access.delete_function_role_mapping.0c0eec6e"
message={i18nMessage("i18n:govoplan-access.delete_function_role_mapping_value.419da2aa", { value0: deleting?.function_id })}
confirmLabel="i18n:govoplan-access.delete_mapping.0d27d92a"
tone="danger"
busy={busy}
onCancel={() => setDeleting(null)}
onConfirm={() => void remove()}
/>
</>
);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function draftSelection(draft: typeof emptyDraft): OrganizationFunctionSelection {
return {
sourceModule: "organizations",
functionId: draft.functionId,
label: draft.functionLabel || null,
organizationUnitLabel: draft.organizationUnitLabel || null
};
}
function selectionDraft(selection: OrganizationFunctionSelection | null): Pick<typeof emptyDraft, "sourceModule" | "functionId" | "functionLabel" | "organizationUnitLabel"> {
return {
sourceModule: selection?.sourceModule ?? "organizations",
functionId: selection?.functionId ?? "",
functionLabel: selection?.label ?? "",
organizationUnitLabel: selection?.organizationUnitLabel ?? ""
};
}
function sortMappings(left: ExternalFunctionRoleMappingItem, right: ExternalFunctionRoleMappingItem): number {
const sourceDelta = left.source_module.localeCompare(right.source_module);
return sourceDelta !== 0 ? sourceDelta : left.function_id.localeCompare(right.function_id);
}
function sortRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -16,6 +16,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_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",
@@ -52,6 +53,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_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",
@@ -76,6 +78,9 @@ 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_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_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.", "i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.", "i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
@@ -87,6 +92,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_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",
@@ -101,6 +107,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.file_connections.1e362326": "File connections", "i18n:govoplan-access.file_connections.1e362326": "File connections",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable", "i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
"i18n:govoplan-access.files.6ce6c512": "Files", "i18n:govoplan-access.files.6ce6c512": "Files",
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
"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_deleted.fb180786": "Function role 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_updated.76020443": "Function role mapping updated.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function role 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.",
@@ -163,6 +176,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_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.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.", "i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
@@ -207,6 +221,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.save_account.0b761f5c": "Save account", "i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings", "i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Save group", "i18n:govoplan-access.save_group.36ca6865": "Save group",
"i18n:govoplan-access.save_mapping.a4ac90e9": "Save mapping",
"i18n:govoplan-access.save_role.16fe10d1": "Save role", "i18n:govoplan-access.save_role.16fe10d1": "Save role",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Save tenant", "i18n:govoplan-access.save_tenant.9eb2ac74": "Save tenant",
"i18n:govoplan-access.save_user.0d071b89": "Save user", "i18n:govoplan-access.save_user.0d071b89": "Save user",
@@ -214,11 +229,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.saving.ae7e8875": "Saving...", "i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Scope", "i18n:govoplan-access.scope.4651a34e": "Scope",
"i18n:govoplan-access.scopes.c23540e5": "Scopes", "i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_role.c543f191": "Select role",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user", "i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.", "i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.", "i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked", "i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug", "i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.source_module.62b7241c": "Source module",
"i18n:govoplan-access.status.bae7d5be": "Status", "i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.", "i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant", "i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
@@ -329,6 +346,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_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",
@@ -365,6 +383,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_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",
@@ -389,6 +408,9 @@ 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_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_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.", "i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.", "i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
@@ -400,6 +422,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_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",
@@ -414,6 +437,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen", "i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar", "i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
"i18n:govoplan-access.files.6ce6c512": "Dateien", "i18n:govoplan-access.files.6ce6c512": "Dateien",
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
"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_deleted.fb180786": "Funktions-Rollenzuordnung 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_updated.76020443": "Funktions-Rollenzuordnung aktualisiert.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktions-Rollenzuordnungen",
"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.",
@@ -476,6 +506,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_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.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.", "i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
@@ -520,6 +551,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.save_account.0b761f5c": "Save account", "i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings", "i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Gruppe speichern", "i18n:govoplan-access.save_group.36ca6865": "Gruppe speichern",
"i18n:govoplan-access.save_mapping.a4ac90e9": "Zuordnung speichern",
"i18n:govoplan-access.save_role.16fe10d1": "Rolle speichern", "i18n:govoplan-access.save_role.16fe10d1": "Rolle speichern",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Mandant speichern", "i18n:govoplan-access.save_tenant.9eb2ac74": "Mandant speichern",
"i18n:govoplan-access.save_user.0d071b89": "Benutzer speichern", "i18n:govoplan-access.save_user.0d071b89": "Benutzer speichern",
@@ -527,11 +559,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.saving.ae7e8875": "Saving...", "i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Geltungsbereich", "i18n:govoplan-access.scope.4651a34e": "Geltungsbereich",
"i18n:govoplan-access.scopes.c23540e5": "Scopes", "i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_role.c543f191": "Rolle auswählen",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user", "i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.", "i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.", "i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked", "i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug", "i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
"i18n:govoplan-access.status.bae7d5be": "Status", "i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.", "i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant", "i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",