Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab07075a67 | |||
| f2afcaa09c | |||
| d08a41fb56 | |||
| 002d12e417 | |||
| 5b8baa6cde | |||
| d6a0d6241d | |||
| e32841077c | |||
| a7c486788e | |||
| c71de86a1f | |||
| 04681f1d75 | |||
| 37828fe340 | |||
| efb69b3d2d |
265
.gitignore
vendored
265
.gitignore
vendored
@@ -8,3 +8,268 @@ dist/
|
||||
.venv/
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# GovOPlaN shared ignore rules from govoplan-core
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
# TypeScript cache
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
# dotenv environment variable files
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
*$py.class
|
||||
# C extensions
|
||||
*.so
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
*.db
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
|
||||
69
README.md
69
README.md
@@ -1,16 +1,40 @@
|
||||
# GovOPlaN Access
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-access` is the platform module for GovOPlaN identity,
|
||||
authentication, sessions, API keys, RBAC, groups, users, and access
|
||||
administration.
|
||||
|
||||
The repository contains the extracted access seed implementation under
|
||||
`src/govoplan_access/backend`. Some live product routes and legacy ORM models
|
||||
still reside in `govoplan-core` as compatibility code while the staged
|
||||
extraction continues. The staged extraction path is documented in:
|
||||
`src/govoplan_access/backend`. Session, API-key, and password helper services,
|
||||
interactive auth routes, and administration routers are owned here. Access
|
||||
still exports `govoplan_access.auth` for compatibility, but sibling modules
|
||||
should import the core `govoplan_core.auth` facade so auth can become a
|
||||
provider-neutral capability. Modules must not import the backend dependency
|
||||
module directly. Access-side admin service helpers remain
|
||||
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
||||
access enforcement, admin/audit lookup capabilities, tenant owner
|
||||
provisioning, and governance-template materialization into access-owned groups
|
||||
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||
transitional administration WebUI route shell and
|
||||
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
||||
system administration panels are contributed by `@govoplan/admin-webui` through
|
||||
core's `admin.sections` UI capability. Live access ORM models are defined here
|
||||
with `access_*` table names. Access stores current scope identifiers in
|
||||
`tenant_id` columns but does not hard-depend on the tenancy module package.
|
||||
Tenancy-specific tenant administration and tenant resolver behavior live in
|
||||
`govoplan-tenancy`; governance templates in `govoplan-admin`, audit logs in
|
||||
`govoplan-audit`, and system settings in `govoplan-core`. The current access
|
||||
boundary is documented in:
|
||||
|
||||
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md`
|
||||
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_RBAC_MODEL.md`
|
||||
- `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`
|
||||
- `docs/ACCESS_MODULE_BOUNDARY.md`
|
||||
- `docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md`
|
||||
- `docs/OPENDESK_IDENTITY_BOUNDARY.md`
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
@@ -19,15 +43,50 @@ This module will own:
|
||||
- accounts and login identity
|
||||
- authentication routes and session lifecycle
|
||||
- API keys
|
||||
- legacy administration route contribution during the transition
|
||||
- tenant-local users
|
||||
- identity-to-account projection for explainable access decisions
|
||||
- organization-bound functions and function assignments
|
||||
- explicit delegation and acting-in-place facts
|
||||
- groups and memberships
|
||||
- roles and role assignments
|
||||
- principal resolution
|
||||
- permission evaluation
|
||||
- access administration backend routes
|
||||
- access administration WebUI route contributions
|
||||
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
||||
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
||||
- access administration, tenant provisioning, and governance materializer
|
||||
capabilities
|
||||
- access-owned migrations
|
||||
|
||||
The governance-template routes under `/admin/system/governance-templates` are
|
||||
contributed by `govoplan-admin`; access must not register those routes.
|
||||
|
||||
`govoplan-access` treats `govoplan-tenancy` as optional. Access can run in the
|
||||
single-scope compatibility mode used by the core/access baseline, and tenancy
|
||||
adds tenant administration plus tenant resolver behavior when installed.
|
||||
|
||||
## Principal Context
|
||||
|
||||
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
||||
resolves sessions, API keys, and future service accounts into that DTO and
|
||||
serializes it as `principal` in auth API responses. Feature modules should use
|
||||
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
||||
instead of importing access ORM models or backend dependency internals.
|
||||
|
||||
The detailed module boundary and serialization fields are documented in
|
||||
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
|
||||
|
||||
## WebUI Package
|
||||
|
||||
The repository root and `webui/` directory both expose the package
|
||||
`@govoplan/access-webui`. The package contributes the `/admin` route and admin
|
||||
nav item through core's module WebUI contract. The route shell consumes
|
||||
module-owned `admin.sections` contributions from installed platform modules and
|
||||
continues to render access-owned tenant/user/group/role panels locally. Core
|
||||
supplies shared admin components, route rendering, and icon resolution.
|
||||
|
||||
The kernel remains responsible for module discovery, route aggregation,
|
||||
database/session lifecycle, migration orchestration, capability registry, and
|
||||
stable contracts.
|
||||
@@ -43,7 +102,7 @@ available:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
```
|
||||
|
||||
## Development Install
|
||||
|
||||
194
docs/ACCESS_MODULE_BOUNDARY.md
Normal file
194
docs/ACCESS_MODULE_BOUNDARY.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# GovOPlaN Access Module Boundary
|
||||
|
||||
`govoplan-access` is the platform module that owns login identity and runtime
|
||||
authorization state. Core remains the kernel: it composes modules, mounts
|
||||
routes, owns process/database lifecycle, and exposes stable capability
|
||||
contracts.
|
||||
|
||||
## Access-Owned Capabilities
|
||||
|
||||
`govoplan-access` owns the canonical implementation for:
|
||||
|
||||
- accounts and global login identity
|
||||
- interactive authentication routes and session lifecycle
|
||||
- API-key creation, verification, revocation, and scope delegation
|
||||
- tenant-local users, memberships, groups, roles, and role assignments
|
||||
- identity-to-account projection used for explainability
|
||||
- organization-bound functions, function assignments, and delegation facts
|
||||
- principal resolution and request authentication dependencies
|
||||
- permission evaluation for access-owned scopes and legacy access aliases
|
||||
- access-decision explain output with identity/account/function/role/right
|
||||
provenance
|
||||
- access administration backend routes for users, groups, roles, system
|
||||
accounts, sessions, and API keys
|
||||
- access administration WebUI route contribution for `/admin`
|
||||
- tenant owner provisioning and default access bootstrap
|
||||
- materializing governance templates into access-owned groups and roles
|
||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||
|
||||
The active access tables use the `access_*` namespace while the model classes
|
||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||
`access_roles`, `access_system_role_assignments`,
|
||||
`access_user_group_memberships`, `access_user_role_assignments`,
|
||||
`access_group_role_assignments`, `access_api_keys`, and
|
||||
`access_auth_sessions`.
|
||||
|
||||
## Kernel-Owned Contracts
|
||||
|
||||
`govoplan-core` owns the stable contracts that let modules interact without
|
||||
importing access internals:
|
||||
|
||||
- `ModuleManifest`, route factories, migration specs, and registry validation
|
||||
- database engine/session lifecycle and migration orchestration
|
||||
- capability registry and capability names in `govoplan_core.core.access`
|
||||
- access DTO/protocol contracts such as `PrincipalRef`, `AccountRef`,
|
||||
`UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`,
|
||||
`OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`,
|
||||
`FunctionDelegationRef`, `AccessDecisionProvenance`,
|
||||
`ApiPrincipalProvider`, `TenantContextSwitcher`, `PrincipalResolver`,
|
||||
`AccessDirectory`, `AccessSemanticDirectory`, `PermissionEvaluator`,
|
||||
`AccessExplanationService`, `TenantAccessProvisioner`,
|
||||
`AccessAdministration`, and
|
||||
`AccessGovernanceMaterializer`
|
||||
- health, platform metadata, and module startup ordering
|
||||
- generic security helpers that are not access-state semantics, such as
|
||||
secret encryption and UTC time helpers
|
||||
|
||||
Feature modules should depend on these kernel contracts or the core
|
||||
`govoplan_core.auth` request dependency facade, not on access ORM models or
|
||||
`govoplan_access.backend.*` implementation internals. The access package still
|
||||
exports `govoplan_access.auth` for compatibility, but new routers should use
|
||||
the core facade so auth can move behind provider-neutral capabilities.
|
||||
|
||||
Access declares tenancy as an optional module integration. It uses the
|
||||
core-owned `core_scopes` table as the scope table, but it must not import
|
||||
`govoplan_tenancy` or require the tenancy package to start.
|
||||
|
||||
## Core-Only Startup Contract
|
||||
|
||||
A core-only installation must be able to start far enough to expose process
|
||||
health, module metadata, and the unauthenticated shell needed for installation
|
||||
or recovery work. It is not a usable authenticated product installation.
|
||||
|
||||
Authenticated product use requires the `access` module or another module that
|
||||
provides the same kernel auth capabilities:
|
||||
|
||||
- `auth.apiPrincipalProvider`
|
||||
- `auth.principalResolver`
|
||||
- `auth.permissionEvaluator`
|
||||
- `auth.tenantContextSwitcher`
|
||||
|
||||
Access contributes the default implementations for those capabilities plus the
|
||||
interactive `/api/v1/auth/*` routes. Product modules should express auth needs
|
||||
as required capabilities or route permission requirements instead of importing
|
||||
access internals. Runtime configurations that intentionally omit access should
|
||||
hide authenticated navigation and return capability errors for authenticated
|
||||
product routes rather than failing process startup.
|
||||
|
||||
## Principal Context Contract
|
||||
|
||||
The stable runtime principal is `govoplan_core.core.access.PrincipalRef`.
|
||||
Access resolves request credentials into that DTO and `ApiPrincipal` keeps the
|
||||
legacy ORM objects only for routers that have not yet moved to pure kernel
|
||||
contracts. New module code should pass around `PrincipalRef` or primitive IDs.
|
||||
|
||||
`PrincipalRef.to_dict()` is the canonical API/WebUI serialization shape:
|
||||
|
||||
- `account_id`, `membership_id`, and `tenant_id`
|
||||
- optional `identity_id`
|
||||
- sorted `scopes`, `group_ids`, `role_ids`, `function_assignment_ids`, and
|
||||
`delegation_ids`
|
||||
- `auth_method` plus optional `session_id`, `api_key_id`, or
|
||||
`service_account_id`
|
||||
- optional `acting_for_account_id` for acting-in-place flows
|
||||
- optional display fields `email` and `display_name`
|
||||
|
||||
`govoplan_core.auth` is now backed by the `auth.apiPrincipalProvider`
|
||||
capability. The access module provides that capability; core no longer imports
|
||||
access auth dependencies directly. `/api/v1/auth/me`, `/api/v1/auth/login`,
|
||||
profile refreshes, and tenant switches include this payload as `principal`
|
||||
alongside the existing compatibility fields. Modules that need current user
|
||||
context should prefer `auth.principal`/`AuthInfo.principal` in the WebUI and
|
||||
`principal.to_platform_principal()` in backend request handlers.
|
||||
|
||||
Interactive tenant context switching is exposed through
|
||||
`auth.tenantContextSwitcher`. The existing `/api/v1/auth/switch-tenant` route
|
||||
remains for API compatibility; `govoplan-tenancy` also contributes
|
||||
`/api/v1/tenancy/switch-tenant`. Both delegate to the same access-owned session
|
||||
switch behavior. Lifecycle code must use the capability instead of importing
|
||||
`govoplan_access.backend.security.sessions`.
|
||||
|
||||
## Identity And Function Boundary
|
||||
|
||||
The full semantic model is documented in
|
||||
[IDENTITY_ACCOUNT_FUNCTION_MODEL.md](IDENTITY_ACCOUNT_FUNCTION_MODEL.md).
|
||||
In short:
|
||||
|
||||
- `govoplan-idm` imports and previews external identity and organization facts
|
||||
from IDM systems.
|
||||
- `govoplan-identity` owns canonical identities and identity/account links.
|
||||
- `govoplan-organizations` owns canonical organization units, functions, and
|
||||
account-held function assignments.
|
||||
- `govoplan-access` owns the authorization projection that maps organization
|
||||
and identity facts to roles, rights, delegation enforcement, and explainable
|
||||
permission decisions.
|
||||
|
||||
Function assignments are account-held and organization-scoped. They can apply
|
||||
only to the selected organization unit or to that unit and all subunits.
|
||||
Delegation and acting-in-place must remain explicit facts with audit
|
||||
provenance; modules must not infer either from plain group membership.
|
||||
|
||||
The backend foundation exposes these administration routes:
|
||||
|
||||
- `/api/v1/admin/identities`
|
||||
- `/api/v1/admin/organization-units`
|
||||
- `/api/v1/admin/functions`
|
||||
- `/api/v1/admin/function-assignments`
|
||||
- `/api/v1/admin/function-delegations`
|
||||
|
||||
Dedicated WebUI management panels and explicit acting-in-place context
|
||||
selection are still follow-up work on top of these routes.
|
||||
|
||||
## Removed Compatibility Paths
|
||||
|
||||
These legacy imports were removed from core. Use access-owned modules, the
|
||||
core `govoplan_core.auth` request dependency facade, or kernel capabilities
|
||||
instead:
|
||||
|
||||
- `govoplan_core.security.api_keys`
|
||||
- `govoplan_core.security.sessions`
|
||||
- `govoplan_core.security.passwords`
|
||||
- `govoplan_core.api.v1.auth`
|
||||
- `govoplan_core.api.v1.admin`
|
||||
- `govoplan_core.api.v1.admin_schemas`
|
||||
- `govoplan_core.admin.service`
|
||||
- `govoplan_core.admin.governance`
|
||||
|
||||
HTTP route compatibility remains at the API layer: the access manifest
|
||||
contributes the same `/api/v1/auth/*` and `/api/v1/admin/*` paths through module
|
||||
route aggregation.
|
||||
|
||||
## Route Ownership
|
||||
|
||||
The access manifest contributes the `/api/v1/auth/*` interactive auth routes
|
||||
and the access-owned `/api/v1/admin/*` administration routes through its module
|
||||
route factory. Core default server configuration must not register auth or
|
||||
admin routers as base routers.
|
||||
|
||||
Governance-template metadata CRUD is not access-owned. It is contributed by
|
||||
`govoplan-admin`; access only materializes those templates into access-owned
|
||||
groups and roles through the `access.governanceMaterializer` capability.
|
||||
|
||||
## Verification References
|
||||
|
||||
Focused verification is run from `/mnt/DATA/git/govoplan-core`.
|
||||
|
||||
- `tests.test_module_system` verifies manifest discovery, access startup in
|
||||
module permutations, admin route ownership, governance-template route
|
||||
separation, and legacy compatibility imports.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_cookie_session_requires_csrf_for_mutations`
|
||||
verifies the access-owned session/auth route behavior.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_tenant_user_group_role_and_api_key_administration`
|
||||
verifies access-owned administration and API-key behavior.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_profile_refresh_and_system_role_protection_model`
|
||||
verifies profile/session refresh and protected system role behavior.
|
||||
177
docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md
Normal file
177
docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Identity, Account, Function, Role, And Right Model
|
||||
|
||||
GovOPlaN access distinguishes identity facts, organizational responsibility,
|
||||
and authorization decisions. This is groundwork for postboxes, workflows,
|
||||
service directories, portals, delegation, and audit review.
|
||||
|
||||
Directory services, identity providers, and IDM systems can authenticate people
|
||||
and provide external facts. GovOPlaN access owns the normalized runtime
|
||||
projection used for sessions, tenant memberships, groups, functions, roles,
|
||||
delegations, and permission decisions.
|
||||
|
||||
## Semantic Layers
|
||||
|
||||
- Identity: the real person, service, or external subject. An identity can have
|
||||
multiple accounts, for example a normal account and a privileged
|
||||
administration account.
|
||||
- Account: the login or technical account used to authenticate. Access
|
||||
decisions are account-based because the account is the acting credential.
|
||||
- Tenant membership: the account's participation in a tenant.
|
||||
- Organization unit: the administrative unit where responsibility applies.
|
||||
Organization units are hierarchical; access must know when a function applies
|
||||
only to one unit or to that unit and all subunits.
|
||||
- Function: a named responsibility held by an account in an organization unit,
|
||||
such as case clerk, intake desk, treasurer, dean's office assistant, or
|
||||
committee secretary. A function can map to one or more access roles.
|
||||
- Role: a permission bundle or workflow authority attached to a function,
|
||||
group, or explicit assignment.
|
||||
- Right: the concrete scope or action permission evaluated at runtime.
|
||||
|
||||
The UI and API must not collapse these layers into a generic group concept.
|
||||
Directory groups can feed mappings, but they must not silently become business
|
||||
authority without a governed mapping rule.
|
||||
|
||||
## Organizational Function Scope
|
||||
|
||||
A function is meaningful only with organizational scope. The stable contract
|
||||
therefore separates:
|
||||
|
||||
- `FunctionRef`: the organization-bound function definition, including tenant,
|
||||
organization unit, role mappings, and delegation policy flags.
|
||||
- `FunctionAssignmentRef`: the account-held assignment for that function,
|
||||
including identity provenance and whether the assignment applies to all
|
||||
subunits of the function's organization unit.
|
||||
|
||||
The assignment is the runtime authority. A role mapped to a function does not
|
||||
grant rights until an account has an active assignment for that function.
|
||||
|
||||
Example:
|
||||
|
||||
- Identity `Anna Becker` owns accounts `anna` and `anna-admin`.
|
||||
- Account `anna` has function `Registry Clerk` in organization unit
|
||||
`Student Registry`.
|
||||
- The assignment has `applies_to_subunits = true`, so the same function applies
|
||||
to subordinate registry offices unless policy narrows it.
|
||||
- The function maps to role `registry.case_editor`, which grants rights such as
|
||||
`cases:case:update`.
|
||||
|
||||
## Delegation And Acting In Place
|
||||
|
||||
Functions can be delegated only if the function policy permits it. GovOPlaN
|
||||
distinguishes two delegation modes:
|
||||
|
||||
- Delegation: the delegate acts as themself, with provenance showing the
|
||||
delegated function assignment.
|
||||
- Acting in place: the actor performs an action in another holder's function
|
||||
context. Audit and explain responses must show both the real actor account
|
||||
and the account being represented.
|
||||
|
||||
Both modes should be time-bound, revocable, auditable, and visible in access
|
||||
explain output. Module code must not infer delegation from ordinary group
|
||||
membership.
|
||||
|
||||
## IDM Boundary
|
||||
|
||||
`govoplan-idm` owns synchronization with external IDM systems: SCIM, LDAP,
|
||||
SAML/OIDC claims, directory attributes, preview, rollback, and mapping import.
|
||||
It does not own GovOPlaN's internal identity, organization, function, role, or
|
||||
permission evaluation tables.
|
||||
|
||||
`govoplan-identity` owns canonical identity records and identity/account links.
|
||||
`govoplan-organizations` owns canonical organization units, functions, and
|
||||
function assignments. During the transition, access keeps a security projection
|
||||
of those concepts for compatibility and authorization, but new integrations
|
||||
should target the identity and organization capabilities first.
|
||||
|
||||
`govoplan-access` owns the platform projection created from those mappings:
|
||||
|
||||
- accounts and tenant membership projection
|
||||
- identity-to-account links used for explainability
|
||||
- groups, roles, function assignments, and delegation facts
|
||||
- permission decisions and explain responses
|
||||
- access-owned identity and membership change events
|
||||
- mapping effects after an IDM import is accepted
|
||||
|
||||
Access does not own:
|
||||
|
||||
- mailboxes, calendars, files, cases, tasks, postboxes, or other module data
|
||||
- canonical organization structure once `govoplan-organizations` is enabled
|
||||
- canonical identity records once `govoplan-identity` is enabled
|
||||
- module-specific ACL records beyond stable principal/group/role references
|
||||
- external provider internals except where they mutate access-owned state
|
||||
|
||||
## Kernel Contracts
|
||||
|
||||
The stable DTO and protocol surface lives in
|
||||
`govoplan_core.core.access`. The current groundwork adds:
|
||||
|
||||
- `IdentityRef`
|
||||
- `OrganizationUnitRef`
|
||||
- `FunctionRef`
|
||||
- `FunctionAssignmentRef`
|
||||
- `FunctionDelegationRef`
|
||||
- `AccessDecisionProvenance`
|
||||
- `AccessSemanticDirectory`
|
||||
- `AccessExplanationService`
|
||||
|
||||
Feature modules should consume those contracts instead of importing access ORM
|
||||
models. Storage, migration, and admin UI work can evolve behind the contract
|
||||
without changing module integrations.
|
||||
|
||||
## Required Explainability
|
||||
|
||||
Access decisions must be explainable in concrete terms:
|
||||
|
||||
- actor identity and account
|
||||
- tenant membership
|
||||
- organization unit
|
||||
- function assignment or group membership
|
||||
- role source
|
||||
- permission or right checked
|
||||
- delegation or acting-in-place context
|
||||
- policy, lock, or maintenance state that changed the result
|
||||
|
||||
This shape is required for role-bound postboxes, workflow authorization,
|
||||
service directory personalization, delegated administration, and audit review.
|
||||
|
||||
## Consumer Expectations
|
||||
|
||||
- Postbox can grant access to a role-bound or function-bound postbox without
|
||||
tying the postbox to a specific login account.
|
||||
- Portal/service directory can show services relevant to a user's current
|
||||
organization functions and tenant membership.
|
||||
- Workflow can ask whether the current account can act in a function context
|
||||
for a given organization unit.
|
||||
- Audit can show who acted, with which account, under which function, and
|
||||
whether delegation or acting-in-place was involved.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
Implemented backend foundation:
|
||||
|
||||
- Kernel DTOs/protocols are covered by focused contract tests.
|
||||
- Access-owned storage exists for identities, account links, organization
|
||||
units, functions, function-role mappings, function assignments, and function
|
||||
delegations.
|
||||
- Admin APIs exist under `/api/v1/admin/identities`,
|
||||
`/api/v1/admin/organization-units`, `/api/v1/admin/functions`,
|
||||
`/api/v1/admin/function-assignments`, and
|
||||
`/api/v1/admin/function-delegations`.
|
||||
- `PrincipalRef` population includes identity, role, function assignment, and
|
||||
delegation identifiers when those facts exist.
|
||||
- The access manifest registers `access.semanticDirectory` and
|
||||
`access.explanation` capabilities.
|
||||
|
||||
Remaining rollout:
|
||||
|
||||
1. Move canonical identity and organization reads to `identity.directory` and
|
||||
`organizations.directory`, keeping access-owned rows as a compatibility
|
||||
projection until migration is complete.
|
||||
2. Add dedicated WebUI management panels for identities, organization units,
|
||||
functions, assignments, and delegations.
|
||||
3. Add explicit acting-in-place context selection; `act_in_place` delegation
|
||||
facts are stored now but do not silently grant permissions without a selected
|
||||
acting context.
|
||||
4. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
||||
organization, and access explanation capabilities rather than local access
|
||||
assumptions.
|
||||
97
docs/OPENDESK_IDENTITY_BOUNDARY.md
Normal file
97
docs/OPENDESK_IDENTITY_BOUNDARY.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# OpenDesk Identity Integration Boundary
|
||||
|
||||
OpenDesk-style identity integrations should terminate in `govoplan-access` as
|
||||
canonical accounts, tenant memberships, groups, roles, sessions, and principal
|
||||
claims. Provider protocol details may live in access subpackages or dedicated
|
||||
connector modules, but other GovOPlaN modules must consume identity through
|
||||
access capabilities, typed DTOs, events, and published route dependencies.
|
||||
|
||||
## Boundary Decision
|
||||
|
||||
`govoplan-access` owns the identity projection and authorization effects:
|
||||
|
||||
- external identity links for accounts and memberships
|
||||
- authentication callback/session issuance for federated login
|
||||
- claim, group, and role mapping into access-owned roles and memberships
|
||||
- SCIM-style provisioning effects for accounts, users, groups, and group
|
||||
membership
|
||||
- account suspension/deactivation effects that influence sessions and API keys
|
||||
- audit-relevant identity events emitted through kernel event/audit contracts
|
||||
|
||||
Connector packages may own provider-specific transport and schema logic:
|
||||
|
||||
- LDAP and Active Directory bind/search/sync adapters
|
||||
- OIDC and SAML provider metadata, callback protocol handling, and claim
|
||||
normalization
|
||||
- SCIM client/server protocol specifics
|
||||
- Open-Xchange identity lookup or provisioning clients
|
||||
|
||||
Those connectors should call access capabilities or access-owned service APIs
|
||||
instead of writing access tables directly.
|
||||
|
||||
## Integration Types
|
||||
|
||||
### LDAP And Active Directory
|
||||
|
||||
LDAP/AD adapters may authenticate credentials, search directory entries, and
|
||||
sync group membership. Access owns the resulting account, membership, group,
|
||||
and role mapping. Directory groups should map to access groups or role
|
||||
assignments through explicit mapping rules; they should not grant feature
|
||||
module permissions directly.
|
||||
|
||||
### OIDC And SAML
|
||||
|
||||
OIDC/SAML adapters may handle provider metadata, assertions, tokens, and claim
|
||||
normalization. Access owns external subject linking, session creation, tenant
|
||||
selection, first-login behavior, and claim-to-role/group mapping. Feature
|
||||
modules should see only `PrincipalRef`, `UserRef`, scopes, group IDs, and
|
||||
tenant context.
|
||||
|
||||
### SCIM Provisioning
|
||||
|
||||
SCIM provisioning belongs at the access boundary because it mutates accounts,
|
||||
memberships, groups, and deactivation state. Tenant resolution remains a kernel
|
||||
or tenancy capability concern. SCIM must not provision mailboxes, calendars,
|
||||
campaign ownership, or file spaces directly; those modules may react to
|
||||
access-published identity events when needed.
|
||||
|
||||
### Open-Xchange Touchpoints
|
||||
|
||||
Open-Xchange identity/contact integration should split identity from
|
||||
collaboration data:
|
||||
|
||||
- Access owns external account IDs, email/display-name identity fields,
|
||||
membership state, group references, and auth/session effects.
|
||||
- Mail, calendar, contacts, or connector modules own mailboxes, address books,
|
||||
calendar resources, contact folders, and provider-specific collaboration
|
||||
objects.
|
||||
|
||||
Access may publish identity-change events and stable DTOs that those modules
|
||||
consume, but it should not import their internals or own their provider data.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Access does not own:
|
||||
|
||||
- campaign ACLs, campaign ownership, delivery policy, or recipient contacts
|
||||
- file storage permissions beyond principal/group identity references
|
||||
- mail profile credentials, mailbox state, or reusable mail-server profiles
|
||||
- calendar availability, appointments, rooms, or contact address books
|
||||
- provider-specific UI panels for non-identity configuration
|
||||
|
||||
Those belong to their owning modules and should integrate through capabilities
|
||||
or events.
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
Provider integration should be added in small slices:
|
||||
|
||||
1. Define an access-owned external identity link model and DTO surface.
|
||||
2. Add provider adapter contracts that normalize external subjects, groups,
|
||||
claims, and deactivation signals.
|
||||
3. Route OIDC/SAML login callbacks through access so sessions are issued by
|
||||
the access session service.
|
||||
4. Route LDAP/AD/SCIM provisioning through access administration services and
|
||||
tenant provisioning capabilities.
|
||||
5. Publish identity-change events for optional mail/calendar/contact/file
|
||||
reactions without adding module-to-module imports.
|
||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
"module": "webui/src/index.ts",
|
||||
"types": "webui/src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./webui/src/index.ts",
|
||||
"import": "./webui/src/index.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.4"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives."
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.4",
|
||||
"govoplan-core>=0.1.8",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
21
src/govoplan_access/auth/__init__.py
Normal file
21
src/govoplan_access/auth/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Public FastAPI access dependency API.
|
||||
|
||||
Feature modules may import this package when they need request principal and
|
||||
scope dependencies. Implementation details remain under ``govoplan_access.backend``.
|
||||
"""
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
ApiPrincipal,
|
||||
get_api_principal,
|
||||
has_scope,
|
||||
require_any_scope,
|
||||
require_scope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ApiPrincipal",
|
||||
"get_api_principal",
|
||||
"has_scope",
|
||||
"require_any_scope",
|
||||
"require_scope",
|
||||
]
|
||||
2
src/govoplan_access/backend/admin/__init__.py
Normal file
2
src/govoplan_access/backend/admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned legacy admin helper services."""
|
||||
|
||||
43
src/govoplan_access/backend/admin/governance.py
Normal file
43
src/govoplan_access/backend/admin/governance.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_access.backend.db.models import (
|
||||
ApiKey,
|
||||
Group,
|
||||
Role,
|
||||
Tenant,
|
||||
)
|
||||
from govoplan_core.tenancy.service import effective_tenant_governance
|
||||
|
||||
|
||||
def ensure_group_mutation_allowed(group: Group, *, requested_active: bool | None = None) -> None:
|
||||
if group.system_template_id and group.system_required and requested_active is False:
|
||||
raise AdminConflictError("This centrally required group cannot be deactivated by the tenant.")
|
||||
|
||||
|
||||
def ensure_role_mutation_allowed(role: Role, *, requested_assignable: bool | None = None) -> None:
|
||||
if role.system_template_id:
|
||||
if role.system_required and requested_assignable is False:
|
||||
raise AdminConflictError("This centrally required role cannot be made unavailable by the tenant.")
|
||||
raise AdminConflictError("Centrally managed role definitions can only be changed in System administration.")
|
||||
|
||||
|
||||
def assert_api_keys_allowed(session: Session, tenant: Tenant) -> None:
|
||||
if not effective_tenant_governance(session, tenant).allow_api_keys:
|
||||
raise AdminConflictError("API-key creation is disabled by system or tenant governance.")
|
||||
|
||||
|
||||
def assert_custom_groups_allowed(session: Session, tenant: Tenant) -> None:
|
||||
if not effective_tenant_governance(session, tenant).allow_custom_groups:
|
||||
raise AdminConflictError("Custom tenant groups are disabled by system or tenant governance.")
|
||||
|
||||
|
||||
def assert_custom_roles_allowed(session: Session, tenant: Tenant) -> None:
|
||||
if not effective_tenant_governance(session, tenant).allow_custom_roles:
|
||||
raise AdminConflictError("Custom tenant roles are disabled by system or tenant governance.")
|
||||
|
||||
|
||||
def active_api_key_count(session: Session, tenant_id: str) -> int:
|
||||
return session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()
|
||||
582
src/govoplan_access/backend/admin/service.py
Normal file
582
src/govoplan_access/backend/admin/service.py
Normal file
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import ensure_identity_for_account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
delegateable_system_scopes,
|
||||
delegateable_tenant_scopes,
|
||||
normalize_email,
|
||||
scopes_grant,
|
||||
validate_system_permissions,
|
||||
validate_tenant_permissions,
|
||||
role_templates_for_level,
|
||||
)
|
||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
||||
|
||||
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MembershipCreationResult:
|
||||
user: User
|
||||
account: Account
|
||||
temporary_password: str | None = None
|
||||
account_created: bool = False
|
||||
|
||||
|
||||
def generate_temporary_password(length: int = 20) -> str:
|
||||
return "".join(secrets.choice(_TEMP_PASSWORD_ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]:
|
||||
level = "tenant" if tenant is not None else "system"
|
||||
roles: dict[str, Role] = {}
|
||||
for template in role_templates_for_level(level):
|
||||
query = session.query(Role).filter(Role.slug == template.slug)
|
||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||
role = query.one_or_none()
|
||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
is_assignable = True
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=tenant.id if tenant is not None else None,
|
||||
slug=template.slug,
|
||||
name=template.name,
|
||||
description=template.description or None,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=is_builtin,
|
||||
is_assignable=is_assignable,
|
||||
)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
else:
|
||||
# Tenant built-ins and explicitly managed system roles remain
|
||||
# code-defined. Seeded, non-protected system roles are only created
|
||||
# here and can subsequently be administered in the System roles UI.
|
||||
managed = _template_updates_existing(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
if managed:
|
||||
role.name = template.name
|
||||
role.description = template.description or None
|
||||
role.permissions = list(template.permissions)
|
||||
role.is_builtin = is_builtin
|
||||
role.is_assignable = is_assignable
|
||||
session.add(role)
|
||||
roles[template.slug] = role
|
||||
return roles
|
||||
|
||||
|
||||
def _template_is_builtin(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def _template_updates_existing(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def get_or_create_account(
|
||||
session: Session,
|
||||
*,
|
||||
email: str,
|
||||
display_name: str | None = None,
|
||||
password: str | None = None,
|
||||
password_reset_required: bool = False,
|
||||
) -> tuple[Account, bool, str | None]:
|
||||
normalized = normalize_email(email)
|
||||
if not normalized or "@" not in normalized:
|
||||
raise AdminValidationError("Enter a valid email address.")
|
||||
account = session.query(Account).filter(Account.normalized_email == normalized).one_or_none()
|
||||
if account is not None:
|
||||
if not account.is_active:
|
||||
raise AdminConflictError(
|
||||
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership."
|
||||
)
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, False, None
|
||||
|
||||
temporary_password = password or generate_temporary_password()
|
||||
account = Account(
|
||||
email=email.strip(),
|
||||
normalized_email=normalized,
|
||||
display_name=display_name.strip() if display_name else None,
|
||||
is_active=True,
|
||||
auth_provider="local",
|
||||
password_hash=hash_password(temporary_password),
|
||||
password_reset_required=password_reset_required or password is None,
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, True, temporary_password
|
||||
|
||||
|
||||
def create_membership(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
email: str,
|
||||
display_name: str | None = None,
|
||||
password: str | None = None,
|
||||
password_reset_required: bool = False,
|
||||
is_active: bool = True,
|
||||
) -> MembershipCreationResult:
|
||||
account, account_created, temporary_password = get_or_create_account(
|
||||
session,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password=password,
|
||||
password_reset_required=password_reset_required,
|
||||
)
|
||||
existing = (
|
||||
session.query(User)
|
||||
.filter(User.tenant_id == tenant.id, User.account_id == account.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise AdminConflictError("This account already belongs to the tenant.")
|
||||
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=display_name.strip() if display_name else account.display_name,
|
||||
is_active=is_active,
|
||||
is_tenant_admin=False,
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
return MembershipCreationResult(
|
||||
user=user,
|
||||
account=account,
|
||||
temporary_password=temporary_password if account_created else None,
|
||||
account_created=account_created,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(group_ids))
|
||||
groups = (
|
||||
session.query(Group)
|
||||
.filter(Group.tenant_id == user.tenant_id, Group.id.in_(ids))
|
||||
.all()
|
||||
if ids else []
|
||||
)
|
||||
if len(groups) != len(ids):
|
||||
raise AdminValidationError("One or more selected groups do not belong to the tenant.")
|
||||
|
||||
session.query(UserGroupMembership).filter(
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
UserGroupMembership.user_id == user.id,
|
||||
).delete(synchronize_session=False)
|
||||
for group in groups:
|
||||
session.add(UserGroupMembership(tenant_id=user.tenant_id, user_id=user.id, group_id=group.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||
.all()
|
||||
if ids else []
|
||||
)
|
||||
if len(roles) != len(ids):
|
||||
raise AdminValidationError("One or more selected roles are invalid or not assignable.")
|
||||
|
||||
session.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == user.tenant_id,
|
||||
UserRoleAssignment.user_id == user.id,
|
||||
).delete(synchronize_session=False)
|
||||
for role in roles:
|
||||
session.add(UserRoleAssignment(tenant_id=user.tenant_id, user_id=user.id, role_id=role.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def set_group_members(session: Session, *, group: Group, user_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(user_ids))
|
||||
users = (
|
||||
session.query(User)
|
||||
.filter(User.tenant_id == group.tenant_id, User.id.in_(ids))
|
||||
.all()
|
||||
if ids else []
|
||||
)
|
||||
if len(users) != len(ids):
|
||||
raise AdminValidationError("One or more selected users do not belong to the tenant.")
|
||||
|
||||
session.query(UserGroupMembership).filter(
|
||||
UserGroupMembership.tenant_id == group.tenant_id,
|
||||
UserGroupMembership.group_id == group.id,
|
||||
).delete(synchronize_session=False)
|
||||
for user in users:
|
||||
session.add(UserGroupMembership(tenant_id=group.tenant_id, user_id=user.id, group_id=group.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def set_group_roles(session: Session, *, group: Group, role_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id == group.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||
.all()
|
||||
if ids else []
|
||||
)
|
||||
if len(roles) != len(ids):
|
||||
raise AdminValidationError("One or more selected roles are invalid or not assignable.")
|
||||
|
||||
session.query(GroupRoleAssignment).filter(
|
||||
GroupRoleAssignment.tenant_id == group.tenant_id,
|
||||
GroupRoleAssignment.group_id == group.id,
|
||||
).delete(synchronize_session=False)
|
||||
for role in roles:
|
||||
session.add(GroupRoleAssignment(tenant_id=group.tenant_id, group_id=group.id, role_id=role.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def create_custom_role(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: Iterable[str],
|
||||
) -> Role:
|
||||
normalized_slug = slugify(slug)
|
||||
if session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == normalized_slug).count():
|
||||
raise AdminConflictError("A role with this slug already exists in the tenant.")
|
||||
try:
|
||||
validated = validate_tenant_permissions(permissions)
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
role = Role(
|
||||
tenant_id=tenant.id,
|
||||
slug=normalized_slug,
|
||||
name=name.strip(),
|
||||
description=description.strip() if description else None,
|
||||
permissions=validated,
|
||||
is_builtin=False,
|
||||
is_assignable=True,
|
||||
)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
return role
|
||||
|
||||
|
||||
def update_custom_role(
|
||||
session: Session,
|
||||
*,
|
||||
role: Role,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: Iterable[str],
|
||||
is_assignable: bool,
|
||||
) -> None:
|
||||
if role.is_builtin:
|
||||
raise AdminConflictError("Built-in roles are managed by the application and cannot be edited.")
|
||||
try:
|
||||
validated = validate_tenant_permissions(permissions)
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
role.name = name.strip()
|
||||
role.description = description.strip() if description else None
|
||||
role.permissions = validated
|
||||
role.is_assignable = is_assignable
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_custom_role(session: Session, role: Role) -> None:
|
||||
if role.is_builtin:
|
||||
raise AdminConflictError("Built-in roles cannot be deleted.")
|
||||
assigned = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
||||
assigned += session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
||||
if assigned:
|
||||
raise AdminConflictError("Remove all user and group assignments before deleting this role.")
|
||||
session.delete(role)
|
||||
session.flush()
|
||||
|
||||
|
||||
def create_system_role(
|
||||
session: Session,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: Iterable[str],
|
||||
) -> Role:
|
||||
normalized_slug = slugify(slug)
|
||||
if session.query(Role).filter(Role.tenant_id.is_(None), Role.slug == normalized_slug).count():
|
||||
raise AdminConflictError("A system role with this slug already exists.")
|
||||
try:
|
||||
validated = validate_system_permissions(permissions)
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
if "system:*" in validated:
|
||||
raise AdminValidationError("Only the protected System owner role may contain system:*.")
|
||||
role = Role(
|
||||
tenant_id=None,
|
||||
slug=normalized_slug,
|
||||
name=name.strip(),
|
||||
description=description.strip() if description else None,
|
||||
permissions=validated,
|
||||
is_builtin=False,
|
||||
is_assignable=True,
|
||||
)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
return role
|
||||
|
||||
|
||||
def update_system_role(
|
||||
session: Session,
|
||||
*,
|
||||
role: Role,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: Iterable[str],
|
||||
is_assignable: bool,
|
||||
) -> None:
|
||||
if role.tenant_id is not None:
|
||||
raise AdminValidationError("This is not a system role.")
|
||||
if role.slug == "system_owner":
|
||||
raise AdminConflictError("The protected System owner role cannot be edited.")
|
||||
try:
|
||||
validated = validate_system_permissions(permissions)
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
if "system:*" in validated:
|
||||
raise AdminValidationError("Only the protected System owner role may contain system:*.")
|
||||
role.name = name.strip()
|
||||
role.description = description.strip() if description else None
|
||||
role.permissions = validated
|
||||
role.is_assignable = is_assignable
|
||||
role.is_builtin = False
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_system_role(session: Session, role: Role) -> None:
|
||||
if role.tenant_id is not None:
|
||||
raise AdminValidationError("This is not a system role.")
|
||||
if role.slug == "system_owner":
|
||||
raise AdminConflictError("The protected System owner role cannot be deleted.")
|
||||
assigned = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
if assigned:
|
||||
raise AdminConflictError("Remove all account assignments before deleting this system role.")
|
||||
session.delete(role)
|
||||
session.flush()
|
||||
|
||||
|
||||
def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent a system administrator from defining stronger roles than they hold."""
|
||||
requested = set(validate_system_permissions(permissions))
|
||||
if "system:*" in requested:
|
||||
if not scopes_grant(actor_scopes, "system:*"):
|
||||
raise AdminValidationError("Only a System owner may delegate full system access.")
|
||||
return
|
||||
allowed = delegateable_system_scopes(actor_scopes)
|
||||
missing = sorted(scope for scope in requested if scope not in allowed)
|
||||
if missing:
|
||||
raise AdminValidationError("You cannot delegate system permissions you do not currently hold: " + ", ".join(missing))
|
||||
|
||||
|
||||
def assert_can_delegate_system_roles(
|
||||
session: Session,
|
||||
*,
|
||||
actor_scopes: Iterable[str],
|
||||
role_ids: Iterable[str],
|
||||
) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
if not ids:
|
||||
return
|
||||
roles = session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(ids)).all()
|
||||
if len(roles) != len(ids):
|
||||
raise AdminValidationError("One or more selected system roles are invalid.")
|
||||
for role in roles:
|
||||
assert_can_delegate_system_permissions(actor_scopes, role.permissions or [])
|
||||
|
||||
|
||||
def set_system_roles(session: Session, *, account: Account, role_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id.is_(None), Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||
.all()
|
||||
if ids else []
|
||||
)
|
||||
if len(roles) != len(ids):
|
||||
raise AdminValidationError("One or more system roles are invalid or not assignable.")
|
||||
for role in roles:
|
||||
try:
|
||||
validate_system_permissions(role.permissions or [])
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
|
||||
session.query(SystemRoleAssignment).filter(SystemRoleAssignment.account_id == account.id).delete(synchronize_session=False)
|
||||
for role in roles:
|
||||
session.add(SystemRoleAssignment(account_id=account.id, role_id=role.id))
|
||||
session.flush()
|
||||
assert_system_owner_exists(session)
|
||||
|
||||
|
||||
def account_has_system_scope(session: Session, account: Account, required: str) -> bool:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.all()
|
||||
)
|
||||
return any(scopes_grant(role.permissions or [], required) for role in roles)
|
||||
|
||||
|
||||
def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]:
|
||||
"""Return active tenant memberships that currently satisfy the operational-owner invariant."""
|
||||
|
||||
users = (
|
||||
session.query(User)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
Account.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
owner_ids: set[str] = set()
|
||||
user_ids = [user.id for user in users]
|
||||
if not user_ids:
|
||||
return owner_ids
|
||||
|
||||
roles_by_user_id: dict[str, list[Role]] = {user_id: [] for user_id in user_ids}
|
||||
direct_role_rows = (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id.in_(user_ids),
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for user_id, role in direct_role_rows:
|
||||
roles_by_user_id.setdefault(user_id, []).append(role)
|
||||
|
||||
group_role_rows = (
|
||||
session.query(UserGroupMembership.user_id, Role)
|
||||
.join(GroupRoleAssignment, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
|
||||
.join(Role, GroupRoleAssignment.role_id == Role.id)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for user_id, role in group_role_rows:
|
||||
roles_by_user_id.setdefault(user_id, []).append(role)
|
||||
|
||||
for user in users:
|
||||
effective = [
|
||||
scope
|
||||
for role in roles_by_user_id.get(user.id, [])
|
||||
for scope in (role.permissions or [])
|
||||
]
|
||||
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):
|
||||
owner_ids.add(user.id)
|
||||
return owner_ids
|
||||
|
||||
|
||||
def tenant_has_owner(session: Session, tenant_id: str) -> bool:
|
||||
return bool(tenant_owner_user_ids(session, tenant_id))
|
||||
|
||||
|
||||
def assert_tenant_owner_exists(session: Session, tenant_id: str) -> None:
|
||||
session.flush()
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is not None and tenant.is_active and not tenant_has_owner(session, tenant_id):
|
||||
raise AdminConflictError("An active tenant must retain at least one active owner or administrator with full operational access.")
|
||||
|
||||
|
||||
def assert_system_owner_exists(session: Session) -> None:
|
||||
"""Keep one active assignment of the protected System owner role.
|
||||
|
||||
Other roles may hold broad system permissions, but they are intentionally
|
||||
not substitutes for the protected break-glass owner identity.
|
||||
"""
|
||||
session.flush()
|
||||
owner_role = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id.is_(None), Role.slug == "system_owner")
|
||||
.one_or_none()
|
||||
)
|
||||
if owner_role is None:
|
||||
raise AdminConflictError("The protected System owner role is missing.")
|
||||
count = (
|
||||
session.query(func.count(SystemRoleAssignment.id))
|
||||
.join(Account, Account.id == SystemRoleAssignment.account_id)
|
||||
.filter(SystemRoleAssignment.role_id == owner_role.id, Account.is_active.is_(True))
|
||||
.scalar()
|
||||
)
|
||||
if not count:
|
||||
raise AdminConflictError("At least one active account must retain the protected System owner role.")
|
||||
|
||||
|
||||
def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
|
||||
return (
|
||||
session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role_id).count(),
|
||||
session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role_id).count(),
|
||||
)
|
||||
|
||||
|
||||
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
|
||||
requested = set(validate_tenant_permissions(permissions))
|
||||
if "tenant:*" in requested:
|
||||
# Only a tenant owner-equivalent can delegate the wildcard.
|
||||
if not scopes_grant(actor_scopes, "tenant:*"):
|
||||
raise AdminValidationError("You cannot delegate full tenant access unless you already have it.")
|
||||
return
|
||||
allowed = delegateable_tenant_scopes(actor_scopes)
|
||||
missing = sorted(scope for scope in requested if scope not in allowed)
|
||||
if missing:
|
||||
raise AdminValidationError("You cannot delegate permissions you do not currently hold: " + ", ".join(missing))
|
||||
|
||||
|
||||
def assert_can_delegate_roles(session: Session, *, actor_scopes: Iterable[str], role_ids: Iterable[str], tenant_id: str) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
if not ids:
|
||||
return
|
||||
roles = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id.in_(ids)).all()
|
||||
if len(roles) != len(ids):
|
||||
raise AdminValidationError("One or more selected roles do not belong to the tenant.")
|
||||
for role in roles:
|
||||
assert_can_delegate_tenant_permissions(actor_scopes, role.permissions or [])
|
||||
111
src/govoplan_access/backend/administration.py
Normal file
111
src/govoplan_access/backend/administration.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
from govoplan_core.core.access import AccessAdministration
|
||||
|
||||
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
db = _session(session)
|
||||
return {
|
||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Account).count()
|
||||
|
||||
def role_count_for_tenant(self, session: object, tenant_id: str) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Role).filter(Role.tenant_id == tenant_id).count()
|
||||
|
||||
def active_api_key_count_for_tenant(self, session: object, tenant_id: str) -> int:
|
||||
db = _session(session)
|
||||
return db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()
|
||||
|
||||
def actor_email_by_user_id(self, session: object, user_ids: Iterable[str]) -> Mapping[str, str | None]:
|
||||
ids = {str(user_id) for user_id in user_ids if user_id}
|
||||
if not ids:
|
||||
return {}
|
||||
db = _session(session)
|
||||
rows = (
|
||||
db.query(User.id, Account.email)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
return {user_id: email for user_id, email in rows}
|
||||
|
||||
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
|
||||
normalized = value.casefold().strip()
|
||||
if not normalized:
|
||||
return ()
|
||||
db = _session(session)
|
||||
text = func.lower(func.coalesce(Account.email, ""))
|
||||
condition = text == normalized if operator == "eq" else text.contains(normalized)
|
||||
rows = (
|
||||
db.query(User.id)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(condition)
|
||||
.all()
|
||||
)
|
||||
return tuple(user_id for (user_id,) in rows)
|
||||
|
||||
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
user = _session(session).get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(user.settings or {})
|
||||
|
||||
def set_user_settings(
|
||||
self,
|
||||
session: object,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
user = db.get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
user.settings = dict(settings)
|
||||
db.add(user)
|
||||
return dict(user.settings or {})
|
||||
|
||||
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
group = _session(session).get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(group.settings or {})
|
||||
|
||||
def set_group_settings(
|
||||
self,
|
||||
session: object,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
group = db.get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
group.settings = dict(settings)
|
||||
db.add(group)
|
||||
return dict(group.settings or {})
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access administration requires a SQLAlchemy Session")
|
||||
return session
|
||||
2
src/govoplan_access/backend/api/__init__.py
Normal file
2
src/govoplan_access/backend/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned backend API routes."""
|
||||
|
||||
2
src/govoplan_access/backend/api/v1/__init__.py
Normal file
2
src/govoplan_access/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned API v1 routes."""
|
||||
|
||||
11
src/govoplan_access/backend/api/v1/admin.py
Normal file
11
src/govoplan_access/backend/api/v1/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Compatibility import for access-owned admin routes.
|
||||
|
||||
The remaining platform admin routes are contributed by the `admin`, `tenancy`,
|
||||
`policy`, and `audit` modules through their own manifests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
454
src/govoplan_access/backend/api/v1/admin_common.py
Normal file
454
src/govoplan_access/backend/api/v1/admin_common.py
Normal file
@@ -0,0 +1,454 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import (
|
||||
AdminConflictError,
|
||||
AdminValidationError,
|
||||
assert_tenant_owner_exists,
|
||||
role_assignment_counts,
|
||||
set_user_groups,
|
||||
set_user_roles,
|
||||
tenant_owner_user_ids,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ApiKeyAdminItem,
|
||||
GroupSummary,
|
||||
RoleSummary,
|
||||
SystemAccountItem,
|
||||
UserAdminItem,
|
||||
)
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_direct_user_roles,
|
||||
collect_system_roles,
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
from govoplan_access.backend.semantic import (
|
||||
collect_external_function_roles,
|
||||
collect_function_assignment_ids,
|
||||
collect_function_delegation_ids,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, AdminValidationError):
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _require_system_role_assignment(principal: ApiPrincipal) -> None:
|
||||
if not (has_scope(principal, "system:roles:assign") or has_scope(principal, "system:access:assign")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:roles:assign")
|
||||
|
||||
|
||||
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _resolve_tenant(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
tenant_id: str | None,
|
||||
) -> Tenant:
|
||||
target_id = tenant_id or principal.tenant_id
|
||||
if target_id != principal.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Switch to the target tenant before using tenant-administration endpoints.",
|
||||
)
|
||||
tenant = session.get(Tenant, target_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
return tenant
|
||||
|
||||
|
||||
def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
user_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
|
||||
.filter(UserRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(UserRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
group_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
|
||||
.filter(GroupRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(GroupRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
|
||||
|
||||
|
||||
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
return {
|
||||
role_id: int(count)
|
||||
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
|
||||
.filter(SystemRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(SystemRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
|
||||
if not account_ids:
|
||||
return {}
|
||||
return {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
return {
|
||||
user.id: account
|
||||
for user, account in session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.id.in_(sorted(set(user_ids))))
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
|
||||
grouped: dict[str, list[str]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, user_id in (
|
||||
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
|
||||
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(user_id)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, role in (
|
||||
session.query(GroupRoleAssignment.group_id, Role)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
|
||||
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
|
||||
grouped: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, group in (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(group)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, role in (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _role_summary(
|
||||
session: Session,
|
||||
role: Role,
|
||||
*,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
system_role_assignment_counts: dict[str, int] | None = None,
|
||||
) -> RoleSummary:
|
||||
group_count = 0
|
||||
if role.tenant_id is None:
|
||||
user_count = (
|
||||
system_role_assignment_counts.get(role.id, 0)
|
||||
if system_role_assignment_counts is not None
|
||||
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
)
|
||||
permission_level = "system"
|
||||
else:
|
||||
user_count, group_count = (
|
||||
tenant_role_assignment_counts.get(role.id, (0, 0))
|
||||
if tenant_role_assignment_counts is not None
|
||||
else role_assignment_counts(session, role.id)
|
||||
)
|
||||
permission_level = "tenant"
|
||||
permissions = list(role.permissions or [])
|
||||
return RoleSummary(
|
||||
id=role.id,
|
||||
slug=role.slug,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
permissions=permissions,
|
||||
effective_permission_count=effective_permission_count(permissions, level=permission_level),
|
||||
is_builtin=role.is_builtin,
|
||||
is_assignable=role.is_assignable,
|
||||
user_assignments=user_count,
|
||||
group_assignments=group_count,
|
||||
level="system" if role.tenant_id is None else "tenant",
|
||||
system_template_id=role.system_template_id,
|
||||
system_required=role.system_required,
|
||||
)
|
||||
|
||||
|
||||
def _group_summary(
|
||||
session: Session,
|
||||
group: Group,
|
||||
*,
|
||||
include_members: bool = True,
|
||||
member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> GroupSummary:
|
||||
member_ids = (
|
||||
member_ids_by_group.get(group.id, [])
|
||||
if member_ids_by_group is not None
|
||||
else [
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||
.all()
|
||||
]
|
||||
)
|
||||
roles = (
|
||||
roles_by_group.get(group.id, [])
|
||||
if roles_by_group is not None
|
||||
else (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
)
|
||||
return GroupSummary(
|
||||
id=group.id,
|
||||
slug=group.slug,
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
is_active=group.is_active,
|
||||
member_count=len(member_ids),
|
||||
member_ids=member_ids if include_members else [],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
system_template_id=group.system_template_id,
|
||||
system_required=group.system_required,
|
||||
)
|
||||
|
||||
|
||||
def _user_item(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
accounts_by_id: dict[str, Account] | None = None,
|
||||
groups_by_user: dict[str, list[Group]] | None = None,
|
||||
roles_by_user: dict[str, list[Role]] | None = None,
|
||||
group_member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
group_roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> UserAdminItem:
|
||||
account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
|
||||
roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
|
||||
external_roles = (
|
||||
collect_external_function_roles(
|
||||
session,
|
||||
user,
|
||||
idm_assignments,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if idm_assignments else []
|
||||
)
|
||||
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
|
||||
for role in external_roles:
|
||||
effective_scopes.update(role.permissions or [])
|
||||
function_assignment_ids = collect_function_assignment_ids(session, user)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
|
||||
return UserAdminItem(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
tenant_id=user.tenant_id,
|
||||
email=account.email,
|
||||
display_name=user.display_name or account.display_name,
|
||||
is_active=user.is_active,
|
||||
account_is_active=account.is_active,
|
||||
password_reset_required=account.password_reset_required,
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[
|
||||
_group_summary(
|
||||
session,
|
||||
group,
|
||||
include_members=False,
|
||||
member_ids_by_group=group_member_ids_by_group,
|
||||
roles_by_group=group_roles_by_group,
|
||||
tenant_role_assignment_counts=tenant_role_assignment_counts,
|
||||
)
|
||||
for group in groups
|
||||
],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=expand_scopes(effective_scopes),
|
||||
is_owner=user.id in effective_owner_ids,
|
||||
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
memberships = (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(User.account_id == account.id)
|
||||
.order_by(Tenant.name.asc())
|
||||
.all()
|
||||
)
|
||||
owner_ids_by_tenant = {
|
||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
||||
for _, tenant in memberships
|
||||
}
|
||||
return SystemAccountItem(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
memberships=[
|
||||
{
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
||||
"is_last_active_owner": (
|
||||
user.id in owner_ids_by_tenant[tenant.id]
|
||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
||||
),
|
||||
}
|
||||
for user, tenant in memberships
|
||||
],
|
||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
||||
last_login_at=account.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
else:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
return ApiKeyAdminItem(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
user_email=account.email if account else "Unknown account",
|
||||
name=item.name,
|
||||
prefix=item.prefix,
|
||||
scopes=item.scopes or [],
|
||||
expires_at=item.expires_at,
|
||||
last_used_at=item.last_used_at,
|
||||
revoked_at=item.revoked_at,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _set_system_memberships(session: Session, account: Account, requested: list[dict]) -> None:
|
||||
desired = {item["tenant_id"]: item for item in requested}
|
||||
existing = {item.tenant_id: item for item in session.query(User).filter(User.account_id == account.id).all()}
|
||||
affected = set(existing) | set(desired)
|
||||
for tenant_id, user in existing.items():
|
||||
if tenant_id not in desired:
|
||||
user.is_active = False
|
||||
session.add(user)
|
||||
for tenant_id, item in desired.items():
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
|
||||
user = existing.get(tenant_id)
|
||||
if user is None:
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=bool(item.get("is_active", True)),
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
else:
|
||||
user.is_active = bool(item.get("is_active", True))
|
||||
user.email = account.email
|
||||
session.add(user)
|
||||
set_user_roles(session, user=user, role_ids=item.get("role_ids", []))
|
||||
set_user_groups(session, user=user, group_ids=item.get("group_ids", []))
|
||||
session.flush()
|
||||
for tenant_id in affected:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant and tenant.is_active:
|
||||
assert_tenant_owner_exists(session, tenant_id)
|
||||
898
src/govoplan_access/backend/api/v1/admin_schemas.py
Normal file
898
src/govoplan_access/backend/api/v1/admin_schemas.py
Normal file
@@ -0,0 +1,898 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
|
||||
|
||||
class PermissionItem(BaseModel):
|
||||
scope: str
|
||||
label: str
|
||||
description: str
|
||||
category: str
|
||||
level: Literal["tenant", "system"]
|
||||
|
||||
|
||||
class PermissionCatalogResponse(BaseModel):
|
||||
permissions: list[PermissionItem]
|
||||
|
||||
|
||||
class AdminOverviewResponse(BaseModel):
|
||||
active_tenant_id: str
|
||||
active_tenant_name: str
|
||||
tenant_count: int | None = None
|
||||
system_account_count: int | None = None
|
||||
system_group_template_count: int | None = None
|
||||
system_role_template_count: int | None = None
|
||||
user_count: int
|
||||
active_user_count: int
|
||||
group_count: int
|
||||
role_count: int
|
||||
active_api_key_count: int
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TenantAdminItem(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
effective_governance: dict[str, bool] = Field(default_factory=dict)
|
||||
is_active: bool
|
||||
counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TenantListResponse(BaseModel):
|
||||
tenants: list[TenantAdminItem]
|
||||
|
||||
|
||||
class TenantOwnerCandidate(BaseModel):
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class TenantOwnerCandidateListResponse(BaseModel):
|
||||
accounts: list[TenantOwnerCandidate]
|
||||
|
||||
|
||||
class PagedListResponse(BaseModel):
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
owner_account_id: str | None = None
|
||||
description: str | None = None
|
||||
default_locale: str = "en"
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
|
||||
|
||||
class TenantUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str | None = Field(default=None, min_length=1, max_length=20)
|
||||
settings: dict[str, Any] | None = None
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class TenantSettingsItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TenantSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class RoleSummary(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
effective_permission_count: int = 0
|
||||
is_builtin: bool = False
|
||||
is_assignable: bool = True
|
||||
user_assignments: int = 0
|
||||
group_assignments: int = 0
|
||||
level: Literal["tenant", "system"] = "tenant"
|
||||
system_template_id: str | None = None
|
||||
system_required: bool = False
|
||||
|
||||
|
||||
class IdentityAccountLinkItem(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_primary: bool = False
|
||||
source: str = "local"
|
||||
|
||||
|
||||
class IdentityAdminItem(BaseModel):
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
external_subject: str | None = None
|
||||
source: str = "local"
|
||||
is_active: bool = True
|
||||
accounts: list[IdentityAccountLinkItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(PagedListResponse):
|
||||
identities: list[IdentityAdminItem]
|
||||
|
||||
|
||||
class IdentityCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str = Field(default="local", max_length=50)
|
||||
account_ids: list[str] = Field(default_factory=list)
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class IdentityUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str | None = Field(default=None, max_length=50)
|
||||
account_ids: list[str] | None = None
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class OrganizationUnitItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
parent_id: str | None = None
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
class OrganizationUnitCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OrganizationUnitUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
organization_unit_id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
function_id: str
|
||||
role_id: str
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListDeltaResponse(ExternalFunctionRoleMappingListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = True
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_id: str
|
||||
role_id: str
|
||||
source_module: str = Field(default="organizations", max_length=50)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role_id: str | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FunctionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str | None = None
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] | None = None
|
||||
delegable: bool | None = None
|
||||
act_in_place_allowed: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAssignmentAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool = False
|
||||
source: str = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
class FunctionAssignmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str | None = None
|
||||
identity_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: str = Field(default="direct", max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionAssignmentUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
identity_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
applies_to_subunits: bool | None = None
|
||||
source: str | None = Field(default=None, max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionDelegationAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
function_assignment_id: str
|
||||
delegator_account_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
class FunctionDelegationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_assignment_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionDelegationUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
revoked: bool | None = None
|
||||
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
member_count: int = 0
|
||||
member_ids: list[str] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
system_template_id: str | None = None
|
||||
system_required: bool = False
|
||||
|
||||
|
||||
class UserAdminItem(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
tenant_id: str
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
is_active: bool
|
||||
account_is_active: bool
|
||||
password_reset_required: bool = False
|
||||
last_login_at: datetime | None = None
|
||||
groups: list[GroupSummary] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
function_assignment_ids: list[str] = Field(default_factory=list)
|
||||
function_delegation_ids: list[str] = Field(default_factory=list)
|
||||
effective_scopes: list[str] = Field(default_factory=list)
|
||||
is_owner: bool = False
|
||||
is_last_active_owner: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal["direct_role", "group_role", "legacy_function_role", "idm_function_role", "system_role"]
|
||||
|
||||
|
||||
class AccessRoleSourceItem(BaseModel):
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
|
||||
class AccessScopeExplanationItem(BaseModel):
|
||||
scope: str
|
||||
sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccessDecisionProvenanceItem(BaseModel):
|
||||
kind: str
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
tenant_id: str | None = None
|
||||
source: str | None = None
|
||||
details: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionFactExplanationItem(BaseModel):
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
function_id: str
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
role_sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
scopes: list[AccessScopeExplanationItem] = Field(default_factory=list)
|
||||
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ResourceAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(PagedListResponse):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
class UserListDeltaResponse(UserListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
password: str | None = Field(default=None, min_length=10)
|
||||
password_reset_required: bool = True
|
||||
is_active: bool = True
|
||||
group_ids: list[str] = Field(default_factory=list)
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserCreateResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
account_created: bool
|
||||
temporary_password: str | None = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
is_active: bool | None = None
|
||||
group_ids: list[str] | None = None
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class GroupListResponse(PagedListResponse):
|
||||
groups: list[GroupSummary]
|
||||
|
||||
|
||||
class GroupListDeltaResponse(GroupListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class GroupCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
member_ids: list[str] = Field(default_factory=list)
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GroupUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
member_ids: list[str] | None = None
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class RoleListResponse(PagedListResponse):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class RoleListDeltaResponse(RoleListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
is_assignable: bool = True
|
||||
|
||||
|
||||
class SystemAccountItem(BaseModel):
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
is_active: bool
|
||||
memberships: list[dict[str, Any]] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemAccountListResponse(PagedListResponse):
|
||||
accounts: list[SystemAccountItem]
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class SystemAccountListDeltaResponse(SystemAccountListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class SystemAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
is_active: bool | None = None
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class SystemAccountRolesUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemMembershipUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_id: str
|
||||
is_active: bool = True
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
group_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemAccountMembershipsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemAccountCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
password: str | None = Field(default=None, min_length=10)
|
||||
password_reset_required: bool = True
|
||||
is_active: bool = True
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemAccountCreateResponse(BaseModel):
|
||||
account: SystemAccountItem
|
||||
temporary_password: str | None = None
|
||||
|
||||
|
||||
class PolicySourceStepItem(BaseModel):
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
label: str
|
||||
applied_fields: list[str] = Field(default_factory=list)
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyScopeResponse(BaseModel):
|
||||
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
scope_id: str | None = None
|
||||
policy: dict[str, Any]
|
||||
effective_policy: PrivacyRetentionPolicyItem
|
||||
parent_policy: PrivacyRetentionPolicyItem | None = None
|
||||
effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RetentionRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
class RetentionRunResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationSafetyFieldItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
owner_module: str
|
||||
scope: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: Literal["low", "medium", "high", "destructive"]
|
||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
policy_explanation_required: bool = False
|
||||
audit_event: str | None = None
|
||||
maintenance_required: bool = False
|
||||
two_person_approval_required: bool = False
|
||||
rollback_history_required: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConfigurationSafetyCatalogResponse(BaseModel):
|
||||
fields: list[ConfigurationSafetyFieldItem]
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
maintenance_mode: bool = False
|
||||
approval_count: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanResponse(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationChangeRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
target: dict[str, Any] = Field(default_factory=dict)
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestApproveRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestResponse(BaseModel):
|
||||
request: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationControlSnapshotResponse(BaseModel):
|
||||
requests: list[dict[str, Any]] = Field(default_factory=list)
|
||||
history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationControlDeltaResponse(ConfigurationControlSnapshotResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ConfigurationPackageCatalogResponse(BaseModel):
|
||||
validation: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationPackageRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
package: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
supplied_data: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class ConfigurationPackageDryRunResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
required_data: list[dict[str, Any]] = Field(default_factory=list)
|
||||
plan: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageApplyResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ConfigurationPackageExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
module_ids: list[str] = Field(default_factory=list)
|
||||
object_refs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageExportResponse(BaseModel):
|
||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemSettingsItem(BaseModel):
|
||||
default_locale: str = "en"
|
||||
allow_tenant_custom_groups: bool = True
|
||||
allow_tenant_custom_roles: bool = True
|
||||
allow_tenant_api_keys: bool = True
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
allow_tenant_custom_groups: bool
|
||||
allow_tenant_custom_roles: bool
|
||||
allow_tenant_api_keys: bool
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||
available_languages: list[dict[str, Any]] | None = None
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class ApiKeyAdminItem(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_email: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiKeyListResponse(PagedListResponse):
|
||||
api_keys: list[ApiKeyAdminItem]
|
||||
|
||||
|
||||
class ApiKeyListDeltaResponse(ApiKeyListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class AdminApiKeyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
user_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
||||
secret: str
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
tenant_id: str | None = None
|
||||
actor_email: str | None = None
|
||||
action: str
|
||||
object_type: str | None = None
|
||||
object_id: str | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuditAdminListResponse(BaseModel):
|
||||
items: list[AuditAdminItem]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
pages: int = 1
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
857
src/govoplan_access/backend/api/v1/auth.py
Normal file
857
src/govoplan_access/backend/api/v1/auth.py
Normal file
@@ -0,0 +1,857 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
AuthGroupsResponse,
|
||||
AuthProfileResponse,
|
||||
AuthRolesResponse,
|
||||
AuthShellResponse,
|
||||
AuthSessionResponse,
|
||||
AuthSessionUserInfo,
|
||||
GroupInfo,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MeResponse,
|
||||
PrincipalContextInfo,
|
||||
ProfileUpdateRequest,
|
||||
RoleInfo,
|
||||
SwitchTenantRequest,
|
||||
TenantInfo,
|
||||
TenantMembershipInfo,
|
||||
UserInfo,
|
||||
UserUiPreferences,
|
||||
)
|
||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.i18n import (
|
||||
i18n_settings,
|
||||
normalize_enabled_language_codes,
|
||||
normalize_language_code,
|
||||
preferred_language_code,
|
||||
system_enabled_language_codes,
|
||||
system_i18n_payload,
|
||||
tenant_enabled_language_codes,
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
collect_system_roles,
|
||||
collect_tenant_memberships,
|
||||
collect_user_groups,
|
||||
collect_user_roles,
|
||||
collect_user_scopes,
|
||||
create_auth_session,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthContext:
|
||||
account: Account
|
||||
user: User
|
||||
tenant: Tenant
|
||||
auth_method: str
|
||||
source: str
|
||||
auth_session: AuthSession | None = None
|
||||
api_key: ApiKey | None = None
|
||||
|
||||
|
||||
def _cookie_samesite() -> str:
|
||||
value = settings.auth_cookie_samesite.lower().strip()
|
||||
if value not in {"lax", "strict", "none"}:
|
||||
return "lax"
|
||||
if value == "none" and not settings.auth_cookie_secure:
|
||||
return "lax"
|
||||
return value
|
||||
|
||||
|
||||
def _set_auth_cookies(response: Response, created) -> None:
|
||||
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
|
||||
common = {
|
||||
"secure": settings.auth_cookie_secure,
|
||||
"samesite": _cookie_samesite(),
|
||||
"max_age": max_age,
|
||||
"path": "/",
|
||||
}
|
||||
if settings.auth_cookie_domain:
|
||||
common["domain"] = settings.auth_cookie_domain
|
||||
response.set_cookie(settings.auth_session_cookie_name, created.token, httponly=True, **common)
|
||||
response.set_cookie(settings.auth_csrf_cookie_name, created.csrf_token, httponly=False, **common)
|
||||
|
||||
|
||||
def _clear_auth_cookies(response: Response) -> None:
|
||||
kwargs = {"path": "/"}
|
||||
if settings.auth_cookie_domain:
|
||||
kwargs["domain"] = settings.auth_cookie_domain
|
||||
response.delete_cookie(settings.auth_session_cookie_name, **kwargs)
|
||||
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
|
||||
|
||||
|
||||
def _tenant_info(tenant: Tenant, *, enabled_language_codes: list[str] | None = None) -> TenantInfo:
|
||||
return TenantInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
)
|
||||
|
||||
|
||||
def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
|
||||
raw = settings_payload.get("ui") if isinstance(settings_payload, dict) else None
|
||||
try:
|
||||
return UserUiPreferences.model_validate(raw if isinstance(raw, dict) else {})
|
||||
except ValueError:
|
||||
return UserUiPreferences()
|
||||
|
||||
|
||||
def _user_info(
|
||||
user: User,
|
||||
account: Account,
|
||||
*,
|
||||
preferred_language: str | None = None,
|
||||
enabled_language_codes: list[str] | None = None,
|
||||
) -> UserInfo:
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
ui_preferences=_user_ui_preferences(user.settings),
|
||||
)
|
||||
|
||||
|
||||
def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
|
||||
return AuthSessionUserInfo(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
)
|
||||
|
||||
|
||||
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
|
||||
return [
|
||||
RoleInfo(
|
||||
id=role.id,
|
||||
slug=role.slug,
|
||||
name=role.name,
|
||||
permissions=role.permissions or [],
|
||||
level=level,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _groups_info(groups) -> list[GroupInfo]:
|
||||
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
|
||||
|
||||
|
||||
def _tenant_memberships(
|
||||
session: Session,
|
||||
account: Account,
|
||||
*,
|
||||
active_user_id: str | None = None,
|
||||
active_tenant_roles: list[Role] | None = None,
|
||||
) -> list[TenantMembershipInfo]:
|
||||
memberships: list[TenantMembershipInfo] = []
|
||||
for user, tenant in collect_tenant_memberships(session, account):
|
||||
roles = (
|
||||
active_tenant_roles
|
||||
if active_tenant_roles is not None and user.id == active_user_id
|
||||
else collect_user_roles(session, user)
|
||||
)
|
||||
memberships.append(
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active and user.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
roles=[role.slug for role in roles],
|
||||
)
|
||||
)
|
||||
return memberships
|
||||
|
||||
|
||||
def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]:
|
||||
return [
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active and user.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
roles=[],
|
||||
)
|
||||
for user, tenant in collect_tenant_memberships(session, account)
|
||||
]
|
||||
|
||||
|
||||
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
|
||||
system_settings = get_system_settings(session)
|
||||
system_payload = system_i18n_payload(system_settings)
|
||||
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
|
||||
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
|
||||
user_enabled = user_enabled_language_codes(user.settings, tenant_enabled)
|
||||
preferred = preferred_language_code(
|
||||
user.settings,
|
||||
user_enabled,
|
||||
default_locale=tenant.default_locale or system_payload.get("default_language"),
|
||||
)
|
||||
return {
|
||||
"available_languages": system_payload["available_languages"],
|
||||
"tenant_enabled_language_codes": tenant_enabled,
|
||||
"user_enabled_language_codes": user_enabled,
|
||||
"preferred_language": preferred,
|
||||
}
|
||||
|
||||
|
||||
def _identity_directory_from_request(request: Request) -> IdentityDirectory | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
|
||||
account = (
|
||||
session.query(Account)
|
||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||
.one_or_none()
|
||||
)
|
||||
if account is None or not verify_password(payload.password, account.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||
|
||||
query = (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == account.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if payload.tenant_slug:
|
||||
query = query.filter(Tenant.slug == payload.tenant_slug)
|
||||
row = query.order_by(Tenant.name.asc()).first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
|
||||
return account, row[0], row[1]
|
||||
|
||||
|
||||
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
|
||||
x_api_key = request.headers.get("x-api-key")
|
||||
if x_api_key:
|
||||
return x_api_key.strip(), "api_key"
|
||||
authorization = request.headers.get("authorization")
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip(), "bearer"
|
||||
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
if cookie_token:
|
||||
return cookie_token.strip(), "cookie"
|
||||
return None, "none"
|
||||
|
||||
|
||||
def _active_context_or_401(
|
||||
*,
|
||||
user: User | None,
|
||||
account: Account | None,
|
||||
tenant: Tenant | None,
|
||||
expected_tenant_id: str,
|
||||
) -> tuple[Account, User, Tenant]:
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
or not user.is_active or not account.is_active or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != expected_tenant_id
|
||||
or tenant.id != expected_tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session")
|
||||
return account, user, tenant
|
||||
|
||||
|
||||
def _session_response(
|
||||
*,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
auth_method: str,
|
||||
auth_session: AuthSession | None = None,
|
||||
api_key: ApiKey | None = None,
|
||||
) -> AuthSessionResponse:
|
||||
active_tenant = _tenant_info(tenant)
|
||||
return AuthSessionResponse(
|
||||
authenticated=True,
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
user=_session_user_info(user, account),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
token, source = _extract_auth_token(request)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
|
||||
api_key = authenticate_api_key(session, token) if source != "cookie" else None
|
||||
if api_key is not None:
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
account, user, tenant = _active_context_or_401(
|
||||
user=user,
|
||||
account=account,
|
||||
tenant=tenant,
|
||||
expected_tenant_id=api_key.tenant_id,
|
||||
)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
auth_method="api_key",
|
||||
source=source,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
if auth_session is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
account, user, tenant = _active_context_or_401(
|
||||
user=user,
|
||||
account=account,
|
||||
tenant=tenant,
|
||||
expected_tenant_id=auth_session.tenant_id,
|
||||
)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
auth_method="session",
|
||||
source=source,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
|
||||
|
||||
def _shell_response(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
scopes: list[str],
|
||||
auth_method: str,
|
||||
auth_session: AuthSession | None = None,
|
||||
api_key: ApiKey | None = None,
|
||||
) -> AuthShellResponse:
|
||||
active_tenant = _tenant_info(tenant)
|
||||
memberships = (
|
||||
_tenant_membership_summaries(session, account)
|
||||
if auth_session is not None
|
||||
else [
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active and user.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
roles=[],
|
||||
)
|
||||
]
|
||||
)
|
||||
return AuthShellResponse(
|
||||
user=_user_info(user, account),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
tenants=memberships,
|
||||
scopes=scopes,
|
||||
principal=PrincipalContextInfo(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
scopes=scopes,
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
return _session_response(
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant=context.tenant,
|
||||
auth_method=context.auth_method,
|
||||
auth_session=context.auth_session,
|
||||
api_key=context.api_key,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
if context.api_key is not None:
|
||||
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
||||
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
||||
return _shell_response(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant=context.tenant,
|
||||
scopes=scopes,
|
||||
auth_method="api_key",
|
||||
api_key=context.api_key,
|
||||
)
|
||||
|
||||
scopes = collect_user_scopes(session, context.user, include_system=True)
|
||||
return _shell_response(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant=context.tenant,
|
||||
scopes=scopes,
|
||||
auth_method="session",
|
||||
auth_session=context.auth_session,
|
||||
)
|
||||
|
||||
|
||||
def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse:
|
||||
languages = _language_context(session, tenant=context.tenant, user=context.user)
|
||||
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
||||
user_enabled = list(languages["user_enabled_language_codes"])
|
||||
preferred_language = str(languages["preferred_language"])
|
||||
active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled)
|
||||
return AuthProfileResponse(
|
||||
user=_user_info(
|
||||
context.user,
|
||||
context.account,
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=user_enabled,
|
||||
),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
available_languages=languages["available_languages"],
|
||||
enabled_language_codes=tenant_enabled,
|
||||
default_language=preferred_language,
|
||||
)
|
||||
|
||||
|
||||
def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse:
|
||||
tenant_roles = collect_user_roles(session, context.user)
|
||||
system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else []
|
||||
return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"))
|
||||
|
||||
|
||||
def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse:
|
||||
return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user)))
|
||||
|
||||
|
||||
def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None:
|
||||
if context.auth_session is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
|
||||
if context.source != "cookie":
|
||||
return
|
||||
header_token = request.headers.get("x-csrf-token")
|
||||
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(context.auth_session, header_token):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
|
||||
|
||||
|
||||
def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.id.in_(sorted(principal.role_ids)))
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
tenant_roles = [role for role in roles if role.tenant_id == tenant_id]
|
||||
system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None]
|
||||
return tenant_roles, system_roles
|
||||
|
||||
|
||||
def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]:
|
||||
return (
|
||||
session.query(Group)
|
||||
.filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids)))
|
||||
.order_by(Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _me_response(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
effective_scopes: list[str] | None = None,
|
||||
auth_method: AuthMethod = "session",
|
||||
api_key_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
service_account_id: str | None = None,
|
||||
include_system: bool = True,
|
||||
include_all_memberships: bool = True,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
identity_id: str | None = None,
|
||||
tenant_roles: list[Role] | None = None,
|
||||
system_roles: list[Role] | None = None,
|
||||
groups: list[Group] | None = None,
|
||||
function_assignment_ids: tuple[str, ...] | None = None,
|
||||
delegation_ids: tuple[str, ...] | None = None,
|
||||
) -> MeResponse:
|
||||
if tenant_roles is None:
|
||||
tenant_roles = collect_user_roles(session, user)
|
||||
if system_roles is None:
|
||||
system_roles = collect_system_roles(session, account) if include_system else []
|
||||
elif not include_system:
|
||||
system_roles = []
|
||||
if groups is None:
|
||||
groups = collect_user_groups(session, user)
|
||||
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
|
||||
resolved_function_assignment_ids = (
|
||||
function_assignment_ids
|
||||
if function_assignment_ids is not None
|
||||
else tuple(collect_function_assignment_ids(session, user))
|
||||
)
|
||||
resolved_delegation_ids = (
|
||||
delegation_ids
|
||||
if delegation_ids is not None
|
||||
else tuple(collect_function_delegation_ids(session, user))
|
||||
)
|
||||
languages = _language_context(session, tenant=tenant, user=user)
|
||||
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
||||
user_enabled = list(languages["user_enabled_language_codes"])
|
||||
preferred_language = str(languages["preferred_language"])
|
||||
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
|
||||
memberships = _tenant_memberships(
|
||||
session,
|
||||
account,
|
||||
active_user_id=user.id,
|
||||
active_tenant_roles=tenant_roles,
|
||||
) if include_all_memberships else [
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active and user.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
roles=[role.slug for role in tenant_roles],
|
||||
)
|
||||
]
|
||||
return MeResponse(
|
||||
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
tenants=memberships,
|
||||
scopes=scopes,
|
||||
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
|
||||
groups=_groups_info(groups),
|
||||
principal=PrincipalContextInfo.model_validate(
|
||||
PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
identity_id=identity_id or identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=frozenset(group.id for group in groups),
|
||||
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
||||
function_assignment_ids=frozenset(resolved_function_assignment_ids),
|
||||
delegation_ids=frozenset(resolved_delegation_ids),
|
||||
auth_method=auth_method,
|
||||
api_key_id=api_key_id,
|
||||
session_id=session_id,
|
||||
service_account_id=service_account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
).to_dict()
|
||||
),
|
||||
available_languages=languages["available_languages"],
|
||||
enabled_language_codes=tenant_enabled,
|
||||
default_language=preferred_language,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||
account, user, tenant = _resolve_login_user(session, payload)
|
||||
identity_directory = _identity_directory_from_request(request)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=True,
|
||||
)
|
||||
tenant_roles = authorization_context.tenant_roles
|
||||
system_roles = authorization_context.system_roles
|
||||
groups = authorization_context.groups
|
||||
effective_scopes = authorization_context.scopes
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(maintenance_mode),
|
||||
)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
ip_address = request.client.host if request.client else None
|
||||
created = create_auth_session(
|
||||
session,
|
||||
user=user,
|
||||
hours=settings.auth_session_hours,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.commit()
|
||||
_set_auth_cookies(response, created)
|
||||
return LoginResponse(
|
||||
access_token=created.token,
|
||||
expires_at=created.model.expires_at,
|
||||
**_me_response(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
effective_scopes=effective_scopes,
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
identity_directory=identity_directory,
|
||||
tenant_roles=tenant_roles,
|
||||
system_roles=system_roles,
|
||||
groups=groups,
|
||||
function_assignment_ids=authorization_context.function_assignment_ids,
|
||||
delegation_ids=authorization_context.function_delegation_ids,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/session", response_model=AuthSessionResponse)
|
||||
def auth_session(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_lightweight_session(request, session)
|
||||
|
||||
|
||||
@router.get("/shell", response_model=AuthShellResponse)
|
||||
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_shell_auth(request, session)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=AuthProfileResponse)
|
||||
def auth_profile(request: Request, session: Session = Depends(get_session)):
|
||||
return _profile_response(session, _resolve_auth_context(request, session))
|
||||
|
||||
|
||||
@router.get("/roles", response_model=AuthRolesResponse)
|
||||
def auth_roles(request: Request, session: Session = Depends(get_session)):
|
||||
context = _resolve_auth_context(request, session)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
context.user,
|
||||
account=context.account,
|
||||
include_system=context.auth_session is not None,
|
||||
)
|
||||
return AuthRolesResponse(
|
||||
roles=_roles_info(authorization_context.tenant_roles)
|
||||
+ _roles_info(authorization_context.system_roles, level="system")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups", response_model=AuthGroupsResponse)
|
||||
def auth_groups(request: Request, session: Session = Depends(get_session)):
|
||||
return _groups_response(session, _resolve_auth_context(request, session))
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
|
||||
tenant = session.get(Tenant, principal.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
||||
tenant_roles: list[Role] | None = None
|
||||
system_roles: list[Role] | None = None
|
||||
groups: list[Group] | None = None
|
||||
if principal.role_ids:
|
||||
tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id)
|
||||
if principal.group_ids:
|
||||
groups = _groups_from_principal(session, principal, tenant_id=tenant.id)
|
||||
return _me_response(
|
||||
session,
|
||||
account=principal.account,
|
||||
user=principal.user,
|
||||
tenant=tenant,
|
||||
effective_scopes=principal.scopes,
|
||||
auth_method=principal.auth_method,
|
||||
api_key_id=principal.api_key_id,
|
||||
session_id=principal.session_id,
|
||||
service_account_id=principal.principal.service_account_id,
|
||||
include_system=principal.auth_session is not None,
|
||||
include_all_memberships=principal.auth_session is not None,
|
||||
identity_id=principal.principal.identity_id,
|
||||
tenant_roles=tenant_roles,
|
||||
system_roles=system_roles,
|
||||
groups=groups,
|
||||
function_assignment_ids=tuple(principal.function_assignment_ids),
|
||||
delegation_ids=tuple(principal.delegation_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/profile", response_model=AuthProfileResponse)
|
||||
def update_profile(
|
||||
payload: ProfileUpdateRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
context = _resolve_auth_context(request, session)
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
|
||||
if "display_name" in payload.model_fields_set:
|
||||
context.account.display_name = payload.display_name.strip() if payload.display_name else None
|
||||
session.add(context.account)
|
||||
if "tenant_display_name" in payload.model_fields_set:
|
||||
context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
|
||||
session.add(context.user)
|
||||
next_settings = dict(context.user.settings or {})
|
||||
settings_changed = False
|
||||
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
|
||||
system_settings = get_system_settings(session)
|
||||
system_enabled = system_enabled_language_codes(
|
||||
system_settings.settings,
|
||||
default_locale=system_settings.default_locale,
|
||||
)
|
||||
tenant_enabled = tenant_enabled_language_codes(
|
||||
context.tenant.settings,
|
||||
system_enabled,
|
||||
default_locale=context.tenant.default_locale,
|
||||
)
|
||||
next_i18n = i18n_settings(next_settings)
|
||||
if "preferred_language" in payload.model_fields_set:
|
||||
preferred = normalize_language_code(payload.preferred_language)
|
||||
if preferred:
|
||||
normalized_preferred = normalize_enabled_language_codes(
|
||||
[preferred],
|
||||
[{"code": code} for code in tenant_enabled],
|
||||
fallback_codes=tenant_enabled,
|
||||
)[0]
|
||||
next_i18n["preferred_language"] = normalized_preferred
|
||||
else:
|
||||
next_i18n.pop("preferred_language", None)
|
||||
if "enabled_language_codes" in payload.model_fields_set:
|
||||
next_i18n["enabled_language_codes"] = normalize_enabled_language_codes(
|
||||
payload.enabled_language_codes,
|
||||
[{"code": code} for code in tenant_enabled],
|
||||
fallback_codes=tenant_enabled,
|
||||
)
|
||||
next_settings["i18n"] = next_i18n
|
||||
settings_changed = True
|
||||
if "ui_preferences" in payload.model_fields_set:
|
||||
if payload.ui_preferences is None:
|
||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||
else:
|
||||
next_ui = _user_ui_preferences(next_settings).model_dump()
|
||||
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||
settings_changed = True
|
||||
if settings_changed:
|
||||
context.user.settings = next_settings
|
||||
session.add(context.user)
|
||||
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=context.tenant.id,
|
||||
user_id=context.user.id,
|
||||
action="profile.updated",
|
||||
object_type="account",
|
||||
object_id=context.account.id,
|
||||
details={"fields": sorted(payload.model_fields_set)},
|
||||
)
|
||||
session.commit()
|
||||
return _profile_response(session, context)
|
||||
|
||||
|
||||
@router.post("/switch-tenant", response_model=AuthShellResponse)
|
||||
def switch_tenant(
|
||||
payload: SwitchTenantRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if principal.auth_session is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
|
||||
try:
|
||||
switched = AccessTenantContextSwitcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
tenant = session.get(Tenant, switched.tenant_id)
|
||||
membership = session.get(User, switched.membership_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
if membership is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
|
||||
session.commit()
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
membership,
|
||||
account=principal.account,
|
||||
include_system=True,
|
||||
)
|
||||
return _shell_response(
|
||||
session,
|
||||
account=principal.account,
|
||||
user=membership,
|
||||
tenant=tenant,
|
||||
scopes=authorization_context.scopes,
|
||||
auth_method="session",
|
||||
auth_session=principal.auth_session,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if principal.auth_session is not None:
|
||||
principal.auth_session.revoked_at = utc_now()
|
||||
session.add(principal.auth_session)
|
||||
session.commit()
|
||||
_clear_auth_cookies(response)
|
||||
return {"ok": True}
|
||||
3769
src/govoplan_access/backend/api/v1/routes.py
Normal file
3769
src/govoplan_access/backend/api/v1/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
605
src/govoplan_access/backend/auth/dependencies.py
Normal file
605
src/govoplan_access/backend/auth/dependencies.py
Normal file
@@ -0,0 +1,605 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
PermissionEvaluator,
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
UserAuthorizationContext,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResolvedPrincipalContext:
|
||||
principal: PrincipalRef
|
||||
account: Account
|
||||
user: User
|
||||
tenant: Tenant
|
||||
api_key: ApiKey | None = None
|
||||
auth_session: AuthSession | None = None
|
||||
|
||||
|
||||
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
|
||||
if x_api_key:
|
||||
return x_api_key.strip(), "api_key"
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip(), "bearer"
|
||||
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
if cookie_token:
|
||||
return cookie_token.strip(), "cookie"
|
||||
return None, "none"
|
||||
|
||||
|
||||
def _requires_csrf(request: Request) -> bool:
|
||||
return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
|
||||
|
||||
def _build_principal_ref(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant_id: str,
|
||||
scopes: list[str],
|
||||
auth_method: str,
|
||||
api_key: ApiKey | None = None,
|
||||
auth_session: AuthSession | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
extra_roles: tuple[Role, ...] = (),
|
||||
authorization_context: UserAuthorizationContext | None = None,
|
||||
include_system_roles: bool = False,
|
||||
) -> PrincipalRef:
|
||||
if authorization_context is None:
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=include_system_roles,
|
||||
extra_roles=extra_roles,
|
||||
)
|
||||
function_assignment_ids = list(authorization_context.function_assignment_ids)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||
if include_system_roles:
|
||||
role_ids.extend(role.id for role in authorization_context.system_roles)
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=frozenset(group.id for group in authorization_context.groups),
|
||||
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
|
||||
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
|
||||
delegation_ids=frozenset(authorization_context.function_delegation_ids),
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
)
|
||||
|
||||
|
||||
def _api_principal_from_ref(
|
||||
session: Session,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
permission_evaluator: PermissionEvaluator | None = None,
|
||||
) -> ApiPrincipal:
|
||||
account = session.get(Account, principal.account_id)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||
if (
|
||||
not account
|
||||
or not user
|
||||
or not tenant
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API principal")
|
||||
|
||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||
return ApiPrincipal(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
auth_session=auth_session,
|
||||
permission_evaluator=permission_evaluator,
|
||||
)
|
||||
|
||||
|
||||
def _api_principal_from_context(
|
||||
context: ResolvedPrincipalContext,
|
||||
*,
|
||||
permission_evaluator: PermissionEvaluator | None = None,
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=context.principal,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
api_key=context.api_key,
|
||||
auth_session=context.auth_session,
|
||||
permission_evaluator=permission_evaluator,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_legacy_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> PrincipalRef:
|
||||
return _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
).principal
|
||||
|
||||
|
||||
def _resolve_legacy_principal_context(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> ResolvedPrincipalContext:
|
||||
token, source = _extract_token(request, authorization, x_api_key)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
|
||||
if source != "cookie":
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
context = _resolve_session_principal_context(
|
||||
request,
|
||||
session,
|
||||
token=token,
|
||||
source=source,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
|
||||
def _resolve_api_key_principal_ref(
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> PrincipalRef | None:
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
return context.principal if context is not None else None
|
||||
|
||||
|
||||
def _resolve_api_key_principal_context(
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
# API keys remain supported for CLI/automation. Their permissions are the
|
||||
# intersection of the key grant and the owner's current tenant roles.
|
||||
api_key = authenticate_api_key(session, token)
|
||||
if api_key is None:
|
||||
return None
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
or not user.is_active or not account.is_active or not tenant.is_active
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=api_key.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
api_key=api_key,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=api_key.tenant_id,
|
||||
scopes=effective_scopes,
|
||||
auth_method="api_key",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
)
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||
|
||||
|
||||
def _resolve_session_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
source: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> PrincipalRef | None:
|
||||
context = _resolve_session_principal_context(
|
||||
request,
|
||||
session,
|
||||
token=token,
|
||||
source=source,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
return context.principal if context is not None else None
|
||||
|
||||
|
||||
def _resolve_session_principal_context(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
source: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
if auth_session is None:
|
||||
return None
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
or not user.is_active or not account.is_active or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
|
||||
if source == "cookie":
|
||||
_verify_session_csrf(request, auth_session)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=user.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=True,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=user.tenant_id,
|
||||
scopes=scopes,
|
||||
auth_method="session",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=True,
|
||||
)
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||
|
||||
|
||||
def _verify_session_csrf(request: Request, auth_session: AuthSession) -> None:
|
||||
if not _requires_csrf(request):
|
||||
return
|
||||
header_token = request.headers.get("x-csrf-token")
|
||||
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
|
||||
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
|
||||
|
||||
|
||||
def _principal_idm_context(
|
||||
session: Session,
|
||||
*,
|
||||
user: User,
|
||||
account: Account,
|
||||
tenant_id: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
return idm_assignments, idm_roles
|
||||
|
||||
|
||||
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 _registry_from_request(request: Request) -> PlatformRegistry | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
return registry if isinstance(registry, PlatformRegistry) else None
|
||||
|
||||
|
||||
def _principal_resolver_from_request(request: Request) -> PrincipalResolver | None:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None:
|
||||
return None
|
||||
capability_name = (
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
if registry.has_capability(CAPABILITY_AUTH_PRINCIPAL_RESOLVER)
|
||||
else CAPABILITY_ACCESS_PRINCIPAL_RESOLVER
|
||||
)
|
||||
if not registry.has_capability(capability_name):
|
||||
return None
|
||||
capability = registry.require_capability(capability_name)
|
||||
if not isinstance(capability, PrincipalResolver):
|
||||
raise HTTPException(status_code=500, detail=f"Invalid capability: {capability_name}")
|
||||
return capability
|
||||
|
||||
|
||||
def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator | None:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None:
|
||||
return None
|
||||
capability_name = (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR
|
||||
if registry.has_capability(CAPABILITY_AUTH_PERMISSION_EVALUATOR)
|
||||
else CAPABILITY_ACCESS_PERMISSION_EVALUATOR
|
||||
)
|
||||
if not registry.has_capability(capability_name):
|
||||
return None
|
||||
capability = registry.require_capability(capability_name)
|
||||
if not isinstance(capability, PermissionEvaluator):
|
||||
raise HTTPException(status_code=500, detail=f"Invalid capability: {capability_name}")
|
||||
return capability
|
||||
|
||||
|
||||
class LegacyPrincipalResolver:
|
||||
def __init__(
|
||||
self,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._identity_directory = identity_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
if not isinstance(request, Request):
|
||||
raise TypeError("LegacyPrincipalResolver requires a FastAPI request")
|
||||
authorization = request.headers.get("authorization")
|
||||
x_api_key = request.headers.get("x-api-key")
|
||||
if isinstance(session, Session):
|
||||
return _resolve_legacy_principal_ref(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=self._idm_directory,
|
||||
identity_directory=self._identity_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
with get_database().session() as managed_session:
|
||||
return _resolve_legacy_principal_ref(
|
||||
request,
|
||||
managed_session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=self._idm_directory,
|
||||
identity_directory=self._identity_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
|
||||
|
||||
class LegacyPermissionEvaluator:
|
||||
def scopes_grant(self, scopes, required_scope: str) -> bool:
|
||||
return scopes_grant_compatible(scopes, required_scope)
|
||||
|
||||
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
|
||||
return self.scopes_grant(principal.scopes, required_scope)
|
||||
|
||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||
allowed = self.has_scope(principal, required_scope)
|
||||
return AccessDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else f"Missing scope: {required_scope}",
|
||||
requirements=(required_scope,),
|
||||
)
|
||||
|
||||
|
||||
def resolve_api_principal(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
x_api_key: str | None = None,
|
||||
) -> ApiPrincipal:
|
||||
resolver = _principal_resolver_from_request(request)
|
||||
permission_evaluator = _permission_evaluator_from_request(request)
|
||||
if isinstance(resolver, LegacyPrincipalResolver):
|
||||
context = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=resolver._idm_directory,
|
||||
identity_directory=resolver._identity_directory,
|
||||
organization_directory=resolver._organization_directory,
|
||||
)
|
||||
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
if resolver is not None:
|
||||
principal = resolver.resolve_request(request, session=session)
|
||||
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
context = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
|
||||
class AccessApiPrincipalProvider:
|
||||
def resolve_api_principal(
|
||||
self,
|
||||
request: object,
|
||||
session: object,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
x_api_key: str | None = None,
|
||||
) -> ApiPrincipal:
|
||||
if not isinstance(request, Request):
|
||||
raise TypeError("AccessApiPrincipalProvider requires a FastAPI request")
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("AccessApiPrincipalProvider requires a SQLAlchemy session")
|
||||
return resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
return resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None:
|
||||
mode = saved_maintenance_mode(session)
|
||||
if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(mode),
|
||||
)
|
||||
|
||||
|
||||
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
|
||||
return principal.has(required_scope)
|
||||
|
||||
|
||||
def require_scope(required_scope: str):
|
||||
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
|
||||
if not has_scope(principal, required_scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
|
||||
return principal
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_any_scope(*required_scopes: str):
|
||||
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
|
||||
if not any(has_scope(principal, required) for required in required_scopes):
|
||||
joined = ", ".join(required_scopes)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}")
|
||||
return principal
|
||||
|
||||
return dependency
|
||||
@@ -2,7 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, GroupMembership, Membership, Role, RoleBinding
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.permissions.evaluator import expand_scopes
|
||||
from govoplan_core.core.modules import PermissionDefinition, SubjectType
|
||||
|
||||
@@ -10,10 +19,10 @@ from govoplan_core.core.modules import PermissionDefinition, SubjectType
|
||||
def membership_group_ids(session: Session, *, tenant_id: str, membership_id: str) -> frozenset[str]:
|
||||
rows = (
|
||||
session.query(Group.id)
|
||||
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.filter(
|
||||
GroupMembership.tenant_id == tenant_id,
|
||||
GroupMembership.membership_id == membership_id,
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id == membership_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
@@ -28,22 +37,44 @@ def roles_for_subject(
|
||||
subject_id: str,
|
||||
tenant_id: str | None,
|
||||
) -> list[Role]:
|
||||
query = (
|
||||
session.query(Role)
|
||||
.join(RoleBinding, RoleBinding.role_id == Role.id)
|
||||
.filter(
|
||||
RoleBinding.subject_type == subject_type,
|
||||
RoleBinding.subject_id == subject_id,
|
||||
if tenant_id is None and subject_type == "account":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == subject_id, Role.tenant_id.is_(None))
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
)
|
||||
if tenant_id is None:
|
||||
query = query.filter(RoleBinding.tenant_id.is_(None), Role.tenant_id.is_(None))
|
||||
else:
|
||||
query = query.filter(RoleBinding.tenant_id == tenant_id, Role.tenant_id == tenant_id)
|
||||
return query.order_by(Role.name.asc()).all()
|
||||
return []
|
||||
if subject_type == "membership":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == subject_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
if subject_type == "group":
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
GroupRoleAssignment.tenant_id == tenant_id,
|
||||
GroupRoleAssignment.group_id == subject_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def membership_roles(session: Session, membership: Membership) -> list[Role]:
|
||||
def membership_roles(session: Session, membership: User) -> list[Role]:
|
||||
roles_by_id = {
|
||||
role.id: role
|
||||
for role in roles_for_subject(
|
||||
@@ -67,7 +98,7 @@ def principal_scopes(
|
||||
session: Session,
|
||||
*,
|
||||
account: Account,
|
||||
membership: Membership | None,
|
||||
membership: User | None,
|
||||
include_system: bool,
|
||||
catalog: dict[str, PermissionDefinition],
|
||||
) -> list[str]:
|
||||
|
||||
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,
|
||||
)
|
||||
376
src/govoplan_access/backend/configuration_provider.py
Normal file
376
src/govoplan_access/backend/configuration_provider.py
Normal file
@@ -0,0 +1,376 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import slugify
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationDiagnostic,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProvider,
|
||||
ConfigurationProviderDescription,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
|
||||
ACCESS_CONFIGURATION_CAPABILITY = "access.configuration"
|
||||
|
||||
|
||||
class SqlAccessConfigurationProvider(ConfigurationProvider):
|
||||
module_id = "access"
|
||||
|
||||
def describe(self) -> ConfigurationProviderDescription:
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("roles", "groups", "group_role_assignments"),
|
||||
schema_refs={
|
||||
"roles": "govoplan/access/configuration/roles.v1",
|
||||
"groups": "govoplan/access/configuration/groups.v1",
|
||||
"group_role_assignments": "govoplan/access/configuration/group-role-assignments.v1",
|
||||
},
|
||||
exported_scopes=("system", "tenant"),
|
||||
)
|
||||
|
||||
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
with get_database().session() as session:
|
||||
return _preflight_fragment(session, fragment, context)
|
||||
|
||||
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
del supplied_data
|
||||
with get_database().session() as session:
|
||||
result = _apply_fragment(session, fragment, context)
|
||||
if not any(item.severity == "blocker" for item in result.diagnostics):
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
|
||||
del context
|
||||
with get_database().session() as session:
|
||||
return _export_access_configuration(session, selection)
|
||||
|
||||
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
|
||||
del context
|
||||
if import_result.diagnostics:
|
||||
return tuple(item for item in import_result.diagnostics if item.severity == "blocker")
|
||||
return ()
|
||||
|
||||
|
||||
def _preflight_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _preflight_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _preflight_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _preflight_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _apply_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _apply_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _apply_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _apply_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _preflight_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Tenant role needs a tenant_id."))
|
||||
continue
|
||||
existing = _role_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} role {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
role = _role_by_slug(session, slug, tenant_id)
|
||||
target = updated if role else created
|
||||
if role is None:
|
||||
role = Role(tenant_id=tenant_id, slug=slug, name=_required(item, "name"), permissions=[])
|
||||
session.add(role)
|
||||
session.flush()
|
||||
role.name = str(item.get("name") or role.name).strip()
|
||||
role.description = _optional(item, "description")
|
||||
role.permissions = _string_list(item.get("permissions"))
|
||||
role.is_assignable = _bool(item.get("is_assignable"), default=True)
|
||||
role.system_required = _bool(item.get("required"), default=role.system_required)
|
||||
target[slug] = f"role:{role.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Group needs a tenant_id."))
|
||||
continue
|
||||
existing = _group_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} group {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
group = _group_by_slug(session, slug, tenant_id)
|
||||
target = updated if group else created
|
||||
if group is None:
|
||||
group = Group(tenant_id=tenant_id, slug=slug, name=_required(item, "name"))
|
||||
session.add(group)
|
||||
session.flush()
|
||||
group.name = str(item.get("name") or group.name).strip()
|
||||
group.description = _optional(item, "description")
|
||||
group.is_active = _bool(item.get("is_active"), default=True)
|
||||
group.system_required = _bool(item.get("required"), default=group.system_required)
|
||||
target[slug] = f"group:{group.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, f"{group_slug}:{role_slug}"))
|
||||
plan.append(_plan("blocked", fragment, f"{group_slug}:{role_slug}", "Group-role assignment needs a tenant_id."))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None or role is None:
|
||||
missing = ", ".join(ref for ref, exists in ((f"group:{group_slug}", group is not None), (f"role:{role_slug}", role is not None)) if not exists)
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="info",
|
||||
code="access_reference_pending",
|
||||
message=f"Access assignment references are not present yet and may be created by earlier package fragments: {missing}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=f"{group_slug}:{role_slug}",
|
||||
resolution="Keep role and group fragments before assignment fragments in the package.",
|
||||
))
|
||||
plan.append(_plan("bind", fragment, f"{group_slug}:{role_slug}", f"Bind {group_slug} to {role_slug} after referenced objects exist."))
|
||||
continue
|
||||
exists = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).count()
|
||||
plan.append(_plan("skip" if exists else "bind", fragment, f"{group_slug}:{role_slug}", f"{'Keep' if exists else 'Bind'} {group_slug} to {role_slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
object_ref = f"{group_slug}:{role_slug}"
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, object_ref))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"group:{group_slug}"))
|
||||
if role is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"role:{role_slug}"))
|
||||
if group is None or role is None:
|
||||
continue
|
||||
assignment = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).one_or_none()
|
||||
if assignment is None:
|
||||
assignment = GroupRoleAssignment(tenant_id=tenant_id, group_id=group.id, role_id=role.id)
|
||||
session.add(assignment)
|
||||
session.flush()
|
||||
created[object_ref] = f"group_role_assignment:{assignment.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created)
|
||||
|
||||
|
||||
def _export_access_configuration(session: Session, selection: ConfigurationExportSelection) -> ConfigurationExportResult:
|
||||
tenant_id = selection.tenant_id
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
fragments: list[ConfigurationPackageFragment] = []
|
||||
if tenant_id is None and "system" not in selection.scopes:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access configuration export needs selection.tenant_id unless exporting system scope.",
|
||||
module_id="access",
|
||||
resolution="Choose a tenant before exporting tenant roles and groups.",
|
||||
))
|
||||
return ConfigurationExportResult(diagnostics=tuple(diagnostics))
|
||||
|
||||
if tenant_id is not None:
|
||||
roles = session.query(Role).filter(Role.tenant_id == tenant_id).order_by(Role.slug.asc()).all()
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.slug.asc()).all()
|
||||
assignments = (
|
||||
session.query(GroupRoleAssignment, Group, Role)
|
||||
.join(Group, Group.id == GroupRoleAssignment.group_id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id)
|
||||
.order_by(Group.slug.asc(), Role.slug.asc())
|
||||
.all()
|
||||
)
|
||||
fragments.extend((
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role) for role in roles]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="groups", payload={"items": [_group_payload(group) for group in groups]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="group_role_assignments", payload={"items": [{"group": group.slug, "role": role.slug} for _assignment, group, role in assignments]}),
|
||||
))
|
||||
if "system" in selection.scopes:
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.slug.asc()).all()
|
||||
fragments.append(ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role, level="system") for role in system_roles]}))
|
||||
return ConfigurationExportResult(fragments=tuple(fragments), diagnostics=tuple(diagnostics))
|
||||
|
||||
|
||||
def _payload_items(fragment: ConfigurationPackageFragment) -> list[Mapping[str, Any]]:
|
||||
raw_items = fragment.payload.get("items")
|
||||
if raw_items is None:
|
||||
raw_items = fragment.payload.get(fragment.fragment_type)
|
||||
if raw_items is None and fragment.payload:
|
||||
raw_items = [fragment.payload]
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(f"Access configuration fragment {fragment.fragment_type!r} requires an items list.")
|
||||
return [item for item in raw_items if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _tenant_id(context: ConfigurationPreflightContext, item: Mapping[str, Any], *, level: str) -> str | None:
|
||||
if level == "system":
|
||||
return None
|
||||
value = item.get("tenant_id") or context.tenant_id
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
def _role_by_slug(session: Session, slug: str, tenant_id: str | None) -> Role | None:
|
||||
query = session.query(Role).filter(Role.slug == slug)
|
||||
query = query.filter(Role.tenant_id.is_(None)) if tenant_id is None else query.filter(Role.tenant_id == tenant_id)
|
||||
return query.one_or_none()
|
||||
|
||||
|
||||
def _group_by_slug(session: Session, slug: str, tenant_id: str) -> Group | None:
|
||||
return session.query(Group).filter(Group.tenant_id == tenant_id, Group.slug == slug).one_or_none()
|
||||
|
||||
|
||||
def _role_payload(role: Role, *, level: str = "tenant") -> dict[str, object]:
|
||||
return {
|
||||
"slug": role.slug,
|
||||
"name": role.name,
|
||||
"description": role.description,
|
||||
"permissions": list(role.permissions or []),
|
||||
"level": "system" if role.tenant_id is None else level,
|
||||
"is_assignable": role.is_assignable,
|
||||
"required": role.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _group_payload(group: Group) -> dict[str, object]:
|
||||
return {
|
||||
"slug": group.slug,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"is_active": group.is_active,
|
||||
"required": group.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _required(item: Mapping[str, Any], key: str) -> str:
|
||||
value = item.get(key)
|
||||
if value is None or not str(value).strip():
|
||||
raise ValueError(f"Access configuration item requires {key!r}.")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _optional(item: Mapping[str, Any], key: str) -> str | None:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Access configuration permissions must be a list.")
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: object, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _plan(action: str, fragment: ConfigurationPackageFragment, object_ref: str, summary: str) -> ConfigurationPlanItem:
|
||||
return ConfigurationPlanItem(action=action, module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=object_ref, summary=summary) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _tenant_required(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access tenant configuration requires a tenant_id.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Run the package for a selected tenant or set tenant_id on the fragment item.",
|
||||
)
|
||||
|
||||
|
||||
def _missing_ref(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="access_reference_missing",
|
||||
message=f"Access configuration references missing object {object_ref!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Create referenced groups and roles earlier in the package plan.",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_type_unsupported",
|
||||
message=f"Access configuration does not support fragment type {fragment.fragment_type!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
)
|
||||
@@ -1,21 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, MetaData
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from govoplan_core.db.base import Base as AccessBase
|
||||
from govoplan_core.db.base import NAMING_CONVENTION, TimestampMixin, utcnow
|
||||
|
||||
|
||||
class AccessBase(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
type_annotation_map = {
|
||||
dict[str, Any]: JSON,
|
||||
list[dict[str, Any]]: JSON,
|
||||
list[str]: JSON,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AccessBase", "NAMING_CONVENTION", "TimestampMixin", "utcnow"]
|
||||
|
||||
@@ -4,10 +4,11 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
@@ -15,11 +16,13 @@ def new_uuid() -> str:
|
||||
|
||||
|
||||
class Account(AccessBase, TimestampMixin):
|
||||
"""Global login identity shared by one or more tenant memberships."""
|
||||
|
||||
__tablename__ = "access_accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, unique=True, index=True)
|
||||
normalized_email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
@@ -27,72 +30,108 @@ class Account(AccessBase, TimestampMixin):
|
||||
password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
memberships: Mapped[list[Membership]] = relationship(back_populates="account", cascade="all, delete-orphan")
|
||||
memberships: Mapped[list[User]] = relationship(back_populates="account")
|
||||
identity_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
|
||||
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Tenant(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_tenants"
|
||||
class Identity(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
external_subject: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
memberships: Mapped[list[Membership]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
|
||||
account_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="identity", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Membership(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_memberships"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "account_id", name="uq_access_memberships_tenant_account"),)
|
||||
class IdentityAccountLink(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identity_account_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"account_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"identity_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
identity_id: Mapped[str] = mapped_column(ForeignKey("access_identities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
email_snapshot: Mapped[str | None] = mapped_column(String(320), index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
|
||||
identity: Mapped[Identity] = relationship(back_populates="account_links")
|
||||
account: Mapped[Account] = relationship(back_populates="identity_links")
|
||||
|
||||
|
||||
class User(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
|
||||
)
|
||||
|
||||
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)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(500))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
account: Mapped[Account] = relationship(back_populates="memberships")
|
||||
tenant: Mapped[Tenant] = relationship(back_populates="memberships")
|
||||
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Group(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_groups"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_access_groups_tenant_slug"),)
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
system_template_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class GroupMembership(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_group_memberships"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "membership_id", "group_id", name="uq_access_group_memberships_member_group"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
membership_id: Mapped[str] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class Role(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "slug", name="uq_access_roles_tenant_slug"),
|
||||
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
|
||||
Index(
|
||||
"uq_access_roles_system_slug",
|
||||
"uq_roles_system_slug",
|
||||
"slug",
|
||||
unique=True,
|
||||
sqlite_where=text("tenant_id IS NULL"),
|
||||
@@ -101,136 +140,228 @@ class Role(AccessBase, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
system_template_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class RoleBinding(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_role_bindings"
|
||||
class OrganizationUnit(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_organization_units"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),)
|
||||
|
||||
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)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("access_organization_units.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class Function(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_functions"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),)
|
||||
|
||||
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)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_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)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.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__ = (
|
||||
Index("ix_access_role_bindings_subject", "subject_type", "subject_id"),
|
||||
Index("ix_access_role_bindings_tenant_subject", "tenant_id", "subject_type", "subject_id"),
|
||||
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 | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
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)
|
||||
subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
subject_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
created_by_membership_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class PermissionCatalogEntry(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_permission_catalog"
|
||||
|
||||
scope: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
resource: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
level: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
is_deprecated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class RoleTemplateModel(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_role_templates"
|
||||
__table_args__ = (UniqueConstraint("module_id", "level", "slug", name="uq_access_role_templates_module_level_slug"),)
|
||||
class FunctionAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_function_assignments_account_scope",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False)
|
||||
managed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class ModuleInstallation(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_module_installations"
|
||||
|
||||
module_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(ForeignKey("access_identities.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
|
||||
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(ForeignKey("access_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionDelegation(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_delegations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"function_assignment_id",
|
||||
"delegate_account_id",
|
||||
"mode",
|
||||
name="uq_function_delegations_assignment_delegate_mode",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
function_assignment_id: Mapped[str] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegator_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegate_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String(30), default="delegate", nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class SystemRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_system_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
account: Mapped[Account] = relationship(back_populates="system_role_assignments")
|
||||
role: Mapped[Role] = relationship()
|
||||
|
||||
|
||||
class UserGroupMembership(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_user_group_memberships"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
|
||||
|
||||
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)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class UserRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_user_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_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)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class GroupRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_group_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_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)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ApiKey(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_api_keys"
|
||||
|
||||
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)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="api_keys")
|
||||
|
||||
|
||||
class AuthSession(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_auth_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
membership_id: Mapped[str | None] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100))
|
||||
|
||||
|
||||
class ApiKey(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_api_keys"
|
||||
__table_args__ = (Index("ix_access_api_keys_owner", "owner_subject_type", "owner_subject_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
owner_subject_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
prefix: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
user: Mapped[User] = relationship(back_populates="auth_sessions")
|
||||
account: Mapped[Account] = relationship(back_populates="auth_sessions")
|
||||
|
||||
|
||||
class TenantDatastore(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_tenant_datastores"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "module_id", name="uq_access_tenant_datastores_tenant_module"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
module_id: Mapped[str] = mapped_column(String(100), default="*", nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(20), default="shared", nullable=False)
|
||||
database_url_secret_ref: Mapped[str | None] = mapped_column(String(255))
|
||||
schema_name: Mapped[str | None] = mapped_column(String(100))
|
||||
storage_bucket: Mapped[str | None] = mapped_column(String(255))
|
||||
region: Mapped[str | None] = mapped_column(String(100))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class SystemSettings(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_system_settings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class TenantSettings(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_tenant_settings"
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), primary_key=True)
|
||||
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean)
|
||||
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean)
|
||||
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
__all__ = [
|
||||
"Account",
|
||||
"ApiKey",
|
||||
"AuthSession",
|
||||
"Function",
|
||||
"FunctionAssignment",
|
||||
"FunctionDelegation",
|
||||
"FunctionRoleAssignment",
|
||||
"ExternalFunctionRoleAssignment",
|
||||
"Group",
|
||||
"GroupRoleAssignment",
|
||||
"Identity",
|
||||
"IdentityAccountLink",
|
||||
"OrganizationUnit",
|
||||
"Role",
|
||||
"SystemRoleAssignment",
|
||||
"Tenant",
|
||||
"User",
|
||||
"UserGroupMembership",
|
||||
"UserRoleAssignment",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
467
src/govoplan_access/backend/directory.py
Normal file
467
src/govoplan_access/backend/directory.py
Normal file
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
AccessSemanticDirectory,
|
||||
AccessSubjectRef,
|
||||
AccountRef,
|
||||
FunctionAssignmentRef,
|
||||
FunctionRef,
|
||||
GroupRef,
|
||||
IdentityRef,
|
||||
OrganizationUnitRef,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory, IdentityRef as DirectoryIdentityRef
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import (
|
||||
ORGANIZATIONS_MODULE_ID,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef as DirectoryFunctionRef,
|
||||
OrganizationUnitRef as DirectoryOrganizationUnitRef,
|
||||
)
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
OrganizationUnit,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_access.backend.semantic import active_function_assignments_for_account
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
return "active" if active else "inactive"
|
||||
|
||||
|
||||
def _account_ref(account: Account) -> AccountRef:
|
||||
return AccountRef(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
status=_status(account.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _user_ref(user: User, account: Account | None = None) -> UserRef:
|
||||
return UserRef(
|
||||
id=user.id,
|
||||
account_id=user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
email=account.email if account else user.email,
|
||||
display_name=user.display_name or (account.display_name if account else None),
|
||||
status=_status(user.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _group_ref(group: Group) -> GroupRef:
|
||||
return GroupRef(
|
||||
id=group.id,
|
||||
tenant_id=group.tenant_id,
|
||||
name=group.name,
|
||||
status=_status(group.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink]) -> IdentityRef:
|
||||
primary_account_id = next((link.account_id for link in account_links if link.is_primary), None)
|
||||
return IdentityRef(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
primary_account_id=primary_account_id,
|
||||
account_ids=tuple(link.account_id for link in account_links),
|
||||
status=_status(identity.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_identity_ref(identity: DirectoryIdentityRef) -> IdentityRef:
|
||||
return IdentityRef(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
primary_account_id=identity.primary_account_id,
|
||||
account_ids=tuple(identity.account_ids),
|
||||
status=identity.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_organization_unit_ref(item: DirectoryOrganizationUnitRef) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
|
||||
return FunctionRef(
|
||||
id=function.id,
|
||||
tenant_id=function.tenant_id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
slug=function.slug,
|
||||
name=function.name,
|
||||
role_ids=tuple(role_ids),
|
||||
delegable=function.delegable,
|
||||
act_in_place_allowed=function.act_in_place_allowed,
|
||||
status=_status(function.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_function_ref(function: DirectoryFunctionRef, role_ids: Iterable[str]) -> FunctionRef:
|
||||
return FunctionRef(
|
||||
id=function.id,
|
||||
tenant_id=function.tenant_id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
slug=function.slug,
|
||||
name=function.name,
|
||||
role_ids=tuple(role_ids),
|
||||
delegable=function.delegable,
|
||||
act_in_place_allowed=function.act_in_place_allowed,
|
||||
status=function.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.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=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id: str) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id or account_id,
|
||||
identity_id=item.identity_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source, # type: ignore[arg-type]
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessSemanticDirectory):
|
||||
def __init__(
|
||||
self,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._identity_directory = identity_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRef | None:
|
||||
with get_database().session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
return _account_ref(account) if account is not None else None
|
||||
|
||||
def get_user(self, user_id: str) -> UserRef | None:
|
||||
with get_database().session() as session:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
account = session.get(Account, user.account_id)
|
||||
return _user_ref(user, account)
|
||||
|
||||
def get_users(self, user_ids: Iterable[str]) -> Mapping[str, UserRef]:
|
||||
ids = {str(user_id) for user_id in user_ids if user_id}
|
||||
if not ids:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
users = session.query(User).filter(User.id.in_(ids)).all()
|
||||
account_ids = {user.account_id for user in users}
|
||||
accounts = {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||
} if account_ids else {}
|
||||
return {user.id: _user_ref(user, accounts.get(user.account_id)) for user in users}
|
||||
|
||||
def users_for_tenant(self, tenant_id: str) -> tuple[UserRef, ...]:
|
||||
with get_database().session() as session:
|
||||
users = session.query(User).filter(User.tenant_id == tenant_id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
account_ids = {user.account_id for user in users}
|
||||
accounts = {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||
} if account_ids else {}
|
||||
return tuple(_user_ref(user, accounts.get(user.account_id)) for user in users)
|
||||
|
||||
def get_group(self, group_id: str) -> GroupRef | None:
|
||||
with get_database().session() as session:
|
||||
group = session.get(Group, group_id)
|
||||
return _group_ref(group) if group is not None else None
|
||||
|
||||
def get_groups(self, group_ids: Iterable[str]) -> Mapping[str, GroupRef]:
|
||||
ids = {str(group_id) for group_id in group_ids if group_id}
|
||||
if not ids:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
groups = session.query(Group).filter(Group.id.in_(ids)).all()
|
||||
return {group.id: _group_ref(group) for group in groups}
|
||||
|
||||
def groups_for_tenant(self, tenant_id: str) -> tuple[GroupRef, ...]:
|
||||
with get_database().session() as session:
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()
|
||||
return tuple(_group_ref(group) for group in groups)
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str) -> tuple[GroupRef, ...]:
|
||||
with get_database().session() as session:
|
||||
from govoplan_access.backend.db.models import UserGroupMembership
|
||||
|
||||
groups = (
|
||||
session.query(Group)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id == user_id,
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_group_ref(group) for group in groups)
|
||||
|
||||
def display_label(self, subject: AccessSubjectRef) -> str | None:
|
||||
if subject.kind == "identity":
|
||||
identity = self.get_identity(subject.id)
|
||||
return identity.display_name if identity else subject.label
|
||||
if subject.kind == "account":
|
||||
account = self.get_account(subject.id)
|
||||
return account.display_name or account.email if account else subject.label
|
||||
if subject.kind == "user":
|
||||
user = self.get_user(subject.id)
|
||||
return user.display_name or user.email if user else subject.label
|
||||
if subject.kind == "group":
|
||||
group = self.get_group(subject.id)
|
||||
return group.name if group else subject.label
|
||||
if subject.kind == "organization_unit":
|
||||
item = self.get_organization_unit(subject.id)
|
||||
return item.name if item else subject.label
|
||||
if subject.kind == "function":
|
||||
item = self.get_function(subject.id)
|
||||
return item.name if item else subject.label
|
||||
return subject.label or subject.id
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
if self._identity_directory is not None:
|
||||
identity = self._identity_directory.get_identity(identity_id)
|
||||
if identity is not None:
|
||||
return _directory_identity_ref(identity)
|
||||
with get_database().session() as session:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
return None
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity.id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return _identity_ref(identity, links)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
|
||||
if self._identity_directory is not None:
|
||||
links = tuple(self._identity_directory.accounts_for_identity(identity_id))
|
||||
if links:
|
||||
account_ids = [link.account_id for link in links]
|
||||
with get_database().session() as session:
|
||||
accounts = {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||
}
|
||||
return tuple(_account_ref(accounts[account_id]) for account_id in account_ids if account_id in accounts)
|
||||
with get_database().session() as session:
|
||||
accounts = (
|
||||
session.query(Account)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.account_id == Account.id)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_account_ref(account) for account in accounts)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
if self._organization_directory is not None:
|
||||
item = self._organization_directory.get_organization_unit(organization_unit_id)
|
||||
if item is not None:
|
||||
return _directory_organization_unit_ref(item)
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
return _organization_unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
if self._organization_directory is not None:
|
||||
items = tuple(self._organization_directory.organization_units_for_tenant(tenant_id))
|
||||
if items:
|
||||
return tuple(_directory_organization_unit_ref(item) for item in items)
|
||||
with get_database().session() as session:
|
||||
items = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_organization_unit_ref(item) for item in items)
|
||||
|
||||
def get_function(self, function_id: str) -> FunctionRef | None:
|
||||
if self._organization_directory is not None:
|
||||
function = self._organization_directory.get_function(function_id)
|
||||
if function is not None:
|
||||
return _directory_function_ref(function, self._external_function_role_ids(function.id, tenant_id=function.tenant_id))
|
||||
with get_database().session() as session:
|
||||
function = session.get(Function, function_id)
|
||||
if function is None:
|
||||
return None
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == function.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
]
|
||||
return _function_ref(function, role_ids)
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> tuple[FunctionRef, ...]:
|
||||
if self._organization_directory is not None:
|
||||
functions = tuple(
|
||||
self._organization_directory.functions_for_organization_unit(
|
||||
organization_unit_id,
|
||||
include_subunits=include_subunits,
|
||||
)
|
||||
)
|
||||
if functions:
|
||||
role_ids_by_function = self._external_function_role_ids_by_function(
|
||||
[item.id for item in functions],
|
||||
tenant_id=functions[0].tenant_id,
|
||||
)
|
||||
return tuple(
|
||||
_directory_function_ref(item, role_ids_by_function.get(item.id, ()))
|
||||
for item in functions
|
||||
)
|
||||
with get_database().session() as session:
|
||||
unit_ids = {organization_unit_id}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
while pending:
|
||||
parent_id = pending.pop()
|
||||
children = [
|
||||
row[0]
|
||||
for row in session.query(OrganizationUnit.id)
|
||||
.filter(OrganizationUnit.parent_id == parent_id)
|
||||
.all()
|
||||
]
|
||||
for child_id in children:
|
||||
if child_id not in unit_ids:
|
||||
unit_ids.add(child_id)
|
||||
pending.append(child_id)
|
||||
functions = (
|
||||
session.query(Function)
|
||||
.filter(Function.organization_unit_id.in_(unit_ids))
|
||||
.order_by(Function.name.asc())
|
||||
.all()
|
||||
)
|
||||
role_rows = (
|
||||
session.query(FunctionRoleAssignment.function_id, FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id.in_([item.id for item in functions]))
|
||||
.all()
|
||||
if functions else []
|
||||
)
|
||||
role_ids_by_function: dict[str, list[str]] = {}
|
||||
for function_id, role_id in role_rows:
|
||||
role_ids_by_function.setdefault(function_id, []).append(role_id)
|
||||
return tuple(_function_ref(item, role_ids_by_function.get(item.id, [])) for item in functions)
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(FunctionAssignment, assignment_id)
|
||||
return _function_assignment_ref(item) if item is not None else None
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> tuple[FunctionAssignmentRef, ...]:
|
||||
with get_database().session() as session:
|
||||
items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id)
|
||||
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())
|
||||
|
||||
def _external_function_role_ids(self, function_id: str, *, tenant_id: str) -> tuple[str, ...]:
|
||||
return self._external_function_role_ids_by_function([function_id], tenant_id=tenant_id).get(function_id, ())
|
||||
|
||||
def _external_function_role_ids_by_function(
|
||||
self,
|
||||
function_ids: Iterable[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
ids = sorted({str(function_id) for function_id in function_ids if function_id})
|
||||
if not ids:
|
||||
return {}
|
||||
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment
|
||||
|
||||
with get_database().session() as session:
|
||||
rows = (
|
||||
session.query(ExternalFunctionRoleAssignment.function_id, ExternalFunctionRoleAssignment.role_id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(ids),
|
||||
)
|
||||
.order_by(ExternalFunctionRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
result: dict[str, list[str]] = {}
|
||||
for function_id, role_id in rows:
|
||||
result.setdefault(function_id, []).append(role_id)
|
||||
return {function_id: tuple(role_ids) for function_id, role_ids in result.items()}
|
||||
650
src/govoplan_access/backend/explanation.py
Normal file
650
src/govoplan_access/backend/explanation.py
Normal file
@@ -0,0 +1,650 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import (
|
||||
active_function_assignments_for_account,
|
||||
active_function_delegations_for_account,
|
||||
identity_id_for_account,
|
||||
)
|
||||
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef, ResourceAccessExplanationProvider
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal[
|
||||
"direct_role",
|
||||
"group_role",
|
||||
"legacy_function_role",
|
||||
"idm_function_role",
|
||||
"system_role",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessRoleSourceExplanation:
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: tuple[str, ...]
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_type": self.source_type,
|
||||
"role_id": self.role_id,
|
||||
"role_slug": self.role_slug,
|
||||
"role_name": self.role_name,
|
||||
"permissions": list(self.permissions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"group_id": self.group_id,
|
||||
"group_name": self.group_name,
|
||||
"function_assignment_id": self.function_assignment_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"source_module": self.source_module,
|
||||
"assignment_source": self.assignment_source,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"delegation_id": self.delegation_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessScopeExplanation:
|
||||
scope: str
|
||||
sources: tuple[AccessRoleSourceExplanation, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"scope": self.scope, "sources": [source.to_dict() for source in self.sources]}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionFactExplanation:
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
function_name: str | None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: tuple[str, ...] = ()
|
||||
role_names: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_module": self.source_module,
|
||||
"assignment_id": self.assignment_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"assignment_source": self.assignment_source,
|
||||
"status": self.status,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
"role_ids": list(self.role_ids),
|
||||
"role_names": list(self.role_names),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserAccessExplanation:
|
||||
role_sources: tuple[AccessRoleSourceExplanation, ...] = ()
|
||||
scopes: tuple[AccessScopeExplanation, ...] = ()
|
||||
function_facts: tuple[FunctionFactExplanation, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"role_sources": [source.to_dict() for source in self.role_sources],
|
||||
"scopes": [scope.to_dict() for scope in self.scopes],
|
||||
"function_facts": [fact.to_dict() for fact in self.function_facts],
|
||||
}
|
||||
|
||||
|
||||
class SqlAccessExplanationService(AccessExplanationService):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (),
|
||||
) -> None:
|
||||
self._identity_directory = identity_directory
|
||||
self._idm_directory = idm_directory
|
||||
self._organization_directory = organization_directory
|
||||
self._resource_explanation_providers = tuple(resource_explanation_providers)
|
||||
|
||||
def explain_scope_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
return tuple(
|
||||
_scope_provenance(
|
||||
session,
|
||||
principal,
|
||||
required_scope,
|
||||
identity_directory=self._identity_directory,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
)
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
items = _scope_provenance(
|
||||
session,
|
||||
principal,
|
||||
action,
|
||||
identity_directory=self._identity_directory,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
for provider in self._resource_explanation_providers:
|
||||
provider_items = tuple(
|
||||
provider.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
)
|
||||
)
|
||||
if provider_items:
|
||||
items.extend(provider_items)
|
||||
break
|
||||
return tuple(_dedupe_provenance(items))
|
||||
|
||||
|
||||
def build_user_access_explanation(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
include_system: bool = False,
|
||||
) -> UserAccessExplanation:
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
role_sources.extend(_direct_role_sources(session, user))
|
||||
role_sources.extend(_group_role_sources(session, user))
|
||||
role_sources.extend(_legacy_function_role_sources(session, user))
|
||||
idm_sources, function_facts = _idm_function_role_sources(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
role_sources.extend(idm_sources)
|
||||
if include_system:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is not None:
|
||||
role_sources.extend(_system_role_sources(session, account))
|
||||
return UserAccessExplanation(
|
||||
role_sources=tuple(role_sources),
|
||||
scopes=_scope_explanations(role_sources),
|
||||
function_facts=tuple(function_facts),
|
||||
)
|
||||
|
||||
|
||||
def _scope_provenance(
|
||||
session: Session,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> list[AccessDecisionProvenance]:
|
||||
items: list[AccessDecisionProvenance] = []
|
||||
account = session.get(Account, principal.account_id)
|
||||
identity_id = principal.identity_id or identity_id_for_account(
|
||||
session,
|
||||
principal.account_id,
|
||||
identity_directory=identity_directory,
|
||||
)
|
||||
if identity_id:
|
||||
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="account",
|
||||
id=principal.account_id,
|
||||
label=(account.display_name or account.email) if account else None,
|
||||
source=principal.auth_method,
|
||||
)
|
||||
)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
if user is not None:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="tenant_membership",
|
||||
id=user.id,
|
||||
label=user.display_name or user.email,
|
||||
tenant_id=user.tenant_id,
|
||||
source="membership",
|
||||
)
|
||||
)
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
include_system=False,
|
||||
)
|
||||
_append_source_provenance(
|
||||
items,
|
||||
[source for source in explanation.role_sources if scopes_grant(source.permissions, required_scope)],
|
||||
)
|
||||
if account is not None:
|
||||
for source in _system_role_sources(session, account):
|
||||
if scopes_grant(source.permissions, required_scope):
|
||||
_append_source_provenance(items, [source])
|
||||
items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope))
|
||||
return _dedupe_provenance(items)
|
||||
|
||||
|
||||
def _direct_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="direct_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _group_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
rows = (
|
||||
session.query(Group, Role)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.group_id == Group.id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
UserGroupMembership.user_id == user.id,
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="group_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
)
|
||||
for group, role in rows
|
||||
]
|
||||
|
||||
|
||||
def _legacy_function_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
sources: list[AccessRoleSourceExplanation] = []
|
||||
direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
for assignment in direct_assignments:
|
||||
sources.extend(_legacy_function_assignment_sources(session, assignment, assignment_source="function_assignment"))
|
||||
|
||||
delegations = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
for delegation in delegations:
|
||||
assignment = session.get(FunctionAssignment, delegation.function_assignment_id)
|
||||
if assignment is None:
|
||||
continue
|
||||
sources.extend(
|
||||
_legacy_function_assignment_sources(
|
||||
session,
|
||||
assignment,
|
||||
assignment_source="delegated_function",
|
||||
delegation=delegation,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def _legacy_function_assignment_sources(
|
||||
session: Session,
|
||||
assignment: FunctionAssignment,
|
||||
*,
|
||||
assignment_source: str,
|
||||
delegation: FunctionDelegation | None = None,
|
||||
) -> list[AccessRoleSourceExplanation]:
|
||||
function = session.get(Function, assignment.function_id)
|
||||
if function is None:
|
||||
return []
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(FunctionRoleAssignment.tenant_id == assignment.tenant_id, FunctionRoleAssignment.function_id == function.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="legacy_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=function.id,
|
||||
function_name=function.name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
assignment_source=assignment_source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
delegation_id=delegation.id if delegation else None,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _idm_function_role_sources(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> tuple[list[AccessRoleSourceExplanation], list[FunctionFactExplanation]]:
|
||||
if idm_directory is None:
|
||||
return [], []
|
||||
assignments = [
|
||||
assignment
|
||||
for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
]
|
||||
if organization_directory is not None:
|
||||
assignments = [
|
||||
assignment
|
||||
for assignment in assignments
|
||||
if _organization_function_active(organization_directory, assignment)
|
||||
]
|
||||
if not assignments:
|
||||
return [], []
|
||||
function_ids = sorted({assignment.function_id for assignment in assignments})
|
||||
rows = (
|
||||
session.query(ExternalFunctionRoleAssignment, Role)
|
||||
.join(Role, ExternalFunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
mappings_by_function: dict[str, list[tuple[ExternalFunctionRoleAssignment, Role]]] = {}
|
||||
for mapping, role in rows:
|
||||
mappings_by_function.setdefault(mapping.function_id, []).append((mapping, role))
|
||||
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
function_facts: list[FunctionFactExplanation] = []
|
||||
for assignment in assignments:
|
||||
function_name, organization_unit_name = _organization_labels(organization_directory, assignment)
|
||||
mappings = mappings_by_function.get(assignment.function_id, [])
|
||||
function_facts.append(
|
||||
FunctionFactExplanation(
|
||||
source_module=ORGANIZATIONS_MODULE_ID,
|
||||
assignment_id=assignment.id,
|
||||
tenant_id=assignment.tenant_id,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
assignment_source=assignment.source,
|
||||
status=assignment.status,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
role_ids=tuple(role.id for _, role in mappings),
|
||||
role_names=tuple(role.name for _, role in mappings),
|
||||
)
|
||||
)
|
||||
for mapping, role in mappings:
|
||||
role_sources.append(
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="idm_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
source_module=mapping.source_module,
|
||||
assignment_source=assignment.source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
)
|
||||
return role_sources, function_facts
|
||||
|
||||
|
||||
def _organization_labels(
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if organization_directory is None:
|
||||
return None, None
|
||||
function = organization_directory.get_function(assignment.function_id)
|
||||
unit = organization_directory.get_organization_unit(assignment.organization_unit_id)
|
||||
return (function.name if function is not None else None, unit.name if unit is not None else None)
|
||||
|
||||
|
||||
def _organization_function_active(
|
||||
organization_directory: OrganizationDirectory,
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> bool:
|
||||
function = organization_directory.get_function(assignment.function_id)
|
||||
return function is not None and function.tenant_id == assignment.tenant_id and function.status == "active"
|
||||
|
||||
|
||||
def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="system_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _scope_explanations(role_sources: Iterable[AccessRoleSourceExplanation]) -> tuple[AccessScopeExplanation, ...]:
|
||||
sources_by_scope: dict[str, list[AccessRoleSourceExplanation]] = {}
|
||||
for source in role_sources:
|
||||
for scope in expand_scopes(source.permissions):
|
||||
sources_by_scope.setdefault(scope, []).append(source)
|
||||
return tuple(
|
||||
AccessScopeExplanation(scope=scope, sources=tuple(sources))
|
||||
for scope, sources in sorted(sources_by_scope.items(), key=lambda item: item[0])
|
||||
)
|
||||
|
||||
|
||||
def _append_source_provenance(
|
||||
items: list[AccessDecisionProvenance],
|
||||
sources: Iterable[AccessRoleSourceExplanation],
|
||||
) -> None:
|
||||
for source in sources:
|
||||
if source.group_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="group",
|
||||
id=source.group_id,
|
||||
label=source.group_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source="group_membership",
|
||||
)
|
||||
)
|
||||
if source.delegation_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="delegation",
|
||||
id=source.delegation_id,
|
||||
label=source.assignment_source,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"delegated_from_assignment_id": source.delegated_from_assignment_id,
|
||||
"acting_for_account_id": source.acting_for_account_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
if source.function_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="organization_unit",
|
||||
id=source.organization_unit_id,
|
||||
label=source.organization_unit_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={"applies_to_subunits": source.applies_to_subunits},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=source.function_assignment_id,
|
||||
label=source.function_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_id": source.function_id,
|
||||
"organization_unit_id": source.organization_unit_id,
|
||||
"identity_id": source.identity_id,
|
||||
"account_id": source.account_id,
|
||||
"source_module": source.source_module,
|
||||
"assignment_source": source.assignment_source,
|
||||
},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=source.role_id,
|
||||
label=source.role_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"role_slug": source.role_slug,
|
||||
"group_id": source.group_id,
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"function_id": source.function_id,
|
||||
"source_module": source.source_module,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_provenance(items: Iterable[AccessDecisionProvenance]) -> list[AccessDecisionProvenance]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
deduped: list[AccessDecisionProvenance] = []
|
||||
for item in items:
|
||||
key = (
|
||||
item.kind,
|
||||
item.id,
|
||||
item.tenant_id,
|
||||
item.source,
|
||||
tuple(sorted((str(key), str(value)) for key, value in item.details.items())),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
130
src/govoplan_access/backend/governance_materializer.py
Normal file
130
src/govoplan_access/backend/governance_materializer.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
|
||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if group is None:
|
||||
group = Group(
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Group, template.tenant_id, template.slug),
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
is_active=template.is_active,
|
||||
system_template_id=template.template_id,
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(group)
|
||||
else:
|
||||
group.name = template.name
|
||||
group.description = template.description
|
||||
group.system_required = template.required
|
||||
if template.required:
|
||||
group.is_active = template.is_active
|
||||
db.flush()
|
||||
return
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Role, template.tenant_id, template.slug),
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=False,
|
||||
is_assignable=template.is_active,
|
||||
system_template_id=template.template_id,
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(role)
|
||||
else:
|
||||
role.name = template.name
|
||||
role.description = template.description
|
||||
role.permissions = list(template.permissions)
|
||||
role.system_required = template.required
|
||||
if template.required:
|
||||
role.is_assignable = template.is_active
|
||||
db.flush()
|
||||
|
||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if group is None:
|
||||
return
|
||||
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
||||
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
||||
if membership_count or role_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
|
||||
)
|
||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||
db.delete(group)
|
||||
db.flush()
|
||||
return
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
return
|
||||
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
||||
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
||||
if user_count or group_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
|
||||
)
|
||||
db.delete(role)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access governance materializer requires a SQLAlchemy Session")
|
||||
return session
|
||||
|
||||
|
||||
def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, resource_id: str) -> None:
|
||||
registry = get_registry()
|
||||
if registry is None or not hasattr(registry, "delete_veto_providers"):
|
||||
return
|
||||
for provider in registry.delete_veto_providers(resource_type):
|
||||
try:
|
||||
provider(session, tenant_id, resource_id)
|
||||
except AdminConflictError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AdminConflictError(str(exc)) from exc
|
||||
|
||||
|
||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
@@ -1,9 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
ResourceAccessExplanationProvider,
|
||||
)
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||
@@ -24,9 +59,18 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"),
|
||||
_permission("access:tenant:create", "Create tenants", "Create tenant registry entries.", "Access", "system"),
|
||||
_permission("access:tenant:update", "Update tenants", "Update tenant metadata and activation state.", "Access", "system"),
|
||||
_permission("access:tenant:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "Access", "system"),
|
||||
_permission("access:account:read", "View accounts", "List and inspect global login accounts.", "Access", "system"),
|
||||
_permission("access:account:create", "Create accounts", "Create global login accounts.", "Access", "system"),
|
||||
_permission("access:account:update", "Update accounts", "Update or suspend global login accounts.", "Access", "system"),
|
||||
_permission("access:account:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "Access", "system"),
|
||||
_permission("access:system_role:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "Access", "system"),
|
||||
_permission("access:system_role:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "Access", "system"),
|
||||
_permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"),
|
||||
_permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"),
|
||||
_permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"),
|
||||
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"),
|
||||
@@ -36,11 +80,17 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"),
|
||||
_permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"),
|
||||
_permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"),
|
||||
_permission("access:function:read", "View functions", "Inspect organization-bound functions and assignments.", "Tenant access", "tenant"),
|
||||
_permission("access:function:write", "Manage functions", "Create and update organization units, functions, and role mappings.", "Tenant access", "tenant"),
|
||||
_permission("access:function:assign", "Assign functions", "Assign organization-bound functions to accounts.", "Tenant access", "tenant"),
|
||||
_permission("access:function:delegate", "Delegate functions", "Create and revoke permitted function delegations.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"),
|
||||
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
|
||||
_permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"),
|
||||
)
|
||||
@@ -63,18 +113,50 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:tenant:read",
|
||||
"access:tenant:create",
|
||||
"access:tenant:update",
|
||||
"access:tenant:suspend",
|
||||
"access:account:read",
|
||||
"access:account:create",
|
||||
"access:account:update",
|
||||
"access:account:suspend",
|
||||
"access:system_role:read",
|
||||
"access:system_role:write",
|
||||
"access:system_role:assign",
|
||||
"access:system_setting:read",
|
||||
"access:system_setting:write",
|
||||
"access:governance:read",
|
||||
"access:governance:write",
|
||||
),
|
||||
level="system",
|
||||
managed=True,
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tenant_owner",
|
||||
slug="system_auditor",
|
||||
name="System auditor",
|
||||
description="Read tenant registry, accounts, system roles, settings, governance, and cross-tenant audit records.",
|
||||
permissions=(
|
||||
"access:tenant:read",
|
||||
"access:account:read",
|
||||
"access:system_role:read",
|
||||
"access:audit:read",
|
||||
"access:system_setting:read",
|
||||
"access:governance:read",
|
||||
),
|
||||
level="system",
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="maintenance_operator",
|
||||
name="Maintenance operator",
|
||||
description="Access the system while maintenance mode is active.",
|
||||
permissions=("access:system_setting:read", "access:maintenance:access"),
|
||||
level="system",
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="owner",
|
||||
name="Tenant owner",
|
||||
description="Protected full tenant administration and module access.",
|
||||
permissions=("tenant:*",),
|
||||
@@ -82,6 +164,41 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
managed=True,
|
||||
protected=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tenant_admin",
|
||||
name="Tenant administrator",
|
||||
description="Manage tenant settings, policies, users, groups, roles and API keys.",
|
||||
permissions=(
|
||||
"access:membership:read",
|
||||
"access:membership:create",
|
||||
"access:membership:update",
|
||||
"access:group:read",
|
||||
"access:group:write",
|
||||
"access:group:manage_members",
|
||||
"access:role:read",
|
||||
"access:role:write",
|
||||
"access:role:assign",
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
"access:setting:read",
|
||||
"access:setting:write",
|
||||
"access:policy:read",
|
||||
"access:policy:write",
|
||||
),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="admin",
|
||||
name="Administrator (legacy)",
|
||||
description="Legacy broad tenant role retained for upgraded installations.",
|
||||
permissions=("tenant:*",),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="access_admin",
|
||||
name="Access administrator",
|
||||
@@ -95,6 +212,10 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:group:manage_members",
|
||||
"access:role:read",
|
||||
"access:role:assign",
|
||||
"access:function:read",
|
||||
"access:function:write",
|
||||
"access:function:assign",
|
||||
"access:function:delegate",
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
@@ -105,32 +226,436 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
ADMIN_READ_SCOPES = (
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"admin:api_keys:read",
|
||||
"admin:settings:read",
|
||||
"system:tenants:read",
|
||||
"system:accounts:read",
|
||||
"system:roles:read",
|
||||
"system:settings:read",
|
||||
"system:governance:read",
|
||||
"system:audit:read",
|
||||
"access:tenant:read",
|
||||
"access:account:read",
|
||||
"access:governance:read",
|
||||
"access:function:read",
|
||||
)
|
||||
|
||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationTopic(
|
||||
id="access.workflow.grant-user-access",
|
||||
title="Grant a person access",
|
||||
summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.",
|
||||
body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change.",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin"),
|
||||
order=30,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"access:membership:read",
|
||||
"access:group:read",
|
||||
"access:role:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
|
||||
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"outcome": "A person can sign in to the tenant and receives the intended access through groups and roles.",
|
||||
"prerequisites": [
|
||||
"You can open Admin.",
|
||||
"You may read users, groups, and roles.",
|
||||
"Write or assignment actions require matching management permissions.",
|
||||
],
|
||||
"steps": [
|
||||
"Open Admin and go to Users.",
|
||||
"Find the existing person or create a membership with their email address and display name.",
|
||||
"Review current groups and direct roles before changing anything.",
|
||||
"Add the person to the smallest group that grants the needed shared access.",
|
||||
"Assign direct roles only when a group does not match the case.",
|
||||
"Save and review any blocker message before asking a system or tenant owner for help.",
|
||||
],
|
||||
"result": "The membership has the intended effective permissions and no broader roles than necessary.",
|
||||
"verification": "Open the user again and compare groups, direct roles, and effective permissions with the request.",
|
||||
"related_field_ids": [
|
||||
"access.user.email",
|
||||
"access.user.display_name",
|
||||
"access.user.groups",
|
||||
"access.user.roles",
|
||||
],
|
||||
"related_topic_ids": [
|
||||
"access.reference.admin-access-fields",
|
||||
"docs.pattern.field-help",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.admin-access-fields",
|
||||
title="Access administration fields",
|
||||
summary="The access administration screens show tenant memberships, groups, roles, and API keys. Admin docs map the visible fields to API payloads and permission scopes.",
|
||||
body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions.",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=31,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"admin:api_keys:read",
|
||||
"access:membership:read",
|
||||
"access:group:read",
|
||||
"access:role:read",
|
||||
"access:api_key:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
|
||||
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/admin",
|
||||
"screen": "Admin",
|
||||
"section": "Users, groups, roles, and API keys",
|
||||
"fields": [
|
||||
{
|
||||
"field_id": "access.user.email",
|
||||
"label": "Email",
|
||||
"user_description": "The address used to identify the person when they sign in.",
|
||||
"admin_description": "Stored on the account and membership payloads. It must be normalized and unique for the relevant login account.",
|
||||
"api_path": "/api/v1/admin/users",
|
||||
"api_field": "email",
|
||||
"permission_scope": "access:membership:create",
|
||||
"validation": "Must be a valid email address.",
|
||||
"provenance": "Tenant membership creation or account lookup.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.display_name",
|
||||
"label": "Display name",
|
||||
"user_description": "The readable name shown in user lists and review screens.",
|
||||
"admin_description": "Maps to display_name on user and account responses where available.",
|
||||
"api_path": "/api/v1/admin/users",
|
||||
"api_field": "display_name",
|
||||
"permission_scope": "access:membership:update",
|
||||
"validation": "Human-readable text; keep it recognizable for administrators.",
|
||||
"provenance": "Tenant membership profile.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.groups",
|
||||
"label": "Groups",
|
||||
"user_description": "Shared access bundles that can add roles for many people at once.",
|
||||
"admin_description": "Maps to group_ids when updating a user or group membership.",
|
||||
"api_path": "/api/v1/admin/users/{user_id}",
|
||||
"api_field": "group_ids",
|
||||
"permission_scope": "access:group:manage_members",
|
||||
"validation": "Groups must belong to the same tenant.",
|
||||
"provenance": "User-group membership rows.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.roles",
|
||||
"label": "Roles",
|
||||
"user_description": "Direct access grants assigned to one person or inherited from groups.",
|
||||
"admin_description": "Maps to role_ids on user and group role update requests.",
|
||||
"api_path": "/api/v1/admin/users/{user_id}",
|
||||
"api_field": "role_ids",
|
||||
"permission_scope": "access:role:assign",
|
||||
"validation": "Roles must be assignable and cannot exceed the actor's delegation limit.",
|
||||
"provenance": "Direct user roles plus group role inheritance.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.api_key.scopes",
|
||||
"label": "Scopes",
|
||||
"user_description": "The actions an API key may perform.",
|
||||
"admin_description": "Maps to scopes when creating an API key and is intersected with the owner's current permissions.",
|
||||
"api_path": "/api/v1/admin/api-keys",
|
||||
"api_field": "scopes",
|
||||
"permission_scope": "access:api_key:create",
|
||||
"validation": "Use the narrowest scopes possible.",
|
||||
"provenance": "API key grant plus owner delegation.",
|
||||
},
|
||||
],
|
||||
"related_topic_ids": [
|
||||
"access.workflow.grant-user-access",
|
||||
"docs.pattern.field-help",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.external-function-role-mappings",
|
||||
title="Organization function facts and access roles",
|
||||
summary="Organizations defines function facts, IDM assigns them to identities, and Access maps accepted facts to roles and permissions.",
|
||||
body=(
|
||||
"Organizations owns the organization meta-model, concrete units, structures, and functions. IDM owns the fact that an identity, through one of its accounts, holds a function in an organization unit, including delegated and acting-for assignments. "
|
||||
"Access consumes those accepted IDM facts through the directory capability, validates function identifiers against Organizations, and turns them into rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
|
||||
"The assignment itself does not grant rights. Removing the IDM assignment, disabling the Organizations function, or removing the Access mapping stops the derived role source from contributing effective permissions. "
|
||||
"Tenant administrators inspect this from the user access explanation dialog: role sources link back to the Organizations function or unit that defines the fact and to the IDM assignment that produced it. "
|
||||
"The same explanation is exposed by the admin API, while mapping management remains under Admin > Function role mappings. This keeps the audit trail clear: Organizations records what can exist, IDM records who holds it, and Access records which accepted facts produce 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="Effective user access explanation API", href="/api/v1/admin/users/{user_id}/access-explanation", kind="api"),
|
||||
DocumentationLink(label="Organizations functions", href="/organizations?section=functions", kind="runtime"),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/admin",
|
||||
"api_path": "/api/v1/admin/external-function-role-mappings",
|
||||
"explanation_api_path": "/api/v1/admin/users/{user_id}/access-explanation",
|
||||
"runtime_routes": ["/admin?section=tenant-function-role-mappings", "/organizations?section=functions", "/idm"],
|
||||
"permission_scopes": ["access:function:write", "access:role:assign"],
|
||||
"responsibility_boundaries": {
|
||||
"organizations": "Defines organization units, structures, function types, and functions.",
|
||||
"idm": "Assigns organization functions to identities and accounts, including delegation and acting-for facts.",
|
||||
"access": "Maps accepted function facts to tenant roles and explains effective permissions.",
|
||||
},
|
||||
"related_topic_ids": [
|
||||
"idm.workflow.assign-function-to-identity",
|
||||
"docs.reference.organization-identity-idm-access-boundary",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _legacy_principal_resolver(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_core.auth.dependencies import LegacyPrincipalResolver
|
||||
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
|
||||
|
||||
return LegacyPrincipalResolver()
|
||||
return LegacyPrincipalResolver(
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
identity_directory=_optional_identity_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
)
|
||||
|
||||
|
||||
def _legacy_permission_evaluator(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_core.auth.dependencies import LegacyPermissionEvaluator
|
||||
from govoplan_access.backend.auth.dependencies import LegacyPermissionEvaluator
|
||||
|
||||
return LegacyPermissionEvaluator()
|
||||
|
||||
|
||||
def _api_principal_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.dependencies import AccessApiPrincipalProvider
|
||||
|
||||
return AccessApiPrincipalProvider()
|
||||
|
||||
|
||||
def _tenant_context_switcher(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
|
||||
return AccessTenantContextSwitcher()
|
||||
|
||||
|
||||
def _access_directory(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
|
||||
return SqlAccessDirectory(
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
identity_directory=_optional_identity_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
)
|
||||
|
||||
|
||||
def _access_semantic_directory(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
|
||||
return SqlAccessDirectory(
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
identity_directory=_optional_identity_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
)
|
||||
|
||||
|
||||
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
|
||||
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
return None
|
||||
capability = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
|
||||
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 _optional_organization_directory(context: ModuleContext) -> OrganizationDirectory | None:
|
||||
if not context.registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
return None
|
||||
capability = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _resource_explanation_providers(context: ModuleContext) -> tuple[ResourceAccessExplanationProvider, ...]:
|
||||
providers: list[ResourceAccessExplanationProvider] = []
|
||||
for capability_name in (CAPABILITY_FILES_ACCESS, CAPABILITY_CAMPAIGNS_ACCESS):
|
||||
if not context.registry.has_capability(capability_name):
|
||||
continue
|
||||
capability = context.registry.require_capability(capability_name)
|
||||
if isinstance(capability, ResourceAccessExplanationProvider):
|
||||
providers.append(capability)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def _access_explanation_service(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.explanation import SqlAccessExplanationService
|
||||
|
||||
return SqlAccessExplanationService(
|
||||
identity_directory=_optional_identity_directory(context),
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
resource_explanation_providers=_resource_explanation_providers(context),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_provisioner(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.tenancy.provisioning import LegacyTenantAccessProvisioner
|
||||
|
||||
return LegacyTenantAccessProvisioner()
|
||||
|
||||
|
||||
def _access_administration(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
|
||||
return SqlAccessAdministration()
|
||||
|
||||
|
||||
def _governance_materializer(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
|
||||
|
||||
return SqlAccessGovernanceMaterializer()
|
||||
|
||||
|
||||
def _configuration_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.configuration_provider import SqlAccessConfigurationProvider
|
||||
|
||||
return SqlAccessConfigurationProvider()
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
from fastapi import APIRouter
|
||||
|
||||
from govoplan_access.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(context)
|
||||
|
||||
from govoplan_access.backend.api.v1.auth import router as auth_router
|
||||
from govoplan_access.backend.api.v1.routes import router as access_admin_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth_router)
|
||||
router.include_router(access_admin_router)
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.4",
|
||||
version="0.1.8",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
|
||||
route_factory=_route_factory,
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="access",
|
||||
metadata=AccessBase.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
access_models.Account,
|
||||
access_models.Identity,
|
||||
access_models.IdentityAccountLink,
|
||||
access_models.User,
|
||||
access_models.Group,
|
||||
access_models.Role,
|
||||
access_models.OrganizationUnit,
|
||||
access_models.Function,
|
||||
access_models.FunctionRoleAssignment,
|
||||
access_models.ExternalFunctionRoleAssignment,
|
||||
access_models.FunctionAssignment,
|
||||
access_models.FunctionDelegation,
|
||||
access_models.SystemRoleAssignment,
|
||||
access_models.UserGroupMembership,
|
||||
access_models.UserRoleAssignment,
|
||||
access_models.GroupRoleAssignment,
|
||||
access_models.ApiKey,
|
||||
access_models.AuthSession,
|
||||
label="Access",
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
|
||||
frontend=FrontendModule(
|
||||
module_id="access",
|
||||
package_name="@govoplan/access-webui",
|
||||
routes=(FrontendRoute(path="/admin", component="AdminPage", 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_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_ACCESS_DIRECTORY: _access_directory,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory,
|
||||
CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
},
|
||||
documentation=ACCESS_DOCUMENTATION,
|
||||
)
|
||||
|
||||
|
||||
|
||||
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""access semantic directory
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: 3f4a5b6c7d8e
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "4a5b6c7d8e9f"
|
||||
down_revision = "3f4a5b6c7d8e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables() -> set[str]:
|
||||
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def _scope_fk_target() -> str:
|
||||
tables = _tables()
|
||||
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False, **kwargs) -> None:
|
||||
if table_name in _tables() and name not in _indexes(table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique, **kwargs)
|
||||
|
||||
|
||||
def _drop_index_if_exists(name: str, table_name: str) -> None:
|
||||
if table_name in _tables() and name in _indexes(table_name):
|
||||
op.drop_index(name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
tables = _tables()
|
||||
scope_fk_target = _scope_fk_target()
|
||||
|
||||
if "access_identities" not in tables:
|
||||
op.create_table(
|
||||
"access_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("external_subject", sa.String(length=255), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identities")),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identities_external_subject"), "access_identities", ["external_subject"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_identity_account_links" not in tables:
|
||||
op.create_table(
|
||||
"access_identity_account_links",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_identity_account_links_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_identity_account_links_identity_id_access_identities"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identity_account_links")),
|
||||
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_account_id"), "access_identity_account_links", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_identity_id"), "access_identity_account_links", ["identity_id"])
|
||||
_create_index_if_missing(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"access_identity_account_links",
|
||||
["account_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
_create_index_if_missing(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"access_identity_account_links",
|
||||
["identity_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
|
||||
tables = _tables()
|
||||
if "access_organization_units" not in tables:
|
||||
op.create_table(
|
||||
"access_organization_units",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("parent_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_organization_units_parent_id_access_organization_units"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_organization_units_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_organization_units")),
|
||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_parent_id"), "access_organization_units", ["parent_id"])
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_tenant_id"), "access_organization_units", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_functions" not in tables:
|
||||
op.create_table(
|
||||
"access_functions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("delegable", sa.Boolean(), nullable=False),
|
||||
sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_functions_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_functions_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_functions")),
|
||||
sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_functions_organization_unit_id"), "access_functions", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_functions_tenant_id"), "access_functions", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_role_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_function_id"), "access_function_role_assignments", ["function_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"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_external_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_external_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "source_module", "function_id", "role_id", name="uq_external_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_function_id"), "access_external_function_role_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_role_id"), "access_external_function_role_assignments", ["role_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_source_module"), "access_external_function_role_assignments", ["source_module"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_tenant_id"), "access_external_function_role_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["acting_for_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_acting_for_account_id_access_accounts"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegated_from_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_function_assignments_identity_id_access_identities"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_function_assignments_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "account_id", "function_id", "organization_unit_id", name="uq_function_assignments_account_scope"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_account_id"), "access_function_assignments", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_acting_for_account_id"), "access_function_assignments", ["acting_for_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_delegated_from_assignment_id"), "access_function_assignments", ["delegated_from_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_function_id"), "access_function_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_identity_id"), "access_function_assignments", ["identity_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_organization_unit_id"), "access_function_assignments", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_tenant_id"), "access_function_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_delegations" not in tables:
|
||||
op.create_table(
|
||||
"access_function_delegations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegator_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegate_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegate_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegate_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegator_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegator_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_delegations_function_assignment_id_access_function_assignments"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_delegations_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_delegations")),
|
||||
sa.UniqueConstraint("tenant_id", "function_assignment_id", "delegate_account_id", "mode", name="uq_function_delegations_assignment_delegate_mode"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegate_account_id"), "access_function_delegations", ["delegate_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegator_account_id"), "access_function_delegations", ["delegator_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_function_assignment_id"), "access_function_delegations", ["function_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_revoked_at"), "access_function_delegations", ["revoked_at"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_tenant_id"), "access_function_delegations", ["tenant_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table_name, indexes in (
|
||||
(
|
||||
"access_function_delegations",
|
||||
(
|
||||
op.f("ix_access_function_delegations_tenant_id"),
|
||||
op.f("ix_access_function_delegations_revoked_at"),
|
||||
op.f("ix_access_function_delegations_function_assignment_id"),
|
||||
op.f("ix_access_function_delegations_delegator_account_id"),
|
||||
op.f("ix_access_function_delegations_delegate_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_assignments",
|
||||
(
|
||||
op.f("ix_access_function_assignments_tenant_id"),
|
||||
op.f("ix_access_function_assignments_organization_unit_id"),
|
||||
op.f("ix_access_function_assignments_identity_id"),
|
||||
op.f("ix_access_function_assignments_function_id"),
|
||||
op.f("ix_access_function_assignments_delegated_from_assignment_id"),
|
||||
op.f("ix_access_function_assignments_acting_for_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",
|
||||
(
|
||||
op.f("ix_access_function_role_assignments_tenant_id"),
|
||||
op.f("ix_access_function_role_assignments_role_id"),
|
||||
op.f("ix_access_function_role_assignments_function_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_functions",
|
||||
(
|
||||
op.f("ix_access_functions_tenant_id"),
|
||||
op.f("ix_access_functions_organization_unit_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_organization_units",
|
||||
(
|
||||
op.f("ix_access_organization_units_tenant_id"),
|
||||
op.f("ix_access_organization_units_parent_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_identity_account_links",
|
||||
(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"uq_identity_account_links_primary_account",
|
||||
op.f("ix_access_identity_account_links_identity_id"),
|
||||
op.f("ix_access_identity_account_links_account_id"),
|
||||
),
|
||||
),
|
||||
("access_identities", (op.f("ix_access_identities_external_subject"),)),
|
||||
):
|
||||
for index_name in indexes:
|
||||
_drop_index_if_exists(index_name, table_name)
|
||||
|
||||
for table_name in (
|
||||
"access_function_delegations",
|
||||
"access_function_assignments",
|
||||
"access_function_role_assignments",
|
||||
"access_functions",
|
||||
"access_organization_units",
|
||||
"access_identity_account_links",
|
||||
"access_identities",
|
||||
):
|
||||
if table_name in _tables():
|
||||
op.drop_table(table_name)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""v0.1.7 access baseline
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: None
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '4a5b6c7d8e9f'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = '4f2a9c8e7b6d'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('access_identities',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('external_subject', sa.String(length=255), nullable=True),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identities'))
|
||||
)
|
||||
op.create_index(op.f('ix_access_identities_external_subject'), 'access_identities', ['external_subject'], unique=False)
|
||||
op.create_table('access_organization_units',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['access_organization_units.id'], name=op.f('fk_access_organization_units_parent_id_access_organization_units'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_organization_units_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_organization_units')),
|
||||
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organization_units_tenant_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_organization_units_parent_id'), 'access_organization_units', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_organization_units_tenant_id'), 'access_organization_units', ['tenant_id'], unique=False)
|
||||
op.create_table('access_external_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('source_module', sa.String(length=50), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_external_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_external_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_external_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'source_module', 'function_id', 'role_id', name='uq_external_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_function_id'), 'access_external_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_role_id'), 'access_external_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_source_module'), 'access_external_function_role_assignments', ['source_module'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_tenant_id'), 'access_external_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_functions',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('delegable', sa.Boolean(), nullable=False),
|
||||
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_functions_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_functions_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_functions')),
|
||||
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_functions_tenant_ou_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_functions_organization_unit_id'), 'access_functions', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_functions_tenant_id'), 'access_functions', ['tenant_id'], unique=False)
|
||||
op.create_table('access_identity_account_links',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('is_primary', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_identity_account_links_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_identity_account_links_identity_id_access_identities'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identity_account_links')),
|
||||
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_account_links_identity_account')
|
||||
)
|
||||
op.create_index(op.f('ix_access_identity_account_links_account_id'), 'access_identity_account_links', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_identity_account_links_identity_id'), 'access_identity_account_links', ['identity_id'], unique=False)
|
||||
op.create_index('uq_identity_account_links_primary_account', 'access_identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_index('uq_identity_account_links_primary_identity', 'access_identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_table('access_function_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['acting_for_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_acting_for_account_id_access_accounts'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_function_assignments_identity_id_access_identities'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_function_assignments_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'account_id', 'function_id', 'organization_unit_id', name='uq_function_assignments_account_scope')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_assignments_account_id'), 'access_function_assignments', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_acting_for_account_id'), 'access_function_assignments', ['acting_for_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_delegated_from_assignment_id'), 'access_function_assignments', ['delegated_from_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_function_id'), 'access_function_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_identity_id'), 'access_function_assignments', ['identity_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_organization_unit_id'), 'access_function_assignments', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_tenant_id'), 'access_function_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_role_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_id', 'role_id', name='uq_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_function_id'), 'access_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_role_id'), 'access_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_tenant_id'), 'access_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_delegations',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_assignment_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegator_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegate_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('mode', sa.String(length=30), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['delegate_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegate_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['delegator_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegator_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['function_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_delegations_function_assignment_id_access_function_assignments'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_delegations_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_delegations')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_assignment_id', 'delegate_account_id', 'mode', name='uq_function_delegations_assignment_delegate_mode')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegate_account_id'), 'access_function_delegations', ['delegate_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegator_account_id'), 'access_function_delegations', ['delegator_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_function_assignment_id'), 'access_function_delegations', ['function_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_revoked_at'), 'access_function_delegations', ['revoked_at'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_tenant_id'), 'access_function_delegations', ['tenant_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('access_function_delegations')
|
||||
op.drop_table('access_function_role_assignments')
|
||||
op.drop_table('access_function_assignments')
|
||||
op.drop_table('access_identity_account_links')
|
||||
op.drop_table('access_functions')
|
||||
op.drop_table('access_external_function_role_assignments')
|
||||
op.drop_table('access_organization_units')
|
||||
op.drop_table('access_identities')
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
227
src/govoplan_access/backend/permissions/catalog.py
Normal file
227
src/govoplan_access/backend/permissions/catalog.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
from govoplan_core.security.module_permissions import compatible_required_scopes
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
|
||||
parts = permission.scope.split(":", 2)
|
||||
if len(parts) == 2:
|
||||
module_id, action = parts
|
||||
resource = module_id
|
||||
else:
|
||||
module_id, resource, action = parts
|
||||
return PermissionDefinition(
|
||||
scope=permission.scope,
|
||||
label=permission.label,
|
||||
description=permission.description,
|
||||
category=permission.category,
|
||||
level=permission.level, # type: ignore[arg-type]
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
deprecated=True,
|
||||
)
|
||||
|
||||
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
|
||||
_legacy_permission(permission)
|
||||
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
)
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return value.strip().casefold()
|
||||
|
||||
|
||||
def permission_catalog(*, include_legacy: bool = True) -> tuple[PermissionDefinition, ...]:
|
||||
catalog: dict[str, PermissionDefinition] = {}
|
||||
for permission in _active_permission_definitions():
|
||||
catalog[permission.scope] = permission
|
||||
if include_legacy:
|
||||
for permission in LEGACY_PERMISSION_DEFINITIONS:
|
||||
catalog.setdefault(permission.scope, permission)
|
||||
return tuple(catalog.values())
|
||||
|
||||
|
||||
def permission_map(*, include_legacy: bool = True) -> dict[str, PermissionDefinition]:
|
||||
return {permission.scope: permission for permission in permission_catalog(include_legacy=include_legacy)}
|
||||
|
||||
|
||||
def role_templates() -> tuple[RoleTemplate, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "role_templates"):
|
||||
return tuple(registry.role_templates())
|
||||
from govoplan_access.backend.manifest import ACCESS_ROLE_TEMPLATES
|
||||
|
||||
return ACCESS_ROLE_TEMPLATES
|
||||
|
||||
|
||||
def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]:
|
||||
return tuple(template for template in role_templates() if template.level == level)
|
||||
|
||||
|
||||
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
if _scope_grants(granted, required, catalog=catalog):
|
||||
return True
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
||||
if scope_grants(alias, required, catalog=catalog):
|
||||
return True
|
||||
return any(
|
||||
_scope_grants(granted, alias, catalog=catalog)
|
||||
for alias in compatible_required_scopes(required)
|
||||
if alias != required
|
||||
)
|
||||
|
||||
|
||||
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
|
||||
|
||||
|
||||
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
|
||||
catalog = permission_map(include_legacy=True)
|
||||
raw = {str(scope) for scope in scopes if scope}
|
||||
expanded: set[str] = set()
|
||||
for scope in raw:
|
||||
if scope in {"*", "tenant:*", "system:*"} or scope.endswith(":*"):
|
||||
expanded.add(scope)
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
||||
expanded.add(alias)
|
||||
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
|
||||
expanded.update(matched)
|
||||
for alias in compatible_required_scopes(scope):
|
||||
if alias != scope:
|
||||
expanded.add(alias)
|
||||
if include_unknown and not matched:
|
||||
expanded.add(scope)
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
|
||||
candidates = _effective_permission_candidates(level=level)
|
||||
granted = list(scopes)
|
||||
catalog = permission_map(include_legacy=True)
|
||||
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
|
||||
|
||||
|
||||
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
|
||||
return len(effective_permission_scopes(scopes, level=level))
|
||||
|
||||
|
||||
def _effective_permission_candidates(*, level: PermissionLevel | None = None) -> set[str]:
|
||||
active_catalog = permission_map(include_legacy=False)
|
||||
full_catalog = permission_map(include_legacy=True)
|
||||
active_scopes = {
|
||||
scope
|
||||
for scope, definition in active_catalog.items()
|
||||
if level is None or definition.level == level
|
||||
}
|
||||
candidates = set(active_scopes)
|
||||
for scope, definition in full_catalog.items():
|
||||
if scope in active_catalog:
|
||||
continue
|
||||
if level is not None and definition.level != level:
|
||||
continue
|
||||
if any(
|
||||
scope_grants(active_scope, scope, catalog=full_catalog)
|
||||
or scope_grants(scope, active_scope, catalog=full_catalog)
|
||||
for active_scope in active_scopes
|
||||
):
|
||||
continue
|
||||
candidates.add(scope)
|
||||
return candidates
|
||||
|
||||
|
||||
def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> list[str]:
|
||||
normalized = {str(scope) for scope in scopes if scope}
|
||||
wildcard = "system:*" if level == "system" else "tenant:*"
|
||||
if "*" in normalized or wildcard in normalized:
|
||||
return [wildcard]
|
||||
|
||||
catalog = permission_map(include_legacy=True)
|
||||
expanded: set[str] = set()
|
||||
invalid: list[str] = []
|
||||
for scope in sorted(normalized):
|
||||
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
definition = catalog.get(alias)
|
||||
if definition is not None and definition.level == level:
|
||||
expanded.add(alias)
|
||||
continue
|
||||
if scope.endswith(":*"):
|
||||
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
|
||||
if matching:
|
||||
expanded.add(scope)
|
||||
continue
|
||||
definition = catalog.get(scope)
|
||||
if definition is not None and definition.level == level:
|
||||
expanded.add(scope)
|
||||
continue
|
||||
invalid.append(scope)
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported {level} permissions: {', '.join(invalid)}")
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
|
||||
return validate_permissions(scopes, level="tenant")
|
||||
|
||||
|
||||
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
|
||||
return validate_permissions(scopes, level="system")
|
||||
|
||||
|
||||
def delegateable_scopes(scopes: Iterable[str], *, level: PermissionLevel) -> set[str]:
|
||||
expanded = set(expand_scopes(scopes, include_unknown=False))
|
||||
wildcard = "system:*" if level == "system" else "tenant:*"
|
||||
if "*" in expanded or wildcard in expanded:
|
||||
return {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == level}
|
||||
return effective_permission_scopes(expanded, level=level)
|
||||
|
||||
|
||||
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
|
||||
return delegateable_scopes(scopes, level="tenant")
|
||||
|
||||
|
||||
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
|
||||
return delegateable_scopes(scopes, level="system")
|
||||
|
||||
|
||||
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
|
||||
user = list(user_scopes)
|
||||
key = list(key_scopes)
|
||||
catalog = permission_map(include_legacy=True)
|
||||
tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
|
||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
|
||||
user_raw = set(expand_scopes(user))
|
||||
key_raw = set(expand_scopes(key))
|
||||
allowed.update(
|
||||
scope
|
||||
for scope in user_raw.intersection(key_raw)
|
||||
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
|
||||
)
|
||||
return sorted(allowed)
|
||||
|
||||
|
||||
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "permissions"):
|
||||
return tuple(registry.permissions())
|
||||
from govoplan_access.backend.manifest import ACCESS_PERMISSIONS
|
||||
|
||||
return ACCESS_PERMISSIONS
|
||||
|
||||
|
||||
def _registry() -> object | None:
|
||||
from govoplan_access.backend.runtime import get_registry
|
||||
|
||||
return get_registry()
|
||||
5
src/govoplan_access/backend/runtime.py
Normal file
5
src/govoplan_access/backend/runtime.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.runtime import configure_runtime, get_registry
|
||||
|
||||
__all__ = ["configure_runtime", "get_registry"]
|
||||
2
src/govoplan_access/backend/security/__init__.py
Normal file
2
src/govoplan_access/backend/security/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned security helper services."""
|
||||
|
||||
81
src/govoplan_access/backend/security/api_keys.py
Normal file
81
src/govoplan_access/backend/security/api_keys.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import ApiKey, User
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
API_KEY_PREFIX_LENGTH = 12
|
||||
API_KEY_RANDOM_BYTES = 32
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreatedApiKey:
|
||||
model: ApiKey
|
||||
secret: str
|
||||
|
||||
|
||||
def hash_api_key(secret: str) -> str:
|
||||
return hash_secret(secret)
|
||||
|
||||
|
||||
def verify_api_key(secret: str, expected_hash: str) -> bool:
|
||||
return verify_secret(secret, expected_hash)
|
||||
|
||||
|
||||
def generate_api_key_secret() -> str:
|
||||
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
|
||||
|
||||
|
||||
def api_key_prefix(secret: str) -> str:
|
||||
# Prefix is only a lookup helper and must not be enough to authenticate.
|
||||
return secret[:API_KEY_PREFIX_LENGTH]
|
||||
|
||||
|
||||
def create_api_key(
|
||||
session: Session,
|
||||
*,
|
||||
user: User,
|
||||
name: str,
|
||||
scopes: list[str],
|
||||
secret: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> CreatedApiKey:
|
||||
secret = secret or generate_api_key_secret()
|
||||
model = ApiKey(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
prefix=api_key_prefix(secret),
|
||||
key_hash=hash_api_key(secret),
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
return CreatedApiKey(model=model, secret=secret)
|
||||
|
||||
|
||||
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
prefix = api_key_prefix(secret)
|
||||
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
|
||||
now = utc_now()
|
||||
for candidate in candidates:
|
||||
expires_at = ensure_aware_utc(candidate.expires_at)
|
||||
if expires_at and expires_at < now:
|
||||
continue
|
||||
if verify_api_key(secret, candidate.key_hash):
|
||||
candidate.last_used_at = now
|
||||
session.add(candidate)
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
scopes = set(api_key.scopes or [])
|
||||
return "*" in scopes or required_scope in scopes
|
||||
|
||||
40
src/govoplan_access/backend/security/passwords.py
Normal file
40
src/govoplan_access/backend/security/passwords.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
_ALGORITHM = "pbkdf2_sha256"
|
||||
_DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
|
||||
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return "$".join(
|
||||
[
|
||||
_ALGORITHM,
|
||||
str(iterations),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(digest).decode("ascii"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str | None) -> bool:
|
||||
if not encoded:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
|
||||
if algorithm != _ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_text)
|
||||
salt = base64.b64decode(salt_b64.encode("ascii"))
|
||||
expected = base64.b64decode(digest_b64.encode("ascii"))
|
||||
except Exception:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
284
src/govoplan_access/backend/security/sessions.py
Normal file
284
src/govoplan_access/backend/security/sessions.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
AuthSession,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
SESSION_RANDOM_BYTES = 32
|
||||
DEFAULT_SESSION_HOURS = 12
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreatedSession:
|
||||
model: AuthSession
|
||||
token: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserAuthorizationContext:
|
||||
tenant_roles: list[Role]
|
||||
system_roles: list[Role]
|
||||
groups: list[Group]
|
||||
function_assignment_ids: tuple[str, ...]
|
||||
function_delegation_ids: tuple[str, ...]
|
||||
scopes: list[str]
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
||||
|
||||
|
||||
def hash_session_token(token: str) -> str:
|
||||
return hash_secret(token)
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
return generate_secret("", random_bytes=SESSION_RANDOM_BYTES)
|
||||
|
||||
|
||||
def hash_csrf_token(token: str) -> str:
|
||||
return hash_secret(token)
|
||||
|
||||
|
||||
def verify_session_token(token: str, expected_hash: str) -> bool:
|
||||
return verify_secret(token, expected_hash)
|
||||
|
||||
|
||||
def verify_auth_session_csrf(auth_session: AuthSession, csrf_token: str | None) -> bool:
|
||||
if not csrf_token or not auth_session.csrf_token_hash:
|
||||
return False
|
||||
return verify_secret(csrf_token, auth_session.csrf_token_hash)
|
||||
|
||||
|
||||
def create_auth_session(
|
||||
session: Session,
|
||||
*,
|
||||
user: User,
|
||||
hours: int = DEFAULT_SESSION_HOURS,
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> CreatedSession:
|
||||
if not user.account_id:
|
||||
raise ValueError("Tenant membership has no global account.")
|
||||
token = generate_session_token()
|
||||
csrf_token = generate_csrf_token()
|
||||
now = utc_now()
|
||||
model = AuthSession(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
account_id=user.account_id,
|
||||
token_hash=hash_session_token(token),
|
||||
csrf_token_hash=hash_csrf_token(csrf_token),
|
||||
expires_at=now + timedelta(hours=hours),
|
||||
last_seen_at=now,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
user.last_login_at = now
|
||||
if user.account:
|
||||
user.account.last_login_at = now
|
||||
session.add(user.account)
|
||||
session.add(model)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
||||
|
||||
|
||||
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
||||
token_hash = hash_session_token(token)
|
||||
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
||||
if not model:
|
||||
return None
|
||||
now = utc_now()
|
||||
expires_at = ensure_aware_utc(model.expires_at)
|
||||
if expires_at is None or expires_at < now:
|
||||
return None
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
return model
|
||||
|
||||
|
||||
def revoke_auth_session(session: Session, token: str) -> bool:
|
||||
model = authenticate_session_token(session, token)
|
||||
if not model:
|
||||
return False
|
||||
model.revoked_at = utc_now()
|
||||
session.add(model)
|
||||
return True
|
||||
|
||||
|
||||
def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tenant_id: str) -> User:
|
||||
membership = (
|
||||
session.query(User)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == auth_session.account_id,
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if membership is None:
|
||||
raise LookupError("The account does not have an active membership in this tenant.")
|
||||
auth_session.tenant_id = membership.tenant_id
|
||||
auth_session.user_id = membership.id
|
||||
auth_session.last_seen_at = utc_now()
|
||||
session.add(auth_session)
|
||||
session.flush()
|
||||
return membership
|
||||
|
||||
|
||||
def collect_direct_user_roles(session: Session, user: User) -> list[Role]:
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
UserRoleAssignment.user_id == user.id,
|
||||
UserRoleAssignment.tenant_id == user.tenant_id,
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_user_roles(session: Session, user: User) -> list[Role]:
|
||||
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
||||
|
||||
group_roles = (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id == user.id,
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
Group.is_active.is_(True),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in collect_function_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
return list(roles_by_id.values())
|
||||
|
||||
|
||||
def collect_system_roles(session: Session, account: Account) -> list[Role]:
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_user_groups(session: Session, user: User) -> list[Group]:
|
||||
return (
|
||||
session.query(Group)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id == user.id,
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_user_authorization_context(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
account: Account | None = None,
|
||||
include_system: bool = True,
|
||||
extra_roles: Iterable[Role] = (),
|
||||
) -> UserAuthorizationContext:
|
||||
account = account or user.account
|
||||
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
||||
groups = collect_user_groups(session, user)
|
||||
group_ids = [group.id for group in groups]
|
||||
if group_ids:
|
||||
group_roles = (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
function_context = collect_function_authorization_context(session, user)
|
||||
for role in function_context.roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in extra_roles:
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
tenant_roles = list(roles_by_id.values())
|
||||
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
||||
scopes = {
|
||||
scope
|
||||
for role in [*tenant_roles, *system_roles]
|
||||
for scope in (role.permissions or [])
|
||||
}
|
||||
return UserAuthorizationContext(
|
||||
tenant_roles=tenant_roles,
|
||||
system_roles=system_roles,
|
||||
groups=groups,
|
||||
function_assignment_ids=function_context.assignment_ids,
|
||||
function_delegation_ids=function_context.delegation_ids,
|
||||
scopes=expand_scopes(scopes),
|
||||
)
|
||||
|
||||
|
||||
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
||||
scopes: set[str] = set()
|
||||
for role in collect_user_roles(session, user):
|
||||
scopes.update(role.permissions or [])
|
||||
if include_system and user.account:
|
||||
for role in collect_system_roles(session, user.account):
|
||||
scopes.update(role.permissions or [])
|
||||
return expand_scopes(scopes)
|
||||
|
||||
|
||||
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == account.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.order_by(Tenant.name.asc())
|
||||
.all()
|
||||
)
|
||||
272
src/govoplan_access/backend/semantic.py
Normal file
272
src/govoplan_access/backend/semantic.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FunctionAuthorizationContext:
|
||||
assignment_ids: tuple[str, ...]
|
||||
delegation_ids: tuple[str, ...]
|
||||
roles: tuple[Role, ...]
|
||||
|
||||
|
||||
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
||||
return (
|
||||
session.query(Identity)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.identity_id == Identity.id)
|
||||
.filter(
|
||||
IdentityAccountLink.account_id == account_id,
|
||||
IdentityAccountLink.is_primary.is_(True),
|
||||
Identity.is_active.is_(True),
|
||||
)
|
||||
.order_by(IdentityAccountLink.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def identity_id_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
) -> str | None:
|
||||
if identity_directory is not None:
|
||||
identity = identity_directory.identity_for_account(account_id)
|
||||
if identity is not None and identity.status == "active":
|
||||
return identity.id
|
||||
identity = primary_identity_for_account(session, account_id)
|
||||
return identity.id if identity is not None else None
|
||||
|
||||
|
||||
def ensure_identity_for_account(session: Session, account: Account, *, source: str = "local") -> Identity:
|
||||
identity = primary_identity_for_account(session, account.id)
|
||||
if identity is not None:
|
||||
return identity
|
||||
identity = Identity(display_name=account.display_name or account.email, source=source)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
session.add(IdentityAccountLink(identity_id=identity.id, account_id=account.id, is_primary=True, source=source))
|
||||
session.flush()
|
||||
return identity
|
||||
|
||||
|
||||
def active_function_assignments_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[FunctionAssignment]:
|
||||
now = utc_now()
|
||||
query = (
|
||||
session.query(FunctionAssignment)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.account_id == account_id,
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionAssignment.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionAssignment.tenant_id == tenant_id, Function.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def active_function_delegations_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
modes: Iterable[str] = ("delegate",),
|
||||
) -> list[FunctionDelegation]:
|
||||
now = utc_now()
|
||||
mode_set = sorted({str(mode) for mode in modes})
|
||||
if not mode_set:
|
||||
return []
|
||||
query = (
|
||||
session.query(FunctionDelegation)
|
||||
.join(FunctionAssignment, FunctionAssignment.id == FunctionDelegation.function_assignment_id)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionDelegation.delegate_account_id == account_id,
|
||||
FunctionDelegation.mode.in_(mode_set),
|
||||
FunctionDelegation.is_active.is_(True),
|
||||
FunctionDelegation.revoked_at.is_(None),
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
Function.delegable.is_(True),
|
||||
or_(FunctionDelegation.valid_from.is_(None), FunctionDelegation.valid_from <= now),
|
||||
or_(FunctionDelegation.valid_until.is_(None), FunctionDelegation.valid_until > now),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionDelegation.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionDelegation.tenant_id == tenant_id, FunctionAssignment.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def collect_function_assignment_ids(session: Session, user: User) -> list[str]:
|
||||
ids = [item.id for item in active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)]
|
||||
for delegation in active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id):
|
||||
ids.append(delegation.function_assignment_id)
|
||||
return sorted(dict.fromkeys(ids))
|
||||
|
||||
|
||||
def collect_function_delegation_ids(session: Session, user: User) -> list[str]:
|
||||
return [
|
||||
item.id
|
||||
for item in active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def collect_function_roles(session: Session, user: User) -> list[Role]:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegated = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
assignment_ids = {assignment.id for assignment in assignments}
|
||||
assignment_ids.update(delegation.function_assignment_id for delegation in delegated)
|
||||
if not assignment_ids:
|
||||
return []
|
||||
function_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(FunctionAssignment.tenant_id == user.tenant_id, FunctionAssignment.id.in_(assignment_ids))
|
||||
.all()
|
||||
]
|
||||
if not function_ids:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegations = active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
direct_assignment_ids = {assignment.id for assignment in assignments}
|
||||
delegated_role_assignment_ids = {
|
||||
delegation.function_assignment_id
|
||||
for delegation in delegations
|
||||
if delegation.mode == "delegate"
|
||||
}
|
||||
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
|
||||
function_ids = {assignment.function_id for assignment in assignments}
|
||||
|
||||
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
|
||||
if missing_role_assignment_ids:
|
||||
function_ids.update(
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.tenant_id == user.tenant_id,
|
||||
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
roles: tuple[Role, ...] = ()
|
||||
if function_ids:
|
||||
roles = tuple(
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return FunctionAuthorizationContext(
|
||||
assignment_ids=tuple(sorted(role_assignment_ids)),
|
||||
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
|
||||
def collect_external_function_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
assignments: Iterable[OrganizationFunctionAssignmentRef],
|
||||
*,
|
||||
source_module: str = ORGANIZATIONS_MODULE_ID,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> list[Role]:
|
||||
function_ids = sorted({
|
||||
assignment.function_id
|
||||
for assignment in assignments
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
})
|
||||
if organization_directory is not None and source_module == ORGANIZATIONS_MODULE_ID:
|
||||
function_ids = [
|
||||
function_id
|
||||
for function_id in function_ids
|
||||
if _organization_function_active(organization_directory, function_id, tenant_id=user.tenant_id)
|
||||
]
|
||||
if not function_ids:
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
def _organization_function_active(
|
||||
organization_directory: OrganizationDirectory,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> bool:
|
||||
function = organization_directory.get_function(function_id)
|
||||
return function is not None and function.tenant_id == tenant_id and function.status == "active"
|
||||
175
src/govoplan_access/backend/tenancy/provisioning.py
Normal file
175
src/govoplan_access/backend/tenancy/provisioning.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_access.backend.db.models import Account, SystemRoleAssignment, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.admin.common import AdminValidationError
|
||||
from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef
|
||||
|
||||
|
||||
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
|
||||
return ensure_default_roles(session, tenant) # type: ignore[arg-type]
|
||||
|
||||
def tenant_owner_candidates(self, session: object) -> tuple[TenantOwnerCandidateRef, ...]:
|
||||
db = _session(session)
|
||||
accounts = db.query(Account).filter(Account.is_active.is_(True)).order_by(Account.email.asc()).all()
|
||||
return tuple(
|
||||
TenantOwnerCandidateRef(account_id=account.id, email=account.email, display_name=account.display_name)
|
||||
for account in accounts
|
||||
)
|
||||
|
||||
def ensure_tenant_owner_membership(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
owner_account_id: str,
|
||||
) -> str:
|
||||
db = _session(session)
|
||||
tenant_id = getattr(tenant, "id", None)
|
||||
if not tenant_id:
|
||||
raise AdminValidationError("Tenant owner membership requires a persisted tenant.")
|
||||
|
||||
roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||
owner_role = roles.get("owner")
|
||||
if owner_role is None:
|
||||
raise AdminValidationError("Tenant owner role is not available.")
|
||||
|
||||
owner_account = db.get(Account, owner_account_id)
|
||||
if owner_account is None or not owner_account.is_active:
|
||||
raise AdminValidationError("The selected tenant owner account is missing or inactive.")
|
||||
|
||||
membership = (
|
||||
db.query(User)
|
||||
.filter(User.tenant_id == tenant_id, User.account_id == owner_account.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if membership is None:
|
||||
membership = User(
|
||||
tenant_id=tenant_id,
|
||||
account_id=owner_account.id,
|
||||
email=owner_account.email,
|
||||
display_name=owner_account.display_name,
|
||||
is_active=True,
|
||||
auth_provider=owner_account.auth_provider,
|
||||
password_hash=owner_account.password_hash,
|
||||
)
|
||||
db.add(membership)
|
||||
db.flush()
|
||||
|
||||
existing_assignment = (
|
||||
db.query(UserRoleAssignment)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == membership.id,
|
||||
UserRoleAssignment.role_id == owner_role.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing_assignment is None:
|
||||
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=membership.id, role_id=owner_role.id))
|
||||
db.flush()
|
||||
return membership.id
|
||||
|
||||
def ensure_development_admin(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
user_email: str,
|
||||
user_password: str,
|
||||
api_key_secret: str | None,
|
||||
scopes: Sequence[str],
|
||||
) -> DevelopmentBootstrapRef:
|
||||
db = _session(session)
|
||||
tenant_id = getattr(tenant, "id", None)
|
||||
if not tenant_id:
|
||||
raise AdminValidationError("Development bootstrap requires a persisted tenant.")
|
||||
|
||||
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||
system_roles = ensure_default_roles(db, None)
|
||||
account, _, _ = get_or_create_account(
|
||||
db,
|
||||
email=user_email,
|
||||
display_name="Development Admin",
|
||||
password=user_password,
|
||||
password_reset_required=False,
|
||||
)
|
||||
if not account.password_hash:
|
||||
account.password_hash = hash_password(user_password)
|
||||
account.is_active = True
|
||||
db.add(account)
|
||||
|
||||
user = db.query(User).filter(User.tenant_id == tenant_id, User.account_id == account.id).one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name="Development Admin",
|
||||
is_tenant_admin=True,
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
else:
|
||||
user.email = account.email
|
||||
user.password_hash = account.password_hash
|
||||
user.is_active = True
|
||||
db.add(user)
|
||||
|
||||
owner_role = tenant_roles["owner"]
|
||||
existing_assignment = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == user.id,
|
||||
UserRoleAssignment.role_id == owner_role.id,
|
||||
).one_or_none()
|
||||
if existing_assignment is None:
|
||||
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=user.id, role_id=owner_role.id))
|
||||
|
||||
system_owner = system_roles["system_owner"]
|
||||
existing_system_assignment = db.query(SystemRoleAssignment).filter(
|
||||
SystemRoleAssignment.account_id == account.id,
|
||||
SystemRoleAssignment.role_id == system_owner.id,
|
||||
).one_or_none()
|
||||
if existing_system_assignment is None:
|
||||
db.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
|
||||
|
||||
created_api_key = None
|
||||
if api_key_secret:
|
||||
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
|
||||
if not existing:
|
||||
api_key = create_api_key(
|
||||
db,
|
||||
user=user,
|
||||
name="Development API key",
|
||||
scopes=list(scopes),
|
||||
secret=api_key_secret,
|
||||
)
|
||||
created_api_key = CreatedApiKeyRef(id=api_key.model.id, secret=api_key.secret)
|
||||
|
||||
db.flush()
|
||||
return DevelopmentBootstrapRef(
|
||||
user=UserRef(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
tenant_id=tenant_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
status="active" if user.is_active else "inactive",
|
||||
),
|
||||
created_api_key=created_api_key,
|
||||
)
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Tenant access provisioner requires a SQLAlchemy Session")
|
||||
return session
|
||||
58
tests/test_admin_batch_helpers.py
Normal file
58
tests/test_admin_batch_helpers.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_user_id,
|
||||
_group_member_ids_by_group_id,
|
||||
_groups_by_user_id,
|
||||
_roles_by_group_id,
|
||||
_roles_by_user_id,
|
||||
_tenant_role_assignment_counts,
|
||||
)
|
||||
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AdminBatchHelperTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
|
||||
def test_access_admin_batch_maps_related_rows(self) -> None:
|
||||
session = self.Session()
|
||||
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
||||
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
||||
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
||||
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
|
||||
session.add_all([account, user, group, role])
|
||||
session.flush()
|
||||
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
|
||||
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
|
||||
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
|
||||
session.commit()
|
||||
|
||||
accounts_by_user = _accounts_by_user_id(session, [user.id])
|
||||
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
role_counts = _tenant_role_assignment_counts(session, [role.id])
|
||||
|
||||
self.assertEqual(accounts_by_user[user.id].id, account.id)
|
||||
self.assertEqual(group_member_ids, {group.id: [user.id]})
|
||||
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
|
||||
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
|
||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
89
tests/test_admin_pagination.py
Normal file
89
tests/test_admin_pagination.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import (
|
||||
_decode_full_delta_cursor,
|
||||
_encode_full_delta_cursor,
|
||||
_full_delta_page,
|
||||
_page_query,
|
||||
)
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows: list[int]) -> None:
|
||||
self._rows = rows
|
||||
self._offset = 0
|
||||
self._limit: int | None = None
|
||||
|
||||
def order_by(self, *_args: object) -> FakeQuery:
|
||||
return self
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def offset(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = value
|
||||
clone._limit = self._limit
|
||||
return clone
|
||||
|
||||
def limit(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = self._offset
|
||||
clone._limit = value
|
||||
return clone
|
||||
|
||||
def all(self) -> list[int]:
|
||||
end = None if self._limit is None else self._offset + self._limit
|
||||
return self._rows[self._offset:end]
|
||||
|
||||
|
||||
class AdminPaginationTests(unittest.TestCase):
|
||||
def test_page_query_returns_page_and_metadata(self) -> None:
|
||||
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
|
||||
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=2,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "full:users:3:42")
|
||||
self.assertTrue(has_more)
|
||||
|
||||
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=3,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [6])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "seq:42")
|
||||
self.assertFalse(has_more)
|
||||
|
||||
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
|
||||
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
|
||||
|
||||
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
|
||||
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
|
||||
with self.assertRaises(HTTPException):
|
||||
_decode_full_delta_cursor(cursor, scope="groups")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/test_auth_dependencies.py
Normal file
49
tests/test_auth_dependencies.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/",
|
||||
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthDependencyTests(unittest.TestCase):
|
||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||
|
||||
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
|
||||
|
||||
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
|
||||
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
|
||||
|
||||
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
|
||||
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
|
||||
|
||||
def test_requires_csrf_only_for_mutating_methods(self) -> None:
|
||||
self.assertFalse(_requires_csrf(request_for(method="GET")))
|
||||
self.assertTrue(_requires_csrf(request_for(method="POST")))
|
||||
|
||||
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
tests/test_optional_tenancy_contract.py
Normal file
59
tests/test_optional_tenancy_contract.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class OptionalTenancyContractTests(unittest.TestCase):
|
||||
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
|
||||
dependencies = tuple(project["dependencies"])
|
||||
|
||||
self.assertIn("govoplan-core>=0.1.8", dependencies)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||
|
||||
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
||||
manifest_path = ROOT / "src" / "govoplan_access" / "backend" / "manifest.py"
|
||||
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_call = next(
|
||||
node.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and any(isinstance(target, ast.Name) and target.id == "manifest" for target in node.targets)
|
||||
and isinstance(node.value, ast.Call)
|
||||
)
|
||||
optional_dependencies = next(
|
||||
keyword.value
|
||||
for keyword in manifest_call.keywords
|
||||
if keyword.arg == "optional_dependencies"
|
||||
)
|
||||
|
||||
self.assertIsInstance(optional_dependencies, ast.Tuple)
|
||||
self.assertIn(
|
||||
"tenancy",
|
||||
{
|
||||
item.value
|
||||
for item in optional_dependencies.elts
|
||||
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
||||
},
|
||||
)
|
||||
|
||||
def test_access_source_does_not_import_tenancy_module_internals(self) -> None:
|
||||
offenders: list[str] = []
|
||||
for path in (ROOT / "src" / "govoplan_access").rglob("*.py"):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if "govoplan_tenancy" in source:
|
||||
offenders.append(str(path.relative_to(ROOT)))
|
||||
|
||||
self.assertEqual([], offenders)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
tests/test_permission_catalog_contract.py
Normal file
29
tests/test_permission_catalog_contract.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.permissions import catalog as access_catalog
|
||||
from govoplan_core.security import permissions as core_permissions
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
class PermissionCatalogContractTests(unittest.TestCase):
|
||||
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
|
||||
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
|
||||
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
|
||||
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
|
||||
for alias in aliases:
|
||||
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
|
||||
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
|
||||
|
||||
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
|
||||
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
|
||||
self.assertIn("campaign:queue", scopes)
|
||||
self.assertIn("files:upload", scopes)
|
||||
self.assertIn("mail_servers:test", scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
30
webui/package.json
Normal file
30
webui/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
594
webui/src/api/admin.ts
Normal file
594
webui/src/api/admin.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
PrivacyRetentionLimitPermissionPatch,
|
||||
PrivacyRetentionLimitPermissions,
|
||||
PrivacyRetentionPolicy,
|
||||
PrivacyRetentionPolicyFieldKey,
|
||||
PrivacyRetentionPolicyPatch,
|
||||
PermissionItem,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
effective_permission_count: number;
|
||||
is_builtin: boolean;
|
||||
is_assignable: boolean;
|
||||
user_assignments: number;
|
||||
group_assignments: number;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type GroupSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
member_count: number;
|
||||
member_ids: string[];
|
||||
roles: RoleSummary[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type UserAdminItem = {
|
||||
id: string;
|
||||
account_id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
account_is_active: boolean;
|
||||
password_reset_required: boolean;
|
||||
last_login_at?: string | null;
|
||||
groups: GroupSummary[];
|
||||
roles: RoleSummary[];
|
||||
function_assignment_ids: string[];
|
||||
function_delegation_ids: string[];
|
||||
effective_scopes: string[];
|
||||
is_owner: boolean;
|
||||
is_last_active_owner: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AccessRoleSourceType = "direct_role" | "group_role" | "legacy_function_role" | "idm_function_role" | "system_role";
|
||||
|
||||
export type AccessRoleSourceItem = {
|
||||
source_type: AccessRoleSourceType;
|
||||
role_id: string;
|
||||
role_slug: string;
|
||||
role_name: string;
|
||||
permissions: string[];
|
||||
tenant_id?: string | null;
|
||||
group_id?: string | null;
|
||||
group_name?: string | null;
|
||||
function_assignment_id?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
source_module?: string | null;
|
||||
assignment_source?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
delegation_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
};
|
||||
|
||||
export type AccessScopeExplanationItem = {
|
||||
scope: string;
|
||||
sources: AccessRoleSourceItem[];
|
||||
};
|
||||
|
||||
export type AccessDecisionProvenanceItem = {
|
||||
kind: string;
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tenant_id?: string | null;
|
||||
source?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FunctionFactExplanationItem = {
|
||||
source_module: string;
|
||||
assignment_id: string;
|
||||
tenant_id: string;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
function_id: string;
|
||||
function_name?: string | null;
|
||||
organization_unit_id: string;
|
||||
organization_unit_name?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
assignment_source: string;
|
||||
status: string;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
role_ids: string[];
|
||||
role_names: string[];
|
||||
};
|
||||
|
||||
export type UserAccessExplanationResponse = {
|
||||
user: UserAdminItem;
|
||||
role_sources: AccessRoleSourceItem[];
|
||||
scopes: AccessScopeExplanationItem[];
|
||||
function_facts: FunctionFactExplanationItem[];
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationResponse = {
|
||||
user: UserAdminItem;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
provenance: AccessDecisionProvenanceItem[];
|
||||
};
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
|
||||
roles: RoleSummary[];
|
||||
last_login_at?: string | null;
|
||||
};
|
||||
|
||||
|
||||
export type SystemMembershipDraft = {
|
||||
tenant_id: string;
|
||||
is_active: boolean;
|
||||
role_ids: string[];
|
||||
group_ids: string[];
|
||||
is_owner?: boolean;
|
||||
is_last_active_owner?: boolean;
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||
available_languages?: LanguagePackage[];
|
||||
enabled_language_codes?: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type LanguagePackage = {
|
||||
code: string;
|
||||
label: string;
|
||||
native_label?: string | null;
|
||||
};
|
||||
|
||||
export type TenantSettingsItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
default_locale: string;
|
||||
available_languages: LanguagePackage[];
|
||||
system_enabled_language_codes: string[];
|
||||
enabled_language_codes: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TenantSettingsDeltaSections = Partial<{
|
||||
identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
|
||||
locale: Pick<TenantSettingsItem, "default_locale">;
|
||||
languages: Pick<TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes">;
|
||||
settings: Pick<TenantSettingsItem, "settings">["settings"];
|
||||
}>;
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
};
|
||||
|
||||
export type GovernanceTemplateItem = {
|
||||
id: string;
|
||||
kind: "group" | "role";
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
effective_permission_count: number;
|
||||
is_active: boolean;
|
||||
assignments: GovernanceAssignment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ApiKeyAdminItem = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
last_used_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
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;
|
||||
};
|
||||
|
||||
type DeltaResponseFields = {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
|
||||
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
|
||||
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
||||
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
|
||||
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
|
||||
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
|
||||
export type TenantSettingsDeltaResponse = {
|
||||
item?: TenantSettingsItem | null;
|
||||
sections: TenantSettingsDeltaSections;
|
||||
changed_sections: string[];
|
||||
} & DeltaResponseFields;
|
||||
|
||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||
return apiQuery(options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||
return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
owner_account_id?: string | null;
|
||||
description?: string | null;
|
||||
default_locale?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
}>): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
||||
}
|
||||
|
||||
export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantSettingsDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/tenant/settings/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||
return response.users;
|
||||
}
|
||||
|
||||
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/users/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createUser(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
password?: string | null;
|
||||
password_reset_required?: boolean;
|
||||
is_active?: boolean;
|
||||
group_ids?: string[];
|
||||
role_ids?: string[];
|
||||
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
|
||||
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
|
||||
display_name: string | null;
|
||||
is_active: boolean;
|
||||
group_ids: string[];
|
||||
role_ids: string[];
|
||||
}>): Promise<UserAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function fetchUserAccessExplanation(settings: ApiSettings, userId: string): Promise<UserAccessExplanationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}/access-explanation`);
|
||||
}
|
||||
|
||||
export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
|
||||
): Promise<ResourceAccessExplanationResponse> {
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action,
|
||||
tenant_id: options.tenantId
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
|
||||
}
|
||||
|
||||
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/groups/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createGroup(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
member_ids?: string[];
|
||||
role_ids?: string[];
|
||||
}): Promise<GroupSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
member_ids: string[];
|
||||
role_ids: string[];
|
||||
}>): Promise<GroupSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/roles/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createRole(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateRole(settings: ApiSettings, roleId: string, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
is_assignable: boolean;
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||
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[]> {
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createSystemRole(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
is_assignable: boolean;
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/accounts");
|
||||
}
|
||||
|
||||
export function fetchSystemAccountsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<SystemAccountListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
|
||||
display_name?: string | null;
|
||||
is_active?: boolean;
|
||||
role_ids?: string[];
|
||||
}): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ role_ids: roleIds })
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
||||
return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
|
||||
}
|
||||
|
||||
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
|
||||
include_revoked: includeRevoked ? true : undefined,
|
||||
since: options.since,
|
||||
limit: options.limit
|
||||
}));
|
||||
}
|
||||
|
||||
export function createApiKey(settings: ApiSettings, payload: {
|
||||
name: string;
|
||||
user_id?: string | null;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<ApiKeyAdminItem & { secret: string }> {
|
||||
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
password?: string | null;
|
||||
password_reset_required?: boolean;
|
||||
is_active?: boolean;
|
||||
role_ids?: string[];
|
||||
memberships?: SystemMembershipDraft[];
|
||||
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
|
||||
method: "PUT", body: JSON.stringify({ memberships })
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||
}
|
||||
|
||||
export type SystemSettingsUpdatePayload = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||
available_languages?: LanguagePackage[] | null;
|
||||
enabled_language_codes?: string[] | null;
|
||||
};
|
||||
|
||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
}
|
||||
|
||||
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
||||
}
|
||||
286
webui/src/features/admin/AdminPage.tsx
Normal file
286
webui/src/features/admin/AdminPage.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
AdminSectionContribution,
|
||||
AdminSectionsUiCapability,
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
AuthUpdate,
|
||||
FilesConnectorsUiCapability,
|
||||
MailProfilesUiCapability,
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
|
||||
type AdminSection = string;
|
||||
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
|
||||
const handledAdminSectionIds = new Set<string>([
|
||||
"overview",
|
||||
"system-settings",
|
||||
"system-configuration-changes",
|
||||
"system-configuration-packages",
|
||||
"system-modules",
|
||||
"system-tenants",
|
||||
"system-roles",
|
||||
"system-role-templates",
|
||||
"system-groups",
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"tenant-settings",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
"tenant-groups",
|
||||
"tenant-users",
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-api-keys",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-user-file-connectors",
|
||||
"tenant-user-mail-servers"
|
||||
]);
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[adminSectionCapabilities]
|
||||
);
|
||||
const contributionById = useMemo(() => {
|
||||
const mapped = new Map<string, AdminSectionContribution>();
|
||||
for (const section of contributedSections) {
|
||||
if (!mapped.has(section.id)) mapped.set(section.id, section);
|
||||
}
|
||||
return mapped;
|
||||
}, [contributedSections]);
|
||||
|
||||
const available = useMemo(() => {
|
||||
const sections = new Set<AdminSection>();
|
||||
for (const section of contributedSections) {
|
||||
if (canUseContributedSection(auth, section)) sections.add(section.id);
|
||||
}
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
|
||||
}
|
||||
if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) {
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
return sections;
|
||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : fallbackSection;
|
||||
|
||||
function selectSection(section: AdminSection) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (section === "overview") next.delete("section");
|
||||
else next.set("section", section);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSection && !available.has(requestedSection)) selectSection(fallbackSection);
|
||||
}, [requestedSection, available, fallbackSection]);
|
||||
|
||||
async function refreshAuth() {
|
||||
onAuthChange(await fetchShellAuth(settings));
|
||||
}
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
||||
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
{
|
||||
title: "ADMINISTRATION",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
...contributedNavItems(contributedSections, available, "ROOT"),
|
||||
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||
visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
|
||||
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
||||
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "GLOBAL",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
|
||||
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
|
||||
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
|
||||
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
|
||||
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
||||
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
||||
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
||||
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
||||
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "TENANT",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
|
||||
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
|
||||
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
|
||||
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
|
||||
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
||||
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
|
||||
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
|
||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "GROUP",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "USER",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
}
|
||||
].filter((group) => group.items.length > 0);
|
||||
const contributedSection = contributionById.get(active);
|
||||
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
{contributedSection && contributedSection.render(contributionContext)}
|
||||
{!contributedSection && active === "system-mail-servers" && (
|
||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||
)}
|
||||
{!contributedSection && active === "system-tenants" && (
|
||||
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
|
||||
)}
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<SystemUsersPanel
|
||||
settings={settings}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
|
||||
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} 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-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-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-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribution): boolean {
|
||||
if (section.allOf?.length && !section.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (section.anyOf?.length && !hasAnyScope(auth, section.anyOf)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function contributedNavItems(
|
||||
sections: AdminSectionContribution[],
|
||||
available: ReadonlySet<string>,
|
||||
group: string,
|
||||
excludedIds: ReadonlySet<string> = new Set()
|
||||
): OrderedAdminNavItem[] {
|
||||
return sections
|
||||
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
|
||||
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
|
||||
}
|
||||
|
||||
function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null {
|
||||
return available.has(id) ? { id, label, order } : null;
|
||||
}
|
||||
|
||||
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
|
||||
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function asSubnavItems(items: OrderedAdminNavItem[]) {
|
||||
return items.map(({ id, label }) => ({ id, label }));
|
||||
}
|
||||
195
webui/src/features/admin/ApiKeysPanel.tsx
Normal file
195
webui/src/features/admin/ApiKeysPanel.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } 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 { DateTimeField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
function defaultDraft(userId: string) {
|
||||
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
|
||||
}
|
||||
|
||||
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canRevoke: boolean;}) {
|
||||
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const keysRef = useRef<ApiKeyAdminItem[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id)));
|
||||
const [secret, setSecret] = useState<{name: string;value: string;} | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = creating && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeCreate
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const keyScope = showRevoked ? "all" : "active";
|
||||
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([
|
||||
loadDeltaRows(
|
||||
keysRef.current,
|
||||
`access:api-keys:${keyScope}`,
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchApiKeysDelta(settings, showRevoked, { since }),
|
||||
(response) => response.api_keys,
|
||||
(key) => key.id,
|
||||
"access_api_key",
|
||||
sortApiKeys
|
||||
),
|
||||
loadDeltaRows(
|
||||
usersRef.current,
|
||||
"access:api-keys:users",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchUsersDelta(settings, { since }),
|
||||
(response) => response.users,
|
||||
(user) => user.id,
|
||||
"access_user",
|
||||
sortUsers
|
||||
),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
keysRef.current = nextKeys;
|
||||
usersRef.current = nextUsers;
|
||||
setKeys(nextKeys);
|
||||
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
keysRef.current = [];
|
||||
usersRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked, resetDeltaWatermark]);
|
||||
|
||||
const selectedUser = users.find((user) => user.id === draft.userId);
|
||||
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
|
||||
|
||||
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
|
||||
{ id: "name", header: "i18n:govoplan-access.name.709a2322", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}…</div></div> },
|
||||
{ id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
|
||||
{ id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
||||
</div> }],
|
||||
[canRevoke]);
|
||||
|
||||
function openCreate() {
|
||||
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
|
||||
const nextDraft = defaultDraft(defaultUser?.id || "");
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setCreating(true);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
setCreating(false);
|
||||
const nextDraft = defaultDraft(auth.user.id);
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
|
||||
setSecret({ name: created.name, value: created.secret });
|
||||
setCreating(false);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revoking) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeApiKey(settings, revoking.id);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
|
||||
setRevoking(null);
|
||||
await load();
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.owner.89ff3122"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}…</dd></div>
|
||||
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
||||
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: ReturnType<typeof defaultDraft>): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortApiKeys(left: ApiKeyAdminItem, right: ApiKeyAdminItem): number {
|
||||
return left.name.localeCompare(right.name) || left.prefix.localeCompare(right.prefix);
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
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);
|
||||
}
|
||||
112
webui/src/features/admin/FileConnectorsPanel.tsx
Normal file
112
webui/src/features/admin/FileConnectorsPanel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, Card, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<FileConnectorScope, "user" | "group">;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel: string;panelTitle: string;}> = {
|
||||
user: {
|
||||
title: "i18n:govoplan-access.user_file_connections.996444a0",
|
||||
description: "i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2",
|
||||
targetLabel: "i18n:govoplan-access.user.9f8a2389",
|
||||
panelTitle: "i18n:govoplan-access.user_file_connections.996444a0"
|
||||
},
|
||||
group: {
|
||||
title: "i18n:govoplan-access.group_file_connections.73985bda",
|
||||
description: "i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc",
|
||||
targetLabel: "i18n:govoplan-access.group.171a0606",
|
||||
panelTitle: "i18n:govoplan-access.group_file_connections.73985bda"
|
||||
}
|
||||
};
|
||||
|
||||
export default function FileConnectorsPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const [targets, setTargets] = useState<FileConnectorTargetOption[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(FileConnectorScopeManager));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!FileConnectorScopeManager) {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, FileConnectorScopeManager, resetDeltaWatermark]);
|
||||
|
||||
async function loadTargets() {
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await loadDeltaRows(usersRef.current, "access:file-connector-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
|
||||
usersRef.current = users;
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await loadDeltaRows(groupsRef.current, "access:file-connector-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
|
||||
groupsRef.current = groups;
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
if (!FileConnectorScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="i18n:govoplan-access.files_module_unavailable.0ee90db1">
|
||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153</p>
|
||||
</Card>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
|
||||
<FileConnectorScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
title={labels.panelTitle}
|
||||
canWrite={canWrite} />
|
||||
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
192
webui/src/features/admin/GroupsPanel.tsx
Normal file
192
webui/src/features/admin/GroupsPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } 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 { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||
|
||||
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<GroupSummary | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextGroups, nextUsers, nextRoles] = await Promise.all([
|
||||
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
|
||||
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
|
||||
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
|
||||
);
|
||||
groupsRef.current = nextGroups;
|
||||
usersRef.current = nextUsers;
|
||||
rolesRef.current = nextRoles;
|
||||
setGroups(nextGroups);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
groupsRef.current = [];
|
||||
usersRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
|
||||
function openEdit(group: GroupSummary) {
|
||||
const nextDraft = { slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(group);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
const managed = Boolean(editing.system_template_id);
|
||||
await updateGroup(settings, editing.id, {
|
||||
...(canDefine && !managed ? { name: draft.name, description: draft.description || null } : {}),
|
||||
...(canDefine && !editing.system_required ? { is_active: draft.isActive } : {}),
|
||||
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
|
||||
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
|
||||
});
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateGroup(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_deactivated.8512bf9c", { value0: deactivating.name }));
|
||||
setDeactivating(null);
|
||||
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
||||
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
||||
</div> }],
|
||||
[canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" 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_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.group.171a0606</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.management.63cecca6</dt><dd>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.members.1cb449c1</dt><dd>{viewing.member_count}</dd></div><div><dt>i18n:govoplan-access.roles.47dcc27d</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
139
webui/src/features/admin/MailProfilesPanel.tsx
Normal file
139
webui/src/features/admin/MailProfilesPanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
canWriteProfiles: boolean;
|
||||
canManageCredentials: boolean;
|
||||
canWritePolicy: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;profileTitle: string;policyTitle: string;}> = {
|
||||
system: {
|
||||
title: "i18n:govoplan-access.system_mail_profiles.5af3eb64",
|
||||
description: "i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7",
|
||||
profileTitle: "i18n:govoplan-access.system_profiles.98adae54",
|
||||
policyTitle: "i18n:govoplan-access.system_mail_profile_policy.7edd7fcf"
|
||||
},
|
||||
tenant: {
|
||||
title: "i18n:govoplan-access.tenant_mail_profiles.5132a623",
|
||||
description: "i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f",
|
||||
profileTitle: "i18n:govoplan-access.tenant_profiles.4d7281ce",
|
||||
policyTitle: "i18n:govoplan-access.tenant_mail_profile_policy.239298a1"
|
||||
},
|
||||
user: {
|
||||
title: "i18n:govoplan-access.user_mail_profiles.f54a845a",
|
||||
description: "i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf",
|
||||
targetLabel: "i18n:govoplan-access.user.9f8a2389",
|
||||
profileTitle: "i18n:govoplan-access.user_profiles.57730285",
|
||||
policyTitle: "i18n:govoplan-access.user_mail_profile_policy.529e035b"
|
||||
},
|
||||
group: {
|
||||
title: "i18n:govoplan-access.group_mail_profiles.ebf1b5ba",
|
||||
description: "i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66",
|
||||
targetLabel: "i18n:govoplan-access.group.171a0606",
|
||||
profileTitle: "i18n:govoplan-access.group_profiles.74568838",
|
||||
policyTitle: "i18n:govoplan-access.group_mail_profile_policy.d98ef5a2"
|
||||
}
|
||||
};
|
||||
|
||||
export default function MailProfilesPanel({ settings, scopeType, canWriteProfiles, canManageCredentials, canWritePolicy }: Props) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!MailProfileScopeManager) {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager, resetDeltaWatermark]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await loadDeltaRows(usersRef.current, "access:mail-profile-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
|
||||
usersRef.current = users;
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await loadDeltaRows(groupsRef.current, "access:mail-profile-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
|
||||
groupsRef.current = groups;
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
if (!MailProfileScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
|
||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
|
||||
</Card>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
profileTitle={labels.profileTitle}
|
||||
policyTitle={labels.policyTitle}
|
||||
canWriteProfiles={canWriteProfiles}
|
||||
canManageCredentials={canManageCredentials}
|
||||
canWritePolicy={canWritePolicy} />
|
||||
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
169
webui/src/features/admin/RolesPanel.tsx
Normal file
169
webui/src/features/admin/RolesPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, 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 { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
||||
|
||||
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, nextPermissions] = await Promise.all([
|
||||
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles),
|
||||
fetchPermissionCatalog(settings)]
|
||||
);
|
||||
rolesRef.current = nextRoles;
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
||||
|
||||
const permissionGroups = useMemo(() => {
|
||||
const groups = new Map<string, PermissionItem[]>();
|
||||
for (const permission of permissions) groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]);
|
||||
return Array.from(groups.entries());
|
||||
}, [permissions]);
|
||||
|
||||
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
|
||||
function openEdit(role: RoleSummary) {
|
||||
if (role.is_builtin || role.system_template_id) return;
|
||||
const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(role);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
function togglePermission(scope: string, checked: boolean) {
|
||||
const next = new Set(draft.permissions);
|
||||
if (checked) next.add(scope);else next.delete(scope);
|
||||
setDraft({ ...draft, permissions: Array.from(next) });
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
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 deleteRole(settings, deleting.id);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
|
||||
setDeleting(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
||||
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
||||
</div> }],
|
||||
[canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" 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_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||
</div>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div>
|
||||
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
289
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
289
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
fetchPermissionCatalog,
|
||||
fetchSystemRolesDelta,
|
||||
updateSystemRole,
|
||||
type PermissionItem,
|
||||
type RoleSummary } from
|
||||
"../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } 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 { StatusBadge } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
description: "",
|
||||
permissions: [] as string[],
|
||||
isAssignable: true
|
||||
};
|
||||
|
||||
export default function SystemRolesPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, catalogue] = await Promise.all([
|
||||
loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
|
||||
fetchPermissionCatalog(settings)]
|
||||
);
|
||||
rolesRef.current = nextRoles;
|
||||
setRoles(nextRoles);
|
||||
setPermissions(catalogue.filter((item) => item.level === "system"));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(role: RoleSummary) {
|
||||
const nextDraft = {
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permissions: role.permissions,
|
||||
isAssignable: role.is_assignable
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(role);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createSystemRole(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions
|
||||
});
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
await updateSystemRole(settings, editing.id, {
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions,
|
||||
is_assignable: draft.isAssignable
|
||||
});
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteSystemRole(settings, deleting.id);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{
|
||||
id: "role",
|
||||
header: "i18n:govoplan-access.system_role.91762640",
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 380,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => `${row.name} ${row.slug}`,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: "i18n:govoplan-access.description.55f8ebc8",
|
||||
width: 360,
|
||||
fill: true,
|
||||
minWidth: 220,
|
||||
maxWidth: 640,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.description || "",
|
||||
render: (row) => row.description || "—"
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "i18n:govoplan-access.permissions.d06d5557",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.effective_permission_count,
|
||||
render: (row) => String(row.effective_permission_count)
|
||||
},
|
||||
{
|
||||
id: "assignable",
|
||||
header: "i18n:govoplan-access.assignable.a88debc5",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.is_assignable ? "yes" : "no",
|
||||
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"} />
|
||||
},
|
||||
{
|
||||
id: "assignments",
|
||||
header: "i18n:govoplan-access.accounts.36bae316",
|
||||
width: 110,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.user_assignments
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-access.actions.c3cd636a",
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
||||
</div>;
|
||||
}
|
||||
}],
|
||||
[canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.system_roles.a9461aa6"
|
||||
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
||||
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_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
|
||||
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
||||
<AdminSelectionList
|
||||
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
|
||||
selected={draft.permissions}
|
||||
onChange={(next) => setDraft({ ...draft, permissions: next })} />
|
||||
|
||||
<p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
285
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
285
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } 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 { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemAccount,
|
||||
fetchSystemAccountsDelta,
|
||||
fetchTenants,
|
||||
updateSystemAccount,
|
||||
updateSystemAccountRoles,
|
||||
updateSystemMemberships,
|
||||
type RoleSummary,
|
||||
type SystemAccountItem,
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
roleIds: [] as string[],
|
||||
memberships: [] as SystemMembershipDraft[]
|
||||
};
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
canAssignRoles,
|
||||
canManageMemberships,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const accountsRef = useRef<SystemAccountItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
let nextWatermark = getDeltaWatermark("access:system-accounts");
|
||||
let nextAccounts = accountsRef.current;
|
||||
let nextRoles = rolesRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
nextAccounts = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
|
||||
: response.accounts
|
||||
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
||||
nextRoles = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
|
||||
: response.roles
|
||||
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark("access:system-accounts", nextWatermark);
|
||||
const nextTenants = await fetchTenants(settings);
|
||||
accountsRef.current = nextAccounts;
|
||||
rolesRef.current = nextRoles;
|
||||
setAccounts(nextAccounts);
|
||||
setRoles(nextRoles);
|
||||
setTenants(nextTenants);
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
accountsRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(item: SystemAccountItem) {
|
||||
const nextDraft = {
|
||||
email: item.email,
|
||||
displayName: item.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: false,
|
||||
isActive: item.is_active,
|
||||
roleIds: item.roles.map((role) => role.id),
|
||||
memberships: item.memberships.map((membership) => ({
|
||||
tenant_id: membership.tenant_id,
|
||||
is_active: membership.is_active,
|
||||
role_ids: membership.role_ids || [],
|
||||
group_ids: membership.group_ids || [],
|
||||
is_owner: membership.is_owner,
|
||||
is_last_active_owner: membership.is_last_active_owner
|
||||
}))
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(item);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
function membership(tenantId: string) {
|
||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
function setMembership(tenantId: string, enabled: boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
memberships: enabled ?
|
||||
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
|
||||
current.memberships.filter((item) => item.tenant_id !== tenantId)
|
||||
}));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createSystemAccount(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
role_ids: canAssignRoles ? draft.roleIds : [],
|
||||
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
|
||||
});
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
|
||||
} else if (editing) {
|
||||
const accountChanges: {display_name?: string | null;is_active?: boolean;} = {};
|
||||
if (canUpdate) accountChanges.display_name = draft.displayName || null;
|
||||
if (canSuspend) accountChanges.is_active = draft.isActive;
|
||||
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
|
||||
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
|
||||
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
|
||||
{ id: "account", header: "i18n:govoplan-access.account.85dfa32c", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "tenants", header: "i18n:govoplan-access.tenant_memberships.451de736", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
|
||||
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
||||
</div> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||
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_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" &&
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })} />
|
||||
|
||||
</FormField>
|
||||
}
|
||||
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
||||
</div>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_global_account.66d92736" message={i18nMessage("i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_account.fd9fd676" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortSystemAccounts(left: SystemAccountItem, right: SystemAccountItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
175
webui/src/features/admin/TenantSettingsPanel.tsx
Normal file
175
webui/src/features/admin/TenantSettingsPanel.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
|
||||
const DELTA_KEY = "access:tenant-settings";
|
||||
|
||||
const fallback: TenantSettingsItem = {
|
||||
id: "",
|
||||
slug: "",
|
||||
name: "",
|
||||
default_locale: "en",
|
||||
available_languages: [
|
||||
{ code: "en", label: "English", native_label: "English" },
|
||||
{ code: "de", label: "German", native_label: "Deutsch" }
|
||||
],
|
||||
system_enabled_language_codes: ["en", "de"],
|
||||
enabled_language_codes: ["en", "de"],
|
||||
settings: {}
|
||||
};
|
||||
|
||||
export default function TenantSettingsPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
|
||||
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
|
||||
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => setDraft(savedDraft)
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const wasDirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||
const loaded = await fetchTenantSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
|
||||
setDeltaWatermark(DELTA_KEY, loaded.watermark);
|
||||
if (loaded.full && loaded.item) {
|
||||
setSavedDraft(loaded.item);
|
||||
if (!wasDirty) setDraft(loaded.item);
|
||||
} else if (loaded.changed_sections.length) {
|
||||
setSavedDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||
if (!wasDirty) setDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
|
||||
setDraft(saved);
|
||||
setSavedDraft(saved);
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
setSuccess("i18n:govoplan-access.tenant_general_settings_saved.485e7681");
|
||||
await onAuthRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLanguage(code: string, checked: boolean) {
|
||||
const enabled = new Set(draft.enabled_language_codes);
|
||||
if (checked) enabled.add(code);
|
||||
else enabled.delete(code);
|
||||
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.tenant_general_settings.db1c3ba8"
|
||||
description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-access.saving.ae7e8875" : "i18n:govoplan-access.save_general_settings.5c90f8c4"}</Button></>}>
|
||||
|
||||
<div className="admin-settings-form">
|
||||
<Card title="i18n:govoplan-access.locale.8970f0e6">
|
||||
<FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
|
||||
<select value={draft.default_locale} disabled={!canWrite || busy || defaultLocaleOptions.length === 0} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}>
|
||||
{defaultLocaleOptions.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="settings-list">
|
||||
{draft.system_enabled_language_codes.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return (
|
||||
<label className="admin-inline-check" key={code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled_language_codes.includes(code)}
|
||||
disabled={!canWrite || busy || code === draft.default_locale}
|
||||
onChange={(event) => toggleLanguage(code, event.target.checked)} />
|
||||
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function languageOptionLabel(language: {code: string;label: string;native_label?: string | null}): string {
|
||||
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||
}
|
||||
|
||||
function localeOptions(current: string, enabled: string[]): string[] {
|
||||
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
|
||||
}
|
||||
|
||||
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
|
||||
return JSON.stringify({
|
||||
default_locale: item.default_locale,
|
||||
enabled_language_codes: item.enabled_language_codes
|
||||
});
|
||||
}
|
||||
|
||||
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
|
||||
return {
|
||||
...item,
|
||||
...(sections.identity ?? {}),
|
||||
...(sections.locale ?? {}),
|
||||
...(sections.languages ?? {}),
|
||||
...(sections.settings ? { settings: sections.settings } : {})
|
||||
};
|
||||
}
|
||||
294
webui/src/features/admin/TenantsPanel.tsx
Normal file
294
webui/src/features/admin/TenantsPanel.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } 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 { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
type OverrideValue = "inherit" | "allow" | "deny";
|
||||
type TenantDraft = {
|
||||
slug: string;
|
||||
name: string;
|
||||
ownerAccountId: string;
|
||||
description: string;
|
||||
defaultLocale: string;
|
||||
isActive: boolean;
|
||||
customGroups: OverrideValue;
|
||||
customRoles: OverrideValue;
|
||||
apiKeys: OverrideValue;
|
||||
};
|
||||
|
||||
const emptyDraft: TenantDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
ownerAccountId: "",
|
||||
description: "",
|
||||
defaultLocale: "en",
|
||||
isActive: true,
|
||||
customGroups: "inherit",
|
||||
customRoles: "inherit",
|
||||
apiKeys: "inherit"
|
||||
};
|
||||
|
||||
function fromOverride(value?: boolean | null): OverrideValue {
|
||||
if (value === true) return "allow";
|
||||
if (value === false) return "deny";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function toOverride(value: OverrideValue): boolean | null {
|
||||
if (value === "allow") return true;
|
||||
if (value === "deny") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TenantsPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const tenantsRef = useRef<TenantAdminItem[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||
loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||
fetchSystemSettings(settings).catch(() => null)]
|
||||
);
|
||||
tenantsRef.current = nextTenants;
|
||||
setTenants(nextTenants);
|
||||
setOwnerCandidates(nextOwnerCandidates);
|
||||
setSystemSettings(nextSystemSettings);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
tenantsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(tenant: TenantAdminItem) {
|
||||
const nextDraft = {
|
||||
slug: tenant.slug,
|
||||
name: tenant.name,
|
||||
ownerAccountId: "",
|
||||
description: tenant.description || "",
|
||||
defaultLocale: tenant.default_locale || "en",
|
||||
isActive: tenant.is_active,
|
||||
customGroups: fromOverride(tenant.allow_custom_groups),
|
||||
customRoles: fromOverride(tenant.allow_custom_roles),
|
||||
apiKeys: fromOverride(tenant.allow_api_keys)
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(tenant);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const governance = {
|
||||
allow_custom_groups: toOverride(draft.customGroups),
|
||||
allow_custom_roles: toOverride(draft.customRoles),
|
||||
allow_api_keys: toOverride(draft.apiKeys)
|
||||
};
|
||||
if (editing === "new") {
|
||||
const created = await createTenant(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
owner_account_id: draft.ownerAccountId || null,
|
||||
description: draft.description || null,
|
||||
default_locale: draft.defaultLocale,
|
||||
settings: {},
|
||||
...governance
|
||||
});
|
||||
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-access.the_selected_account.1211bfb9" }));
|
||||
await onAuthRefresh();
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateTenant>[2] = {};
|
||||
if (canUpdate) {
|
||||
payload.name = draft.name;
|
||||
payload.description = draft.description || null;
|
||||
payload.default_locale = draft.defaultLocale;
|
||||
payload.allow_custom_groups = governance.allow_custom_groups;
|
||||
payload.allow_custom_roles = governance.allow_custom_roles;
|
||||
payload.allow_api_keys = governance.allow_api_keys;
|
||||
}
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
await updateTenant(settings, editing.id, payload);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_updated.25b2c855", { value0: draft.name }));
|
||||
await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function suspend() {
|
||||
if (!confirmSuspend) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateTenant(settings, confirmSuspend.id, { is_active: false });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.value_suspended.31731a28", { value0: confirmSuspend.name }));
|
||||
setConfirmSuspend(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
|
||||
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
|
||||
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
|
||||
const systemDeniedGovernance = [
|
||||
systemAllowsCustomGroups ? "" : "i18n:govoplan-access.custom_groups.453a605c",
|
||||
systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
|
||||
systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
|
||||
filter(Boolean).join(", ");
|
||||
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ id: "name", header: "i18n:govoplan-access.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ id: "users", header: "i18n:govoplan-access.users.57f2b181", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
|
||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
||||
{ id: "campaigns", header: "i18n:govoplan-access.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
||||
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
||||
</div> }],
|
||||
[activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.tenants.1f7ae776"
|
||||
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
||||
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_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
|
||||
}
|
||||
|
||||
function draftKey(draft: TenantDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
384
webui/src/features/admin/UsersPanel.tsx
Normal file
384
webui/src/features/admin/UsersPanel.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } 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 { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
groupIds: [] as string[],
|
||||
roleIds: [] as string[]
|
||||
};
|
||||
|
||||
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
|
||||
const [explaining, setExplaining] = useState<UserAdminItem | null>(null);
|
||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextUsers, nextGroups, nextRoles] = await Promise.all([
|
||||
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
|
||||
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
|
||||
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
|
||||
);
|
||||
usersRef.current = nextUsers;
|
||||
groupsRef.current = nextGroups;
|
||||
rolesRef.current = nextRoles;
|
||||
setUsers(nextUsers);
|
||||
setGroups(nextGroups);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(user: UserAdminItem) {
|
||||
const nextDraft = {
|
||||
email: user.email,
|
||||
displayName: user.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: user.password_reset_required,
|
||||
isActive: user.is_active,
|
||||
groupIds: user.groups.map((group) => group.id),
|
||||
roleIds: user.roles.map((role) => role.id)
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(user);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createUser(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
group_ids: canManageGroups ? draft.groupIds : [],
|
||||
role_ids: canAssignRoles ? draft.roleIds : []
|
||||
});
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6", { value0: response.user.email }));
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateUser>[2] = {};
|
||||
if (canUpdate) payload.display_name = draft.displayName || null;
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
if (canManageGroups) payload.group_ids = draft.groupIds;
|
||||
if (canAssignRoles) payload.role_ids = draft.roleIds;
|
||||
await updateUser(settings, editing.id, payload);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.access_updated_for_value.87f22245", { value0: editing.email }));
|
||||
if (editing.id === auth.user.id) await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateUser(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2", { value0: deactivating.email }));
|
||||
if (deactivating.id === auth.user.id) await onAuthRefresh();
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function openAccessExplanation(user: UserAdminItem) {
|
||||
setExplaining(user);
|
||||
setAccessExplanation(null);
|
||||
setAccessExplanationLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setAccessExplanation(await fetchUserAccessExplanation(settings, user.id));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
setExplaining(null);
|
||||
} finally {
|
||||
setAccessExplanationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||
{ id: "roles", header: "i18n:govoplan-access.direct_roles.c4db7156", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }],
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" 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_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" &&
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })} />
|
||||
|
||||
</FormField>
|
||||
}
|
||||
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
</div>
|
||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
|
||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>i18n:govoplan-access.email.84add5b2</dt><dd>{viewing.email}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.membership.53bc9670</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.global_account.e1b00cf5</dt><dd>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.groups.ae9629f4</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>i18n:govoplan-access.direct_roles.c4db7156</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||
{accessExplanation && <>
|
||||
<dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
||||
</dl>
|
||||
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
||||
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid">
|
||||
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
||||
<strong>{source.role_name}</strong>
|
||||
<div className="muted small-note">
|
||||
<span>{sourceTypeLabel(source)}</span>
|
||||
{source.group_name && <> - <span>i18n:govoplan-access.group.171a0606</span>: {source.group_name}</>}
|
||||
{(source.function_name || source.function_id) && <> - <span>i18n:govoplan-access.function_fact.4f7435e4</span>: {source.function_name || source.function_id}</>}
|
||||
{(source.organization_unit_name || source.organization_unit_id) && <> - <span>i18n:govoplan-access.organization_unit.9832b383</span>: {source.organization_unit_name || source.organization_unit_id}</>}
|
||||
</div>
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
||||
</div>)}
|
||||
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
||||
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
||||
<dt>{fact.function_name || fact.function_id}</dt>
|
||||
<dd>
|
||||
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
||||
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
||||
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||
</dd>
|
||||
</div>)}
|
||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||
<div className="admin-scope-list">
|
||||
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
||||
</div>
|
||||
</>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c" message={i18nMessage("i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_user.9ddc7096" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sourceKey(source: AccessRoleSourceItem): string {
|
||||
return [
|
||||
source.source_type,
|
||||
source.role_id,
|
||||
source.group_id || "",
|
||||
source.function_assignment_id || "",
|
||||
source.function_id || ""
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function AccessExplanationLinks({ organizationHref, idmHref }: { organizationHref: string | null; idmHref: string | null }) {
|
||||
if (!organizationHref && !idmHref) return null;
|
||||
return (
|
||||
<div className="muted small-note">
|
||||
{organizationHref && <a href={organizationHref} title="i18n:govoplan-access.open_organizations">i18n:govoplan-access.open_organizations</a>}
|
||||
{organizationHref && idmHref && <span> | </span>}
|
||||
{idmHref && <a href={idmHref} title="i18n:govoplan-access.open_idm_assignment">i18n:govoplan-access.open_idm_assignment</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function organizationHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return organizationHref(source.function_id, source.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return organizationHref(fact.function_id, fact.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHref(functionId?: string | null, unitId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (functionId) {
|
||||
params.set("section", "functions");
|
||||
params.set("function_id", functionId);
|
||||
if (unitId) params.set("unit_id", unitId);
|
||||
} else if (unitId) {
|
||||
params.set("section", "units");
|
||||
params.set("unit_id", unitId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/organizations?${query}` : null;
|
||||
}
|
||||
|
||||
function idmHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return idmHref(source.function_assignment_id, source.function_id);
|
||||
}
|
||||
|
||||
function idmHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return idmHref(fact.assignment_id, fact.function_id);
|
||||
}
|
||||
|
||||
function idmHref(assignmentId?: string | null, functionId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (assignmentId) params.set("assignment_id", assignmentId);
|
||||
if (functionId) params.set("function_id", functionId);
|
||||
const query = params.toString();
|
||||
return query ? `/idm?${query}` : null;
|
||||
}
|
||||
|
||||
function sourceTypeLabel(source: AccessRoleSourceItem): string {
|
||||
switch (source.source_type) {
|
||||
case "direct_role":
|
||||
return "i18n:govoplan-access.direct_role_source.bf3e6a27";
|
||||
case "group_role":
|
||||
return "i18n:govoplan-access.group_role_source.0aa38904";
|
||||
case "legacy_function_role":
|
||||
return "i18n:govoplan-access.legacy_function_role_source.36f81c6b";
|
||||
case "idm_function_role":
|
||||
return "i18n:govoplan-access.idm_function_role_source.14a231fe";
|
||||
case "system_role":
|
||||
return "i18n:govoplan-access.system_role_source.c6e83223";
|
||||
default:
|
||||
return source.source_type;
|
||||
}
|
||||
}
|
||||
|
||||
function factRoles(fact: FunctionFactExplanationItem): string {
|
||||
return fact.role_names.length ? fact.role_names.join(", ") : "i18n:govoplan-access.no_mapped_roles.986bfb44";
|
||||
}
|
||||
38
webui/src/features/admin/utils/deltaRows.ts
Normal file
38
webui/src/features/admin/utils/deltaRows.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mergeDeltaRows, type DeltaDeletedItem } from "@govoplan/core-webui";
|
||||
|
||||
export type AdminDeltaResponse = {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>(
|
||||
current: TItem[],
|
||||
key: string,
|
||||
getDeltaWatermark: (key: string) => string | null,
|
||||
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
|
||||
fetchDelta: (since: string | null) => Promise<TResponse>,
|
||||
rowsFromResponse: (response: TResponse) => TItem[],
|
||||
getKey: (item: TItem) => string,
|
||||
deletedResourceType: string,
|
||||
sort?: (left: TItem, right: TItem) => number
|
||||
): Promise<TItem[]> {
|
||||
let nextWatermark = getDeltaWatermark(key);
|
||||
let merged = current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await fetchDelta(nextWatermark);
|
||||
const rows = rowsFromResponse(response);
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
merged = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
|
||||
: rows
|
||||
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(key, nextWatermark);
|
||||
return merged;
|
||||
}
|
||||
700
webui/src/i18n/generatedTranslations.ts
Normal file
700
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Access explanation",
|
||||
"i18n:govoplan-access.access.2f81a22d": "Access",
|
||||
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
|
||||
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
|
||||
"i18n:govoplan-access.account.85dfa32c": "Account",
|
||||
"i18n:govoplan-access.accounts.36bae316": "Accounts",
|
||||
"i18n:govoplan-access.action.97c89a4d": "Action",
|
||||
"i18n:govoplan-access.actions.c3cd636a": "Actions",
|
||||
"i18n:govoplan-access.active.a733b809": "Active",
|
||||
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
|
||||
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
|
||||
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
|
||||
"i18n:govoplan-access.add_group.2fca464f": "Add group",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function mapping",
|
||||
"i18n:govoplan-access.add_role.d8d5d55c": "Add role",
|
||||
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
|
||||
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
|
||||
"i18n:govoplan-access.add_tenant.b8e32af0": "Add tenant",
|
||||
"i18n:govoplan-access.admin.4e7afebc": "Admin",
|
||||
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
|
||||
"i18n:govoplan-access.changes.8aa57de6": "Changes",
|
||||
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "All tenant permissions",
|
||||
"i18n:govoplan-access.all.6a720856": "All",
|
||||
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
|
||||
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
|
||||
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
|
||||
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
|
||||
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
|
||||
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
|
||||
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
|
||||
"i18n:govoplan-access.api_keys.94fcf3c2": "API keys",
|
||||
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
|
||||
"i18n:govoplan-access.apply_retention.5b991811": "Apply retention",
|
||||
"i18n:govoplan-access.assignable.a88debc5": "Assignable",
|
||||
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
|
||||
"i18n:govoplan-access.assignments.057d58c7": "Assignments",
|
||||
"i18n:govoplan-access.assignment_source.5fac1f72": "Assignment source",
|
||||
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
|
||||
"i18n:govoplan-access.audit.fa1703dd": "Audit",
|
||||
"i18n:govoplan-access.available.ce372771": ", available",
|
||||
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
|
||||
"i18n:govoplan-access.built_in.20f409cc": "Built-in",
|
||||
"i18n:govoplan-access.campaigns.01a23a28": "Campaigns",
|
||||
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
|
||||
"i18n:govoplan-access.cancel.77dfd213": "Cancel",
|
||||
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
|
||||
"i18n:govoplan-access.close.bbfa773e": "Close",
|
||||
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
|
||||
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
|
||||
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
|
||||
"i18n:govoplan-access.create_group.5a0b1c17": "Create group",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function mapping",
|
||||
"i18n:govoplan-access.create_key.e028cb09": "Create key",
|
||||
"i18n:govoplan-access.create_role.db859bad": "Create role",
|
||||
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
|
||||
"i18n:govoplan-access.create_tenant.4dbd55d9": "Create tenant",
|
||||
"i18n:govoplan-access.created.accf40c8": "Created",
|
||||
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
|
||||
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
|
||||
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
|
||||
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
|
||||
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
|
||||
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
|
||||
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
|
||||
"i18n:govoplan-access.custom.081ae3fd": "Custom",
|
||||
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
|
||||
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
|
||||
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
|
||||
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Deactivate tenant membership",
|
||||
"i18n:govoplan-access.deactivate_user.9ddc7096": "Deactivate user",
|
||||
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
|
||||
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
|
||||
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
|
||||
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
|
||||
"i18n:govoplan-access.default_locale.b99d021f": "Default locale",
|
||||
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function mapping",
|
||||
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.",
|
||||
"i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping",
|
||||
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
|
||||
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
|
||||
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
|
||||
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
|
||||
"i18n:govoplan-access.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-access.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-access.direct_role_source.bf3e6a27": "Direct role",
|
||||
"i18n:govoplan-access.direct_roles.c4db7156": "Direct roles",
|
||||
"i18n:govoplan-access.display_name.c7874aaa": "Display name",
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Dry run",
|
||||
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
|
||||
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function mapping",
|
||||
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
|
||||
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
|
||||
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
|
||||
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Edit tenant",
|
||||
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
|
||||
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Effective permissions",
|
||||
"i18n:govoplan-access.email.84add5b2": "Email",
|
||||
"i18n:govoplan-access.expires.a99be3da": "Expires",
|
||||
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Files",
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Function facts",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Function ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function mapping created.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function mapping deleted.",
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
||||
"i18n:govoplan-access.general.9239ee2c": "General",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
|
||||
"i18n:govoplan-access.global_account.e1b00cf5": "Global account",
|
||||
"i18n:govoplan-access.global": "Global",
|
||||
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
|
||||
"i18n:govoplan-access.group_assignments.e534bb56": "Group assignments",
|
||||
"i18n:govoplan-access.group_details.df844ae3": "Group details",
|
||||
"i18n:govoplan-access.group_file_connections.73985bda": "Group file connections",
|
||||
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
|
||||
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
|
||||
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Group mail profiles",
|
||||
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
|
||||
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
|
||||
"i18n:govoplan-access.group_retention.57cdcdaa": "Group retention",
|
||||
"i18n:govoplan-access.group_role_source.0aa38904": "Group role",
|
||||
"i18n:govoplan-access.group_templates": "Group templates",
|
||||
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
|
||||
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
|
||||
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
|
||||
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
|
||||
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
|
||||
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
|
||||
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
|
||||
"i18n:govoplan-access.group.171a0606": "Group",
|
||||
"i18n:govoplan-access.group.ea5a3834": "GROUP",
|
||||
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
|
||||
"i18n:govoplan-access.groups.07551586": "groups,",
|
||||
"i18n:govoplan-access.groups.ae9629f4": "Groups",
|
||||
"i18n:govoplan-access.i_have_recorded_it.7522da18": "I have recorded it",
|
||||
"i18n:govoplan-access.idm_function_role_source.14a231fe": "IDM function role",
|
||||
"i18n:govoplan-access.inactive.09af574c": "Inactive",
|
||||
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
|
||||
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
|
||||
"i18n:govoplan-access.initial_password.2278be8c": "Initial password",
|
||||
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
|
||||
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
|
||||
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
|
||||
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Install and enable the Files module to manage file connector profiles and policies.",
|
||||
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
|
||||
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
|
||||
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
|
||||
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
|
||||
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
|
||||
"i18n:govoplan-access.last_login.43dab84f": "Last login",
|
||||
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
|
||||
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leave empty to generate",
|
||||
"i18n:govoplan-access.legacy_function_role_source.36f81c6b": "Legacy function role",
|
||||
"i18n:govoplan-access.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-access.loading_access_explanation.04a7c934": "Loading access explanation...",
|
||||
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
|
||||
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
|
||||
"i18n:govoplan-access.maintenance.94de303b": "Maintenance",
|
||||
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
|
||||
"i18n:govoplan-access.management.63cecca6": "Management",
|
||||
"i18n:govoplan-access.mapped_roles.504d9ff9": "Mapped roles",
|
||||
"i18n:govoplan-access.members.1cb449c1": "Members",
|
||||
"i18n:govoplan-access.membership_status.b77fc732": "Membership status",
|
||||
"i18n:govoplan-access.membership.53bc9670": "Membership",
|
||||
"i18n:govoplan-access.modules.04e9462c": "Modules",
|
||||
"i18n:govoplan-access.name.709a2322": "Name",
|
||||
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records 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_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function mappings found.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
|
||||
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
|
||||
"i18n:govoplan-access.no_mapped_roles.986bfb44": "No mapped roles",
|
||||
"i18n:govoplan-access.no_roles_found.70f7c0c9": "No roles found.",
|
||||
"i18n:govoplan-access.no_role_sources_found.91013aa5": "No role sources found.",
|
||||
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
|
||||
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
|
||||
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "No tenant users found.",
|
||||
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
|
||||
"i18n:govoplan-access.no.816c52fd": "No",
|
||||
"i18n:govoplan-access.none.6eef6648": "None",
|
||||
"i18n:govoplan-access.object.2883f191": "Object",
|
||||
"i18n:govoplan-access.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-access.organization_unit.9832b383": "Organization unit",
|
||||
"i18n:govoplan-access.open_idm_assignment": "Open IDM assignment",
|
||||
"i18n:govoplan-access.open_organizations": "Open Organizations",
|
||||
"i18n:govoplan-access.owner.89ff3122": "Owner",
|
||||
"i18n:govoplan-access.packages.0a999012": "Packages",
|
||||
"i18n:govoplan-access.permissions.d06d5557": "Permissions",
|
||||
"i18n:govoplan-access.platform_administration": "Platform administration",
|
||||
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
|
||||
"i18n:govoplan-access.protected.28531336": "Protected",
|
||||
"i18n:govoplan-access.reload.cce71553": "Reload",
|
||||
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
|
||||
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Require password change when account settings are implemented",
|
||||
"i18n:govoplan-access.required.2e5396fd": ", required",
|
||||
"i18n:govoplan-access.required.7c65879a": " · required",
|
||||
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
|
||||
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
|
||||
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
|
||||
"i18n:govoplan-access.retention.c7199d9e": "Retention",
|
||||
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
|
||||
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
|
||||
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
|
||||
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
|
||||
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
|
||||
"i18n:govoplan-access.role_details.a16b5d9f": "Role details",
|
||||
"i18n:govoplan-access.role_sources.6f42a672": "Role sources",
|
||||
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
|
||||
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
|
||||
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
|
||||
"i18n:govoplan-access.role.c3f104d1": "Role",
|
||||
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
|
||||
"i18n:govoplan-access.roles.47dcc27d": "Roles",
|
||||
"i18n:govoplan-access.root.77e7e78b": "ROOT",
|
||||
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
|
||||
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
|
||||
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
|
||||
"i18n:govoplan-access.save_group.36ca6865": "Save group",
|
||||
"i18n:govoplan-access.save_mapping.a4ac90e9": "Save mapping",
|
||||
"i18n:govoplan-access.save_role.16fe10d1": "Save role",
|
||||
"i18n:govoplan-access.save_tenant.9eb2ac74": "Save tenant",
|
||||
"i18n:govoplan-access.save_user.0d071b89": "Save user",
|
||||
"i18n:govoplan-access.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-access.scope.4651a34e": "Scope",
|
||||
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
|
||||
"i18n:govoplan-access.select_role.c543f191": "Select role",
|
||||
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
|
||||
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
|
||||
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
|
||||
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
|
||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||
"i18n:govoplan-access.source_module.62b7241c": "Source module",
|
||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||
"i18n:govoplan-access.suspended.794696a7": "Suspended",
|
||||
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
|
||||
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
|
||||
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}–{value1} of {value2} records.",
|
||||
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
|
||||
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System mail profiles",
|
||||
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
|
||||
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
|
||||
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
|
||||
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
|
||||
"i18n:govoplan-access.system_retention.4191e7f7": "System retention",
|
||||
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
|
||||
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
|
||||
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
|
||||
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
|
||||
"i18n:govoplan-access.system_role.91762640": "System role",
|
||||
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
|
||||
"i18n:govoplan-access.system_role_source.c6e83223": "System role",
|
||||
"i18n:govoplan-access.system.29d43743": "SYSTEM",
|
||||
"i18n:govoplan-access.system.bc0792d8": "System",
|
||||
"i18n:govoplan-access.temporary_password.62d60628": "Temporary password",
|
||||
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Tenant API keys",
|
||||
"i18n:govoplan-access.tenant_audit.492b9138": "Tenant audit",
|
||||
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
|
||||
"i18n:govoplan-access.tenant_custom.4307081e": "Tenant custom",
|
||||
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
|
||||
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
|
||||
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
|
||||
"i18n:govoplan-access.tenant_groups.47e6cc05": "Tenant groups",
|
||||
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}–{value1} of {value2} records.",
|
||||
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
|
||||
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
|
||||
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
|
||||
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
|
||||
"i18n:govoplan-access.tenant_languages_help": "Tenant languages can only be selected from languages enabled by the system. Users can choose from the tenant-enabled set.",
|
||||
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
|
||||
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Tenant mail profiles",
|
||||
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
|
||||
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
|
||||
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
|
||||
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
|
||||
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
|
||||
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Role templates",
|
||||
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
|
||||
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
|
||||
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
|
||||
"i18n:govoplan-access.tenant_users.cb800b38": "Tenant users",
|
||||
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
|
||||
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
|
||||
"i18n:govoplan-access.tenant.3ca93c78": "Tenant",
|
||||
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
|
||||
"i18n:govoplan-access.tenants.1f7ae776": "Tenants",
|
||||
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
|
||||
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
|
||||
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
|
||||
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
|
||||
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
|
||||
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
|
||||
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
|
||||
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "This is shown once for",
|
||||
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
|
||||
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
|
||||
"i18n:govoplan-access.time.6c82e6dd": "Time",
|
||||
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Transmit it through a separate secure channel.",
|
||||
"i18n:govoplan-access.type.3deb7456": "Type",
|
||||
"i18n:govoplan-access.updated.f2f8570d": "Updated",
|
||||
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
|
||||
"i18n:govoplan-access.user_assignments.bc7cc801": "User assignments",
|
||||
"i18n:govoplan-access.user_file_connections.996444a0": "User file connections",
|
||||
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
|
||||
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
|
||||
"i18n:govoplan-access.user_mail_profiles.f54a845a": "User mail profiles",
|
||||
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
|
||||
"i18n:govoplan-access.user_retention.f0966bbf": "User retention",
|
||||
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
|
||||
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
|
||||
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
|
||||
"i18n:govoplan-access.user.6eb0c612": "USER",
|
||||
"i18n:govoplan-access.user.9f8a2389": "User",
|
||||
"i18n:govoplan-access.users.57f2b181": "Users",
|
||||
"i18n:govoplan-access.users.81651889": "users,",
|
||||
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
|
||||
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
|
||||
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
|
||||
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
|
||||
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
|
||||
"i18n:govoplan-access.yes.5397e058": "Yes",
|
||||
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Zugriffserklaerung",
|
||||
"i18n:govoplan-access.access.2f81a22d": "Zugriff",
|
||||
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
|
||||
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
|
||||
"i18n:govoplan-access.account.85dfa32c": "Account",
|
||||
"i18n:govoplan-access.accounts.36bae316": "Accounts",
|
||||
"i18n:govoplan-access.action.97c89a4d": "Action",
|
||||
"i18n:govoplan-access.actions.c3cd636a": "Aktionen",
|
||||
"i18n:govoplan-access.active.a733b809": "Aktiv",
|
||||
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
|
||||
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
|
||||
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
|
||||
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktionszuordnung hinzufügen",
|
||||
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
|
||||
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
|
||||
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
|
||||
"i18n:govoplan-access.add_tenant.b8e32af0": "Mandant hinzufügen",
|
||||
"i18n:govoplan-access.admin.4e7afebc": "Administration",
|
||||
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
|
||||
"i18n:govoplan-access.changes.8aa57de6": "Änderungen",
|
||||
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "Alle Mandantenberechtigungen",
|
||||
"i18n:govoplan-access.all.6a720856": "All",
|
||||
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
|
||||
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
|
||||
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
|
||||
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
|
||||
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
|
||||
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
|
||||
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
|
||||
"i18n:govoplan-access.api_keys.94fcf3c2": "API-Schlüssel",
|
||||
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
|
||||
"i18n:govoplan-access.apply_retention.5b991811": "Aufbewahrung anwenden",
|
||||
"i18n:govoplan-access.assignable.a88debc5": "Zuweisbar",
|
||||
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
|
||||
"i18n:govoplan-access.assignments.057d58c7": "Zuweisungen",
|
||||
"i18n:govoplan-access.assignment_source.5fac1f72": "Zuweisungsquelle",
|
||||
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
|
||||
"i18n:govoplan-access.audit.fa1703dd": "Audit",
|
||||
"i18n:govoplan-access.available.ce372771": ", available",
|
||||
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
|
||||
"i18n:govoplan-access.built_in.20f409cc": "Eingebaut",
|
||||
"i18n:govoplan-access.campaigns.01a23a28": "Kampagnen",
|
||||
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
|
||||
"i18n:govoplan-access.cancel.77dfd213": "Abbrechen",
|
||||
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
|
||||
"i18n:govoplan-access.close.bbfa773e": "Schließen",
|
||||
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
|
||||
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
|
||||
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
|
||||
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktionszuordnung erstellen",
|
||||
"i18n:govoplan-access.create_key.e028cb09": "Create key",
|
||||
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
|
||||
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
|
||||
"i18n:govoplan-access.create_tenant.4dbd55d9": "Mandant erstellen",
|
||||
"i18n:govoplan-access.created.accf40c8": "Erstellt",
|
||||
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
|
||||
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
|
||||
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
|
||||
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
|
||||
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
|
||||
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
|
||||
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
|
||||
"i18n:govoplan-access.custom.081ae3fd": "Benutzerdefiniert",
|
||||
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
|
||||
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
|
||||
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
|
||||
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Mandantenmitgliedschaft deaktivieren",
|
||||
"i18n:govoplan-access.deactivate_user.9ddc7096": "Benutzer deaktivieren",
|
||||
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
|
||||
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
|
||||
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
|
||||
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
|
||||
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
|
||||
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktionszuordnung löschen",
|
||||
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.",
|
||||
"i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen",
|
||||
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
|
||||
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
|
||||
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
|
||||
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
|
||||
"i18n:govoplan-access.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-access.description.55f8ebc8": "Beschreibung",
|
||||
"i18n:govoplan-access.direct_role_source.bf3e6a27": "Direkte Rolle",
|
||||
"i18n:govoplan-access.direct_roles.c4db7156": "Direkte Rollen",
|
||||
"i18n:govoplan-access.display_name.c7874aaa": "Anzeigename",
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
|
||||
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
|
||||
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktionszuordnung bearbeiten",
|
||||
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
|
||||
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
|
||||
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
|
||||
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Mandant bearbeiten",
|
||||
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
|
||||
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Wirksame Berechtigungen",
|
||||
"i18n:govoplan-access.email.84add5b2": "E-Mail",
|
||||
"i18n:govoplan-access.expires.a99be3da": "Expires",
|
||||
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Funktions-ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktionszuordnung erstellt.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktionszuordnung gelöscht.",
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
||||
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
|
||||
"i18n:govoplan-access.global_account.e1b00cf5": "Globales Konto",
|
||||
"i18n:govoplan-access.global": "Global",
|
||||
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
|
||||
"i18n:govoplan-access.group_assignments.e534bb56": "Gruppenzuweisungen",
|
||||
"i18n:govoplan-access.group_details.df844ae3": "Gruppendetails",
|
||||
"i18n:govoplan-access.group_file_connections.73985bda": "Gruppen-Dateiverbindungen",
|
||||
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
|
||||
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
|
||||
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Gruppen-Mailprofile",
|
||||
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
|
||||
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
|
||||
"i18n:govoplan-access.group_retention.57cdcdaa": "Gruppenaufbewahrung",
|
||||
"i18n:govoplan-access.group_role_source.0aa38904": "Gruppenrolle",
|
||||
"i18n:govoplan-access.group_templates": "Gruppenvorlagen",
|
||||
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
|
||||
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
|
||||
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
|
||||
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
|
||||
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
|
||||
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
|
||||
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
|
||||
"i18n:govoplan-access.group.171a0606": "Gruppe",
|
||||
"i18n:govoplan-access.group.ea5a3834": "GROUP",
|
||||
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
|
||||
"i18n:govoplan-access.groups.07551586": "groups,",
|
||||
"i18n:govoplan-access.groups.ae9629f4": "Gruppen",
|
||||
"i18n:govoplan-access.i_have_recorded_it.7522da18": "Ich habe es notiert",
|
||||
"i18n:govoplan-access.idm_function_role_source.14a231fe": "IDM-Funktionsrolle",
|
||||
"i18n:govoplan-access.inactive.09af574c": "Inaktiv",
|
||||
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
|
||||
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
|
||||
"i18n:govoplan-access.initial_password.2278be8c": "Initiales Passwort",
|
||||
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
|
||||
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
|
||||
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
|
||||
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Installieren und aktivieren Sie das Dateimodul, um Dateiverbindungsprofile und Richtlinien zu verwalten.",
|
||||
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
|
||||
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
|
||||
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
|
||||
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
|
||||
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
|
||||
"i18n:govoplan-access.last_login.43dab84f": "Letzte Anmeldung",
|
||||
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
|
||||
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leer lassen zum Generieren",
|
||||
"i18n:govoplan-access.legacy_function_role_source.36f81c6b": "Alte Funktionsrolle",
|
||||
"i18n:govoplan-access.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-access.loading_access_explanation.04a7c934": "Zugriffserklaerung wird geladen...",
|
||||
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
|
||||
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
|
||||
"i18n:govoplan-access.maintenance.94de303b": "Wartung",
|
||||
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
|
||||
"i18n:govoplan-access.management.63cecca6": "Management",
|
||||
"i18n:govoplan-access.mapped_roles.504d9ff9": "Zugeordnete Rollen",
|
||||
"i18n:govoplan-access.members.1cb449c1": "Members",
|
||||
"i18n:govoplan-access.membership_status.b77fc732": "Mitgliedschaftsstatus",
|
||||
"i18n:govoplan-access.membership.53bc9670": "Mitgliedschaft",
|
||||
"i18n:govoplan-access.modules.04e9462c": "Module",
|
||||
"i18n:govoplan-access.name.709a2322": "Name",
|
||||
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records 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_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktionszuordnungen gefunden.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
|
||||
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
|
||||
"i18n:govoplan-access.no_mapped_roles.986bfb44": "Keine zugeordneten Rollen",
|
||||
"i18n:govoplan-access.no_roles_found.70f7c0c9": "Keine Rollen gefunden.",
|
||||
"i18n:govoplan-access.no_role_sources_found.91013aa5": "Keine Rollenquellen gefunden.",
|
||||
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
|
||||
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
|
||||
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "Keine Mandantenbenutzer gefunden.",
|
||||
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
|
||||
"i18n:govoplan-access.no.816c52fd": "Nein",
|
||||
"i18n:govoplan-access.none.6eef6648": "Keine",
|
||||
"i18n:govoplan-access.object.2883f191": "Object",
|
||||
"i18n:govoplan-access.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-access.organization_unit.9832b383": "Organisationseinheit",
|
||||
"i18n:govoplan-access.open_idm_assignment": "IDM-Zuordnung oeffnen",
|
||||
"i18n:govoplan-access.open_organizations": "Organisationen oeffnen",
|
||||
"i18n:govoplan-access.owner.89ff3122": "Eigentümer",
|
||||
"i18n:govoplan-access.packages.0a999012": "Pakete",
|
||||
"i18n:govoplan-access.permissions.d06d5557": "Berechtigungen",
|
||||
"i18n:govoplan-access.platform_administration": "Plattformadministration",
|
||||
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
|
||||
"i18n:govoplan-access.protected.28531336": "Protected",
|
||||
"i18n:govoplan-access.reload.cce71553": "Neu laden",
|
||||
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
|
||||
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Passwortwechsel verlangen, sobald Kontoeinstellungen umgesetzt sind",
|
||||
"i18n:govoplan-access.required.2e5396fd": ", required",
|
||||
"i18n:govoplan-access.required.7c65879a": " · required",
|
||||
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
|
||||
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
|
||||
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
|
||||
"i18n:govoplan-access.retention.c7199d9e": "Retention",
|
||||
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
|
||||
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
|
||||
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
|
||||
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
|
||||
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
|
||||
"i18n:govoplan-access.role_details.a16b5d9f": "Rollendetails",
|
||||
"i18n:govoplan-access.role_sources.6f42a672": "Rollenquellen",
|
||||
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
|
||||
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
|
||||
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
|
||||
"i18n:govoplan-access.role.c3f104d1": "Rolle",
|
||||
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
|
||||
"i18n:govoplan-access.roles.47dcc27d": "Roles",
|
||||
"i18n:govoplan-access.root.77e7e78b": "ROOT",
|
||||
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
|
||||
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
|
||||
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
|
||||
"i18n:govoplan-access.save_group.36ca6865": "Gruppe speichern",
|
||||
"i18n:govoplan-access.save_mapping.a4ac90e9": "Zuordnung speichern",
|
||||
"i18n:govoplan-access.save_role.16fe10d1": "Rolle speichern",
|
||||
"i18n:govoplan-access.save_tenant.9eb2ac74": "Mandant speichern",
|
||||
"i18n:govoplan-access.save_user.0d071b89": "Benutzer speichern",
|
||||
"i18n:govoplan-access.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-access.scope.4651a34e": "Geltungsbereich",
|
||||
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
|
||||
"i18n:govoplan-access.select_role.c543f191": "Rolle auswählen",
|
||||
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
|
||||
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
|
||||
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
|
||||
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
|
||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||
"i18n:govoplan-access.suspended.794696a7": "Suspended",
|
||||
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
|
||||
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
|
||||
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}–{value1} of {value2} records.",
|
||||
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
|
||||
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System-Mailprofile",
|
||||
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
|
||||
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
|
||||
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
|
||||
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
|
||||
"i18n:govoplan-access.system_retention.4191e7f7": "Systemaufbewahrung",
|
||||
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
|
||||
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
|
||||
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
|
||||
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
|
||||
"i18n:govoplan-access.system_role.91762640": "System role",
|
||||
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
|
||||
"i18n:govoplan-access.system_role_source.c6e83223": "Systemrolle",
|
||||
"i18n:govoplan-access.system.29d43743": "SYSTEM",
|
||||
"i18n:govoplan-access.system.bc0792d8": "System",
|
||||
"i18n:govoplan-access.temporary_password.62d60628": "Temporäres Passwort",
|
||||
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Mandanten-API-Schlüssel",
|
||||
"i18n:govoplan-access.tenant_audit.492b9138": "Mandanten-Audit",
|
||||
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
|
||||
"i18n:govoplan-access.tenant_custom.4307081e": "Mandanten-spezifisch",
|
||||
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
|
||||
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
|
||||
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
|
||||
"i18n:govoplan-access.tenant_groups.47e6cc05": "Mandantengruppen",
|
||||
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}–{value1} of {value2} records.",
|
||||
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
|
||||
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
|
||||
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
|
||||
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
|
||||
"i18n:govoplan-access.tenant_languages_help": "Mandantensprachen koennen nur aus den systemweit aktivierten Sprachen gewaehlt werden. Benutzer koennen aus den fuer den Mandanten aktivierten Sprachen waehlen.",
|
||||
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
|
||||
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Mandanten-Mailprofile",
|
||||
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
|
||||
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
|
||||
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
|
||||
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
|
||||
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
|
||||
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Rollenvorlagen",
|
||||
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
|
||||
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
|
||||
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",
|
||||
"i18n:govoplan-access.tenant_users.cb800b38": "Mandantenbenutzer",
|
||||
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
|
||||
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
|
||||
"i18n:govoplan-access.tenant.3ca93c78": "Mandant",
|
||||
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
|
||||
"i18n:govoplan-access.tenants.1f7ae776": "Mandanten",
|
||||
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
|
||||
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
|
||||
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
|
||||
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
|
||||
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
|
||||
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
|
||||
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
|
||||
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "Dies wird einmalig angezeigt für",
|
||||
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
|
||||
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
|
||||
"i18n:govoplan-access.time.6c82e6dd": "Time",
|
||||
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Übermitteln Sie es über einen separaten sicheren Kanal.",
|
||||
"i18n:govoplan-access.type.3deb7456": "Typ",
|
||||
"i18n:govoplan-access.updated.f2f8570d": "Aktualisiert",
|
||||
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
|
||||
"i18n:govoplan-access.user_assignments.bc7cc801": "Benutzerzuweisungen",
|
||||
"i18n:govoplan-access.user_file_connections.996444a0": "Benutzer-Dateiverbindungen",
|
||||
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
|
||||
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
|
||||
"i18n:govoplan-access.user_mail_profiles.f54a845a": "Benutzer-Mailprofile",
|
||||
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
|
||||
"i18n:govoplan-access.user_retention.f0966bbf": "Benutzeraufbewahrung",
|
||||
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
|
||||
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
|
||||
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
|
||||
"i18n:govoplan-access.user.6eb0c612": "USER",
|
||||
"i18n:govoplan-access.user.9f8a2389": "Benutzer",
|
||||
"i18n:govoplan-access.users.57f2b181": "Benutzer",
|
||||
"i18n:govoplan-access.users.81651889": "users,",
|
||||
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
|
||||
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
|
||||
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
|
||||
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
|
||||
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
|
||||
"i18n:govoplan-access.yes.5397e058": "Ja",
|
||||
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
||||
}
|
||||
};
|
||||
5
webui/src/index.ts
Normal file
5
webui/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/admin";
|
||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
33
webui/src/module.ts
Normal file
33
webui/src/module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { adminReadScopes } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
|
||||
if (!onAuthChange) {
|
||||
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
|
||||
}
|
||||
return createElement(AdminPage, { settings, auth, onAuthChange });
|
||||
}
|
||||
|
||||
export const accessModule: PlatformWebModule = {
|
||||
id: "access",
|
||||
label: "i18n:govoplan-access.access.2f81a22d",
|
||||
version: "1.0.0",
|
||||
translations,
|
||||
navItems: [
|
||||
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
||||
|
||||
routes: [
|
||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }]
|
||||
|
||||
};
|
||||
|
||||
export default accessModule;
|
||||
Reference in New Issue
Block a user