diff --git a/.gitignore b/.gitignore index d4d907f..4594a47 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,268 @@ dist/ .venv/ node_modules/ *.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/ diff --git a/docs/ACCESS_MODULE_BOUNDARY.md b/docs/ACCESS_MODULE_BOUNDARY.md index bbb47a5..c136858 100644 --- a/docs/ACCESS_MODULE_BOUNDARY.md +++ b/docs/ACCESS_MODULE_BOUNDARY.md @@ -45,9 +45,10 @@ importing access internals: `UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`, `OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`, `FunctionDelegationRef`, `AccessDecisionProvenance`, - `PrincipalResolver`, `AccessDirectory`, `AccessSemanticDirectory`, - `PermissionEvaluator`, `AccessExplanationService`, - `TenantAccessProvisioner`, `AccessAdministration`, and + `ApiPrincipalProvider`, `TenantContextSwitcher`, `PrincipalResolver`, + `AccessDirectory`, `AccessSemanticDirectory`, `PermissionEvaluator`, + `AccessExplanationService`, `TenantAccessProvisioner`, + `AccessAdministration`, and `AccessGovernanceMaterializer` - health, platform metadata, and module startup ordering - 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 display fields `email` and `display_name` -`/api/v1/auth/me`, `/api/v1/auth/login`, 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 +`govoplan_core.auth` is now backed by the `auth.apiPrincipalProvider` +capability. The access module provides that capability; core no longer imports +access auth dependencies directly. `/api/v1/auth/me`, `/api/v1/auth/login`, +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. +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 The full semantic model is documented in diff --git a/src/govoplan_access/backend/administration.py b/src/govoplan_access/backend/administration.py index dae17fe..2ea89d2 100644 --- a/src/govoplan_access/backend/administration.py +++ b/src/govoplan_access/backend/administration.py @@ -5,11 +5,21 @@ from collections.abc import Iterable, Mapping, Sequence from sqlalchemy import func 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 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: db = _session(session) return db.query(Account).count() @@ -50,6 +60,50 @@ class SqlAccessAdministration(AccessAdministration): ) 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: if not isinstance(session, Session): diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index 29f351a..6809a97 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -260,6 +260,44 @@ class FunctionListResponse(BaseModel): 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): model_config = ConfigDict(extra="forbid") diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 9d0513d..36e4ded 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -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.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.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.sessions import ( collect_system_roles, @@ -46,7 +47,6 @@ from govoplan_access.backend.security.sessions import ( collect_user_roles, collect_user_scopes, create_auth_session, - switch_auth_session_tenant, ) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -413,12 +413,15 @@ def switch_tenant( if principal.auth_session is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context") 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: 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: 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() return _me_response( session, diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index 309ec77..daed15f 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -63,6 +63,11 @@ from govoplan_access.backend.api.v1.admin_schemas import ( GroupListResponse, GroupSummary, GroupUpdateRequest, + ExternalFunctionRoleMappingCreateRequest, + ExternalFunctionRoleMappingItem, + ExternalFunctionRoleMappingListDeltaResponse, + ExternalFunctionRoleMappingListResponse, + ExternalFunctionRoleMappingUpdateRequest, FunctionAdminItem, FunctionAssignmentAdminItem, FunctionAssignmentCreateRequest, @@ -148,6 +153,7 @@ from govoplan_core.core.configuration_control import ( record_configuration_change_applied, ) 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.core.change_sequence import ( decode_sequence_watermark, @@ -160,6 +166,7 @@ from govoplan_core.core.change_sequence import ( from govoplan_access.backend.db.models import ( Account, ApiKey, + ExternalFunctionRoleAssignment, Function, FunctionAssignment, FunctionDelegation, @@ -190,6 +197,7 @@ ACCESS_ROLES_COLLECTION = "access.admin.roles" ACCESS_IDENTITIES_COLLECTION = "access.admin.identities" ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units" 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_DELEGATIONS_COLLECTION = "access.admin.function_delegations" 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() +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: return FunctionAssignmentAdminItem( id=item.id, @@ -1292,6 +1354,206 @@ def deactivate_function( 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) def list_function_assignments( tenant_id: str | None = None, diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index 64dff10..2b0a35e 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -1,10 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass - from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.orm import Session +from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import ( CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, @@ -14,12 +13,13 @@ from govoplan_core.core.access import ( PrincipalRef, PrincipalResolver, ) +from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.modules import AccessDecision 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.db.session import get_database, get_session -from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Tenant, User -from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account +from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User +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.sessions import ( authenticate_session_token, @@ -29,99 +29,10 @@ from govoplan_access.backend.security.sessions import ( verify_auth_session_csrf, ) 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 -@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]: if x_api_key: return x_api_key.strip(), "api_key" @@ -151,7 +62,14 @@ def _build_principal_ref( auth_method: str, api_key: ApiKey | None = None, auth_session: AuthSession | None = None, + idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), + extra_roles: tuple[Role, ...] = (), ) -> 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( account_id=account.id, membership_id=user.id, @@ -159,8 +77,8 @@ def _build_principal_ref( identity_id=identity_id_for_account(session, account.id), scopes=frozenset(scopes), group_ids=_principal_group_ids(session, user), - role_ids=frozenset(role.id for role in collect_user_roles(session, user)), - function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), + role_ids=frozenset(sorted(dict.fromkeys(role_ids))), + function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))), delegation_ids=frozenset(collect_function_delegation_ids(session, user)), auth_method=auth_method, # type: ignore[arg-type] api_key_id=api_key.id if api_key else None, @@ -209,6 +127,7 @@ def _resolve_legacy_principal_ref( *, authorization: str | None, x_api_key: str | None, + idm_directory: IdmDirectory | None = None, ) -> PrincipalRef: token, source = _extract_token(request, authorization, x_api_key) if not token: @@ -227,7 +146,9 @@ def _resolve_legacy_principal_ref( or user.tenant_id != api_key.tenant_id ): 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 []) session.commit() return _build_principal_ref( @@ -238,6 +159,8 @@ def _resolve_legacy_principal_ref( tenant_id=api_key.tenant_id, scopes=effective_scopes, auth_method="api_key", + idm_assignments=idm_assignments, + extra_roles=idm_roles, ) 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) 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") - 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() return _build_principal_ref( session, @@ -267,11 +192,31 @@ def _resolve_legacy_principal_ref( tenant_id=user.tenant_id, scopes=scopes, 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") +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: registry = getattr(request.app.state, "govoplan_registry", None) return registry if isinstance(registry, PlatformRegistry) else None @@ -312,15 +257,30 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator 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: if not isinstance(request, Request): raise TypeError("LegacyPrincipalResolver requires a FastAPI request") authorization = request.headers.get("authorization") x_api_key = request.headers.get("x-api-key") 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: - 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: @@ -339,11 +299,12 @@ class LegacyPermissionEvaluator: ) -def get_api_principal( +def resolve_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"), + session: Session, + *, + authorization: str | None = None, + x_api_key: str | None = None, ) -> ApiPrincipal: resolver = _principal_resolver_from_request(request) permission_evaluator = _permission_evaluator_from_request(request) @@ -364,6 +325,41 @@ def get_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: mode = saved_maintenance_mode(session) if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE): diff --git a/src/govoplan_access/backend/auth/tenant_context.py b/src/govoplan_access/backend/auth/tenant_context.py new file mode 100644 index 0000000..3689eaf --- /dev/null +++ b/src/govoplan_access/backend/auth/tenant_context.py @@ -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, + ) diff --git a/src/govoplan_access/backend/db/models.py b/src/govoplan_access/backend/db/models.py index 3230e93..fe4a847 100644 --- a/src/govoplan_access/backend/db/models.py +++ b/src/govoplan_access/backend/db/models.py @@ -191,6 +191,26 @@ class FunctionRoleAssignment(AccessBase, TimestampMixin): 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): __tablename__ = "access_function_assignments" __table_args__ = ( @@ -331,6 +351,7 @@ __all__ = [ "FunctionAssignment", "FunctionDelegation", "FunctionRoleAssignment", + "ExternalFunctionRoleAssignment", "Group", "GroupRoleAssignment", "Identity", diff --git a/src/govoplan_access/backend/directory.py b/src/govoplan_access/backend/directory.py index 0979cf5..1ba7645 100644 --- a/src/govoplan_access/backend/directory.py +++ b/src/govoplan_access/backend/directory.py @@ -13,6 +13,7 @@ from govoplan_core.core.access import ( OrganizationUnitRef, UserRef, ) +from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef from govoplan_access.backend.db.models import ( Account, 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): + def __init__(self, idm_directory: IdmDirectory | None = None) -> None: + self._idm_directory = idm_directory + def get_account(self, account_id: str) -> AccountRef | None: with get_database().session() as session: account = session.get(Account, account_id) @@ -312,4 +334,14 @@ class SqlAccessDirectory(AccessSemanticDirectory): ) -> tuple[FunctionAssignmentRef, ...]: with get_database().session() as session: 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()) diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index b44f2b1..5e156e8 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -13,9 +13,12 @@ from govoplan_core.core.access import ( CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, 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.modules import ( 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: - del context 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: @@ -335,18 +375,39 @@ def _legacy_permission_evaluator(context: ModuleContext) -> object: return LegacyPermissionEvaluator() -def _access_directory(context: ModuleContext) -> object: +def _api_principal_provider(context: ModuleContext) -> object: 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 - return SqlAccessDirectory() + return SqlAccessDirectory(idm_directory=_optional_idm_directory(context)) def _access_semantic_directory(context: ModuleContext) -> object: - del context 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: @@ -404,7 +465,7 @@ manifest = ModuleManifest( id="access", name="Access", version="0.1.6", - optional_dependencies=("identity", "organizations", "tenancy"), + optional_dependencies=("identity", "organizations", "tenancy", "idm"), permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, route_factory=_route_factory, @@ -424,6 +485,7 @@ manifest = ModuleManifest( access_models.OrganizationUnit, access_models.Function, access_models.FunctionRoleAssignment, + access_models.ExternalFunctionRoleAssignment, access_models.FunctionAssignment, access_models.FunctionDelegation, 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),), ), capability_factories={ + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider, CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator, + CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_ACCESS_DIRECTORY: _access_directory, diff --git a/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py b/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py index c6e2aaf..86ffad1 100644 --- a/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py +++ b/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py @@ -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_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() if "access_function_assignments" not in tables: op.create_table( @@ -353,6 +385,15 @@ def downgrade() -> None: 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", ( diff --git a/src/govoplan_access/backend/semantic.py b/src/govoplan_access/backend/semantic.py index ff47466..c7b1279 100644 --- a/src/govoplan_access/backend/semantic.py +++ b/src/govoplan_access/backend/semantic.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from govoplan_access.backend.db.models import ( Account, + ExternalFunctionRoleAssignment, Function, FunctionAssignment, FunctionDelegation, @@ -16,6 +17,7 @@ from govoplan_access.backend.db.models import ( Role, User, ) +from govoplan_core.core.idm import OrganizationFunctionAssignmentRef 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()) .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() + ) diff --git a/src/govoplan_access/backend/tenancy/provisioning.py b/src/govoplan_access/backend/tenancy/provisioning.py index 82bbbb4..5704853 100644 --- a/src/govoplan_access/backend/tenancy/provisioning.py +++ b/src/govoplan_access/backend/tenancy/provisioning.py @@ -1,13 +1,15 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from sqlalchemy.orm import Session -from govoplan_access.backend.admin.service import ensure_default_roles -from govoplan_access.backend.db.models import Account, User, UserRoleAssignment +from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account +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.core.access import TenantAccessProvisioner, TenantOwnerCandidateRef +from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef class LegacyTenantAccessProvisioner(TenantAccessProvisioner): @@ -75,6 +77,97 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner): db.flush() 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: if not isinstance(session, Session): diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 08802f3..6bf1612 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -242,6 +242,17 @@ export type ApiKeyAdminItem = { created_at: string; }; +export type ExternalFunctionRoleMappingItem = { + id: string; + tenant_id: string; + source_module: string; + function_id: string; + role_id: string; + settings: Record; + created_at: string; + updated_at: string; +}; + export type AuditAdminItem = { id: string; scope: "tenant" | "system"; @@ -268,6 +279,7 @@ export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; ro export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields; export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields; export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields; +export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields; export type TenantSettingsDeltaResponse = { item?: TenantSettingsItem | null; sections: TenantSettingsDeltaSections; @@ -451,6 +463,31 @@ export function deleteRole(settings: ApiSettings, roleId: string): Promise return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); } +export function fetchExternalFunctionRoleMappingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + 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; +}): Promise { + 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; +}): Promise { + 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 { + return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "DELETE" }); +} + export async function fetchSystemRoles(settings: ApiSettings): Promise { const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); return response.roles; diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index d8e7a19..1155280 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -6,7 +6,8 @@ import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, - MailProfilesUiCapability + MailProfilesUiCapability, + OrganizationFunctionPickerUiCapability } from "@govoplan/core-webui"; import { fetchMe } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; @@ -19,6 +20,7 @@ import TenantsPanel from "./TenantsPanel"; import UsersPanel from "./UsersPanel"; import GroupsPanel from "./GroupsPanel"; import RolesPanel from "./RolesPanel"; +import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel"; import ApiKeysPanel from "./ApiKeysPanel"; import AdminAuditPanel from "./AdminAuditPanel"; import FileConnectorsPanel from "./FileConnectorsPanel"; @@ -46,6 +48,7 @@ const handledAdminSectionIds = new Set([ "system-retention", "tenant-settings", "tenant-roles", + "tenant-function-role-mappings", "tenant-groups", "tenant-users", "tenant-file-connectors", @@ -72,6 +75,7 @@ export default function AdminPage({ }) { const mailProfilesUi = usePlatformUiCapability("mail.profiles"); const fileConnectorsUi = usePlatformUiCapability("files.connectors"); + const organizationFunctionPicker = usePlatformUiCapability("organizations.functionPicker"); const adminSectionCapabilities = usePlatformUiCapabilities("admin.sections"); const mailProfilesAvailable = Boolean(mailProfilesUi); 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:groups:read")) sections.add("tenant-groups"); 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 (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) { sections.add("tenant-mail-servers"); @@ -126,7 +131,7 @@ export default function AdminPage({ if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit"); return sections; - }, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]); + }, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, policyAvailable]); const [searchParams, setSearchParams] = useSearchParams(); const requestedSection = searchParams.get("section") as AdminSection | null; const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); @@ -198,14 +203,15 @@ export default function AdminPage({ items: asSubnavItems( sortNavItems([ visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10), - visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20), - visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30), - visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40), - visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50), - visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60), - visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70), - visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80), - visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 90), + visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20), + visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30), + visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40), + visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50), + visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60), + visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70), + visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 80), + visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90), + visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 100), ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) ]) ) @@ -265,6 +271,7 @@ export default function AdminPage({ {!contributedSection && active === "tenant-users" && } {!contributedSection && active === "tenant-groups" && } {!contributedSection && active === "tenant-roles" && } + {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && } {!contributedSection && active === "tenant-api-keys" && } {!contributedSection && active === "tenant-mail-servers" && } {!contributedSection && active === "tenant-user-mail-servers" && } diff --git a/webui/src/features/admin/ExternalFunctionRoleMappingsPanel.tsx b/webui/src/features/admin/ExternalFunctionRoleMappingsPanel.tsx new file mode 100644 index 0000000..fa20b6f --- /dev/null +++ b/webui/src/features/admin/ExternalFunctionRoleMappingsPanel.tsx @@ -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; +}) { + const [mappings, setMappings] = useState([]); + const [roles, setRoles] = useState([]); + const mappingsRef = useRef([]); + const rolesRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); + const [deleting, setDeleting] = useState(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 { + 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[]>( + () => [ + { + 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: ( +
+ {row.source_module} +
{row.function_id}
+
+ ) + }) ?? ( +
+ {row.source_module} +
{row.function_id}
+
+ )} + + ) + }, + { + 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 ( +
+ {role?.name ?? row.role_id} + {role &&
{role.slug}
} +
+ ); + } + }, + { + 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) => ( +
+ } onClick={() => openEdit(row)} disabled={!canWrite} /> + } variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} /> +
+ ) + } + ], + [auth, canWrite, functionPicker, roleById, settings] + ); + + return ( + <> + + + } variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} /> + + } + > +
+ row.id} + emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54" + /> +
+
+ + !busy && closeEditor()} + className="admin-dialog" + footer={ + <> + + + + } + > +
+ + {functionPicker.renderPicker({ + settings, + auth, + disabled: editing !== "new", + value: draft.functionId ? draftSelection(draft) : null, + onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) }) + })} + + + + +
+

i18n:govoplan-access.function_role_mapping_help.0cf9996a

+
+ + 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 { + 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); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 60e0dc7..72a2ebd 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -16,6 +16,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-access.add_api_key.725d9988": "Add API key", "i18n:govoplan-access.add_global_account.18e4df22": "Add global account", "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_system_role.f9ef262b": "Add system role", "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_global_account.e821f016": "Create global account", "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_role.db859bad": "Create 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.default_locale.b99d021f": "Default locale", "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_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.", @@ -87,6 +92,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-access.dry_run.485a3d15": "Dry run", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "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_system_role.6ebb7cb0": "Edit system role", "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.files_module_unavailable.0ee90db1": "Files module unavailable", "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.global_account_details.0a0cf240": "Global account details", "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_assignable_roles_exist.a4c268c2": "No assignable roles exist.", "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_groups_exist_yet.9cd029f6": "No groups exist yet.", "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_general_settings.5c90f8c4": "Save general settings", "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_tenant.9eb2ac74": "Save tenant", "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.scope.4651a34e": "Scope", "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.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.show_revoked.b4265807": "Show revoked", "i18n:govoplan-access.slug.094da9b9": "Slug", + "i18n:govoplan-access.source_module.62b7241c": "Source module", "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.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_global_account.18e4df22": "Add global account", "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_system_role.f9ef262b": "Add system role", "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_global_account.e821f016": "Create global account", "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_role.db859bad": "Rolle erstellen", "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.default_locale.b99d021f": "Standardsprache", "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_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.", @@ -400,6 +422,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "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_system_role.6ebb7cb0": "Edit system role", "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.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar", "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.global_account_details.0a0cf240": "Global account details", "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_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.", "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_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.", "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_general_settings.5c90f8c4": "Save general settings", "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_tenant.9eb2ac74": "Mandant 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.scope.4651a34e": "Geltungsbereich", "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.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.show_revoked.b4265807": "Show revoked", "i18n:govoplan-access.slug.094da9b9": "Slug", + "i18n:govoplan-access.source_module.62b7241c": "Quellmodul", "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.suspend_tenant.151d283a": "Suspend tenant",