Sync GovOPlaN module state
This commit is contained in:
265
.gitignore
vendored
265
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
26
src/govoplan_access/backend/auth/tenant_context.py
Normal file
26
src/govoplan_access/backend/auth/tenant_context.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"])} />}
|
||||||
|
|||||||
377
webui/src/features/admin/ExternalFunctionRoleMappingsPanel.tsx
Normal file
377
webui/src/features/admin/ExternalFunctionRoleMappingsPanel.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user