32 Commits

Author SHA1 Message Date
a758c8f2da perf(access): provide bounded reference search 2026-07-30 01:30:04 +02:00
6295cfa840 perf(access): batch tenant summary counts 2026-07-30 01:15:16 +02:00
b6c2c89adf feat: cache immutable auth principal summaries 2026-07-29 19:24:00 +02:00
6b0dd8beab feat: manage bounded automation service accounts 2026-07-29 17:48:35 +02:00
984a015704 perf: batch authorization and activity updates 2026-07-29 14:16:28 +02:00
3df4fc5bff fix: wrap administration fallback viewport 2026-07-28 22:50:11 +02:00
1b57df7753 Expose administration View surfaces 2026-07-28 21:04:54 +02:00
bf1ecc54b3 Expose credential envelope administration 2026-07-28 19:32:45 +02:00
fdfcfbb440 Resolve current principals for automation 2026-07-28 15:03:50 +02:00
f1d64d247e chore(access): bump version to 0.1.11 2026-07-22 03:43:27 +02:00
198803d5b9 feat(access): expose tenant-bounded people search 2026-07-22 03:02:05 +02:00
79781662e8 chore(access): bump version to 0.1.10 2026-07-21 20:48:22 +02:00
4075ac4d73 feat(access): derive authenticated baseline roles 2026-07-21 20:48:21 +02:00
21f77ffc50 refactor(webui): consume core access explanations 2026-07-21 13:18:36 +02:00
d0ef0531c0 refactor(webui): use central admin selections 2026-07-21 12:34:39 +02:00
c2917459a4 refactor(webui): centralize access table actions 2026-07-21 12:23:31 +02:00
fb1b573855 fix(release): align access runtime version 2026-07-21 12:23:12 +02:00
8c74e360d2 feat(auth): throttle password login attempts 2026-07-21 12:23:12 +02:00
e91935c03a Clean Access audit and test resources 2026-07-21 03:16:32 +02:00
01d082e552 Equalize Access login failure handling 2026-07-21 03:16:31 +02:00
90d8f65835 chore(i18n): refresh access translations 2026-07-20 20:03:19 +02:00
1c8e18e109 fix(permissions): preserve canonical granted scopes 2026-07-20 20:03:11 +02:00
ab07075a67 intermittent commit 2026-07-14 13:22:09 +02:00
f2afcaa09c Release v0.1.8 2026-07-11 16:49:00 +02:00
d08a41fb56 Release v0.1.7 2026-07-11 02:34:55 +02:00
002d12e417 Prefer canonical identity and organization directories 2026-07-11 01:13:53 +02:00
5b8baa6cde Make tenancy an optional access integration 2026-07-11 00:40:09 +02:00
d6a0d6241d Expose resource access explanation API 2026-07-11 00:39:39 +02:00
e32841077c Move access permissions to access module 2026-07-10 23:27:48 +02:00
a7c486788e Sync GovOPlaN module state 2026-07-10 21:57:20 +02:00
c71de86a1f Use core auth and scope contracts 2026-07-10 17:33:43 +02:00
04681f1d75 feat: add access module boundary migrations 2026-07-10 12:51:16 +02:00
75 changed files with 16965 additions and 1567 deletions

265
.gitignore vendored
View File

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

View File

@@ -1,28 +1,46 @@
# GovOPlaN Access # GovOPlaN Access
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-access` is the platform module for GovOPlaN identity, `govoplan-access` is the platform module for GovOPlaN identity,
authentication, sessions, API keys, RBAC, groups, users, and access authentication, sessions, API keys, RBAC, groups, users, and access
administration. administration.
The repository contains the extracted access seed implementation under The repository contains the extracted access seed implementation under
`src/govoplan_access/backend`. Session, API-key, and password helper services, `src/govoplan_access/backend`. Session, API-key, and password helper services,
interactive auth routes, FastAPI auth dependencies, and the legacy interactive auth routes, and administration routers are owned here. Access
administration router are owned here. Access-side admin service helpers remain 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 here for users, groups, roles, system accounts, sessions, API keys, tenant
access enforcement, admin/audit lookup capabilities, tenant owner access enforcement, admin/audit lookup capabilities, tenant owner
provisioning, and governance-template materialization into access-owned groups provisioning, and governance-template materialization into access-owned groups
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The and roles. The module also enforces narrowly declared managed
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
derived from the active manifest set for every authenticated tenant member,
without writing assignments during an authorization read. The optional role
row is a non-assignable administration projection, not the source of the
automatic grant. Domain permissions and resource policy remain separate checks.
Governance-template metadata CRUD lives in `govoplan-admin`. The
transitional administration WebUI route shell and transitional administration WebUI route shell and
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
system administration panels are contributed by `@govoplan/admin-webui` through system administration panels are contributed by `@govoplan/admin-webui` through
core's `admin.sections` UI capability. Live access ORM models are defined here core's `admin.sections` UI capability. Live access ORM models are defined here
while retaining their historical table names; tenant records live in with `access_*` table names. Access stores current scope identifiers in
`govoplan-tenancy`, governance templates in `govoplan-admin`, audit logs in `tenant_id` columns but does not hard-depend on the tenancy module package.
`govoplan-audit`, and system settings in `govoplan-core`. The staged Tenancy-specific tenant administration and tenant resolver behavior live in
extraction path is documented 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` - `/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 ## Initial Ownership
@@ -33,13 +51,17 @@ This module will own:
- API keys - API keys
- legacy administration route contribution during the transition - legacy administration route contribution during the transition
- tenant-local users - 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 - groups and memberships
- roles and role assignments - roles and role assignments
- principal resolution - principal resolution
- permission evaluation - permission evaluation
- access administration backend routes - access administration backend routes
- access administration WebUI route contributions - access administration WebUI route contributions
- published FastAPI auth dependency API - 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 - access administration, tenant provisioning, and governance materializer
capabilities capabilities
- access-owned migrations - access-owned migrations
@@ -47,8 +69,28 @@ This module will own:
The governance-template routes under `/admin/system/governance-templates` are The governance-template routes under `/admin/system/governance-templates` are
contributed by `govoplan-admin`; access must not register those routes. contributed by `govoplan-admin`; access must not register those routes.
`govoplan-access` depends on `govoplan-tenancy`; the registry loads tenancy `govoplan-access` treats `govoplan-tenancy` as optional. Access can run in the
before access for authenticated platform composition. 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).
For scheduled and event-driven work, Access provides
`auth.automationPrincipalProvider`. Automation records store only an owner
account/membership reference and an explicit least-privilege scope grant, not
a session or API token. At delivery time Access rebuilds the principal from
current roles, groups, functions, and delegations and intersects that
authorization with the stored grant. Missing, inactive, moved, or
under-authorized owners fail closed before module work starts.
## WebUI Package ## WebUI Package
@@ -74,7 +116,7 @@ available:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply /mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
``` ```
## Development Install ## Development Install
@@ -85,3 +127,21 @@ From the core checkout:
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-access ./.venv/bin/python -m pip install -e ../govoplan-access
``` ```
## Login Throttling
Interactive password login is throttled by normalized global login identity and by
the directly connected client address. The deployment defaults are 10 identity
failures and 100 client failures in a 15-minute window. Counters use
`REDIS_URL` when Redis is reachable, allowing all API workers to share the same
limits. Local development and Redis outages fall back automatically to a
bounded, process-local counter; authentication remains available, but limits
then apply per API process.
The deployment settings are `AUTH_LOGIN_THROTTLE_ENABLED`,
`AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT`, `AUTH_LOGIN_THROTTLE_CLIENT_LIMIT`,
`AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`, and
`AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS`. Client-supplied forwarding headers
are not trusted for throttling. A reverse proxy should pass the real peer
address only through the platform's separately configured trusted-proxy
boundary.

View 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.

View 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.

View 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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.6", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,8 +18,8 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.6" version = "0.1.11"
description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.11",
"govoplan-tenancy>=0.1.6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]

View File

@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.4" __version__ = "0.1.11"

View 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",
]

View File

@@ -20,16 +20,18 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import ensure_identity_for_account
from govoplan_access.backend.security.passwords import hash_password from govoplan_access.backend.security.passwords import hash_password
from govoplan_core.security.permissions import ( from govoplan_access.backend.permissions.catalog import (
DEFAULT_SYSTEM_ROLES, delegateable_system_scopes,
DEFAULT_TENANT_ROLES, delegateable_tenant_scopes,
normalize_email, normalize_email,
scopes_grant, scopes_grant,
validate_system_permissions, validate_system_permissions,
validate_tenant_permissions, validate_tenant_permissions,
role_templates_for_level,
) )
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#" _TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
@@ -47,21 +49,23 @@ def generate_temporary_password(length: int = 20) -> str:
def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]: def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]:
definitions = DEFAULT_TENANT_ROLES if tenant is not None else DEFAULT_SYSTEM_ROLES level = "tenant" if tenant is not None else "system"
roles: dict[str, Role] = {} roles: dict[str, Role] = {}
for slug, definition in definitions.items(): for template in role_templates_for_level(level):
query = session.query(Role).filter(Role.slug == slug) 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)) 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() 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 = not template.default_authenticated
if role is None: if role is None:
role = Role( role = Role(
tenant_id=tenant.id if tenant is not None else None, tenant_id=tenant.id if tenant is not None else None,
slug=slug, slug=template.slug,
name=str(definition["name"]), name=template.name,
description=str(definition.get("description") or "") or None, description=template.description or None,
permissions=list(definition["permissions"]), permissions=list(template.permissions),
is_builtin=bool(definition.get("is_builtin", True)), is_builtin=is_builtin,
is_assignable=bool(definition.get("is_assignable", True)), is_assignable=is_assignable,
) )
session.add(role) session.add(role)
session.flush() session.flush()
@@ -69,18 +73,30 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
# Tenant built-ins and explicitly managed system roles remain # Tenant built-ins and explicitly managed system roles remain
# code-defined. Seeded, non-protected system roles are only created # code-defined. Seeded, non-protected system roles are only created
# here and can subsequently be administered in the System roles UI. # here and can subsequently be administered in the System roles UI.
managed = tenant is not None or bool(definition.get("managed", False)) managed = _template_updates_existing(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
if managed: if managed:
role.name = str(definition["name"]) role.name = template.name
role.description = str(definition.get("description") or "") or None role.description = template.description or None
role.permissions = list(definition["permissions"]) role.permissions = list(template.permissions)
role.is_builtin = bool(definition.get("is_builtin", True)) role.is_builtin = is_builtin
role.is_assignable = bool(definition.get("is_assignable", True)) role.is_assignable = is_assignable
session.add(role) session.add(role)
roles[slug] = role roles[template.slug] = role
return roles 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( def get_or_create_account(
session: Session, session: Session,
*, *,
@@ -98,6 +114,7 @@ def get_or_create_account(
raise AdminConflictError( raise AdminConflictError(
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership." "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 return account, False, None
temporary_password = password or generate_temporary_password() temporary_password = password or generate_temporary_password()
@@ -112,6 +129,7 @@ def get_or_create_account(
) )
session.add(account) session.add(account)
session.flush() session.flush()
ensure_identity_for_account(session, account)
return account, True, temporary_password return account, True, temporary_password
@@ -182,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None: def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids)) default_slugs = {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
default_roles = (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.all()
if default_slugs
else []
)
default_role_ids = {role.id for role in default_roles}
requested_ids = set(role_ids) - default_role_ids
ids = sorted(requested_ids)
roles = ( roles = (
session.query(Role) session.query(Role)
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True)) .filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
@@ -378,8 +413,6 @@ def delete_system_role(session: Session, role: Role) -> None:
def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None: 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.""" """Prevent a system administrator from defining stronger roles than they hold."""
from govoplan_core.security.permissions import delegateable_system_scopes
requested = set(validate_system_permissions(permissions)) requested = set(validate_system_permissions(permissions))
if "system:*" in requested: if "system:*" in requested:
if not scopes_grant(actor_scopes, "system:*"): if not scopes_grant(actor_scopes, "system:*"):
@@ -543,7 +576,6 @@ def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None: 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.""" """Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
from govoplan_core.security.permissions import delegateable_tenant_scopes
requested = set(validate_tenant_permissions(permissions)) requested = set(validate_tenant_permissions(permissions))
if "tenant:*" in requested: if "tenant:*" in requested:
# Only a tenant owner-equivalent can delegate the wildcard. # Only a tenant owner-equivalent can delegate the wildcard.

View File

@@ -2,14 +2,109 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from sqlalchemy import func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, ApiKey, Role, User from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
from govoplan_core.core.access import AccessAdministration from govoplan_core.core.access import AccessAdministration
class SqlAccessAdministration(AccessAdministration): class SqlAccessAdministration(AccessAdministration):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
db = _session(session)
users, active_users = (
db.query(
func.count(User.id),
func.coalesce(
func.sum(case((User.is_active.is_(True), 1), else_=0)),
0,
),
)
.filter(User.tenant_id == tenant_id)
.one()
)
api_keys, active_api_keys = (
db.query(
func.count(ApiKey.id),
func.coalesce(
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
0,
),
)
.filter(ApiKey.tenant_id == tenant_id)
.one()
)
return {
"users": int(users),
"active_users": int(active_users),
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
"api_keys": int(api_keys),
"active_api_keys": int(active_api_keys),
}
def tenant_counts_many(
self,
session: object,
tenant_ids: Sequence[str],
) -> Mapping[str, Mapping[str, int]]:
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
if not ids:
return {}
db = _session(session)
counts: dict[str, dict[str, int]] = {
tenant_id: {
"users": 0,
"active_users": 0,
"groups": 0,
"api_keys": 0,
"active_api_keys": 0,
}
for tenant_id in ids
}
user_rows = (
db.query(
User.tenant_id,
func.count(User.id),
func.coalesce(
func.sum(case((User.is_active.is_(True), 1), else_=0)),
0,
),
)
.filter(User.tenant_id.in_(ids))
.group_by(User.tenant_id)
.all()
)
for tenant_id, users, active_users in user_rows:
counts[tenant_id]["users"] = int(users)
counts[tenant_id]["active_users"] = int(active_users)
group_rows = (
db.query(Group.tenant_id, func.count(Group.id))
.filter(Group.tenant_id.in_(ids))
.group_by(Group.tenant_id)
.all()
)
for tenant_id, groups in group_rows:
counts[tenant_id]["groups"] = int(groups)
api_key_rows = (
db.query(
ApiKey.tenant_id,
func.count(ApiKey.id),
func.coalesce(
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
0,
),
)
.filter(ApiKey.tenant_id.in_(ids))
.group_by(ApiKey.tenant_id)
.all()
)
for tenant_id, api_keys, active_api_keys in api_key_rows:
counts[tenant_id]["api_keys"] = int(api_keys)
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
return counts
def system_account_count(self, session: object) -> int: def system_account_count(self, session: object) -> int:
db = _session(session) db = _session(session)
return db.query(Account).count() return db.query(Account).count()
@@ -50,6 +145,50 @@ class SqlAccessAdministration(AccessAdministration):
) )
return tuple(user_id for (user_id,) in rows) return tuple(user_id for (user_id,) in rows)
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
user = _session(session).get(User, user_id)
if user is None or user.tenant_id != tenant_id:
return None
return dict(user.settings or {})
def set_user_settings(
self,
session: object,
user_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
db = _session(session)
user = db.get(User, user_id)
if user is None or user.tenant_id != tenant_id:
return None
user.settings = dict(settings)
db.add(user)
return dict(user.settings or {})
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
group = _session(session).get(Group, group_id)
if group is None or group.tenant_id != tenant_id:
return None
return dict(group.settings or {})
def set_group_settings(
self,
session: object,
group_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
db = _session(session)
group = db.get(Group, group_id)
if group is None or group.tenant_id != tenant_id:
return None
group.settings = dict(settings)
db.add(group)
return dict(group.settings or {})
def _session(session: object) -> Session: def _session(session: object) -> Session:
if not isinstance(session, Session): if not isinstance(session, Session):

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ( from govoplan_access.backend.admin.service import (
@@ -21,10 +24,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
) )
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
collect_direct_user_roles, collect_direct_user_roles,
collect_system_roles,
collect_user_groups, collect_user_groups,
collect_user_scopes, 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.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
@@ -36,15 +43,22 @@ from govoplan_access.backend.db.models import (
Tenant, Tenant,
User, User,
UserGroupMembership, 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,
scopes_grant,
) )
from govoplan_core.security.permissions import effective_permission_count
def _http_admin_error(exc: Exception) -> HTTPException: def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError): if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError): if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@@ -75,13 +89,147 @@ def _resolve_tenant(
return tenant return tenant
def _role_summary(session: Session, role: Role) -> RoleSummary: 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 group_count = 0
if role.tenant_id is None: if role.tenant_id is None:
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count() 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" permission_level = "system"
else: else:
user_count, group_count = role_assignment_counts(session, role.id) 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" permission_level = "tenant"
permissions = list(role.permissions or []) permissions = list(role.permissions or [])
return RoleSummary( return RoleSummary(
@@ -101,19 +249,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
) )
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary: def _group_summary(
member_ids = [ session: Session,
row[0] group: Group,
for row in session.query(UserGroupMembership.user_id) *,
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) include_members: bool = True,
.all() 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 = (
session.query(Role) roles_by_group.get(group.id, [])
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id) if roles_by_group is not None
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id) else (
.order_by(Role.name.asc()) session.query(Role)
.all() .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( return GroupSummary(
id=group.id, id=group.id,
@@ -123,7 +287,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
is_active=group.is_active, is_active=group.is_active,
member_count=len(member_ids), member_count=len(member_ids),
member_ids=member_ids if include_members else [], member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role) for role in roles], roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
created_at=group.created_at, created_at=group.created_at,
updated_at=group.updated_at, updated_at=group.updated_at,
system_template_id=group.system_template_id, system_template_id=group.system_template_id,
@@ -131,12 +295,39 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
) )
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem: def _user_item(
account = session.get(Account, user.account_id) 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: if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user) groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
roles = collect_direct_user_roles(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) effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
return UserAdminItem( return UserAdminItem(
id=user.id, id=user.id,
@@ -148,9 +339,21 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
account_is_active=account.is_active, account_is_active=account.is_active,
password_reset_required=account.password_reset_required, password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at, last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups], groups=[
roles=[_role_summary(session, role) for role in roles], _group_summary(
effective_scopes=collect_user_scopes(session, user, include_system=False), 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_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1, is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
created_at=user.created_at, created_at=user.created_at,
@@ -158,47 +361,255 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
) )
def _system_account_item(session: Session, account: Account) -> SystemAccountItem: def _system_membership_rows(
memberships = ( session: Session,
account_ids: list[str],
) -> list[tuple[User, Tenant]]:
return (
session.query(User, Tenant) session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id) .join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id) .filter(User.account_id.in_(account_ids))
.order_by(Tenant.name.asc()) .order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
.all() .all()
) )
owner_ids_by_tenant = {
tenant.id: tenant_owner_user_ids(session, tenant.id)
for _, tenant in memberships def _memberships_by_account(
membership_rows: list[tuple[User, Tenant]],
) -> dict[str, list[tuple[User, Tenant]]]:
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
for user, tenant in membership_rows:
memberships_by_account[user.account_id].append((user, tenant))
return memberships_by_account
def _system_direct_roles_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Role]]:
roles_by_user: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return roles_by_user
rows = (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(UserRoleAssignment.user_id.in_(user_ids))
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
.all()
)
for user_id, role in rows:
roles_by_user[user_id].append(role)
return roles_by_user
def _system_groups_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Group]]:
groups_by_user: dict[str, list[Group]] = defaultdict(list)
if not user_ids:
return groups_by_user
rows = (
session.query(UserGroupMembership.user_id, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
)
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
.all()
)
for user_id, group in rows:
groups_by_user[user_id].append(group)
return groups_by_user
def _system_group_roles_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Role]]:
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return group_roles_by_user
rows = (
session.query(UserGroupMembership.user_id, Role)
.join(
GroupRoleAssignment,
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
)
.all()
)
for user_id, role in rows:
group_roles_by_user[user_id].append(role)
return group_roles_by_user
def _system_owner_ids_by_tenant(
membership_rows: list[tuple[User, Tenant]],
*,
accounts_by_id: dict[str, Account],
direct_roles_by_user: dict[str, list[Role]],
group_roles_by_user: dict[str, list[Role]],
) -> dict[str, set[str]]:
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
for user, tenant in membership_rows:
account = accounts_by_id[user.account_id]
if not user.is_active or not account.is_active:
continue
effective_permissions = [
permission
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
for permission in (role.permissions or [])
]
if (
scopes_grant(effective_permissions, "admin:roles:write")
and scopes_grant(effective_permissions, "campaign:send")
):
owner_ids_by_tenant[tenant.id].add(user.id)
return owner_ids_by_tenant
def _system_roles_for_accounts(
session: Session,
account_ids: list[str],
) -> tuple[dict[str, list[Role]], dict[str, int]]:
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
system_role_ids: set[str] = set()
rows = (
session.query(SystemRoleAssignment.account_id, Role)
.join(Role, Role.id == SystemRoleAssignment.role_id)
.filter(
SystemRoleAssignment.account_id.in_(account_ids),
Role.tenant_id.is_(None),
)
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
.all()
)
for account_id, role in rows:
system_roles_by_account[account_id].append(role)
system_role_ids.add(role.id)
return (
system_roles_by_account,
_system_role_assignment_counts(session, sorted(system_role_ids)),
)
def _system_membership_item(
user: User,
tenant: Tenant,
*,
roles_by_user: dict[str, list[Role]],
groups_by_user: dict[str, list[Group]],
owner_ids_by_tenant: dict[str, set[str]],
) -> dict[str, object]:
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
return {
"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 roles_by_user[user.id]],
"group_ids": [group.id for group in groups_by_user[user.id]],
"is_owner": user.id in tenant_owner_ids,
"is_last_active_owner": (
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
),
} }
def _system_account_response_item(
session: Session,
account: Account,
*,
memberships: list[tuple[User, Tenant]],
roles_by_user: dict[str, list[Role]],
groups_by_user: dict[str, list[Group]],
owner_ids_by_tenant: dict[str, set[str]],
system_roles: list[Role],
system_role_counts: dict[str, int],
) -> SystemAccountItem:
return SystemAccountItem( return SystemAccountItem(
account_id=account.id, account_id=account.id,
email=account.email, email=account.email,
display_name=account.display_name, display_name=account.display_name,
is_active=account.is_active, is_active=account.is_active,
memberships=[ memberships=[
{ _system_membership_item(
"tenant_id": tenant.id, user,
"tenant_name": tenant.name, tenant,
"user_id": user.id, roles_by_user=roles_by_user,
"is_active": user.is_active and tenant.is_active, groups_by_user=groups_by_user,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)], owner_ids_by_tenant=owner_ids_by_tenant,
"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 for user, tenant in memberships
], ],
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)], roles=[
_role_summary(
session,
role,
system_role_assignment_counts=system_role_counts,
)
for role in system_roles
],
last_login_at=account.last_login_at, last_login_at=account.last_login_at,
) )
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem: def _system_account_items(
user = session.get(User, item.user_id) session: Session,
account = session.get(Account, user.account_id) if user else None accounts: list[Account],
) -> list[SystemAccountItem]:
if not accounts:
return []
account_ids = [account.id for account in accounts]
accounts_by_id = {account.id: account for account in accounts}
membership_rows = _system_membership_rows(session, account_ids)
memberships_by_account = _memberships_by_account(membership_rows)
user_ids = [user.id for user, _tenant in membership_rows]
roles_by_user = _system_direct_roles_by_user(session, user_ids)
groups_by_user = _system_groups_by_user(session, user_ids)
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
owner_ids_by_tenant = _system_owner_ids_by_tenant(
membership_rows,
accounts_by_id=accounts_by_id,
direct_roles_by_user=roles_by_user,
group_roles_by_user=group_roles_by_user,
)
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
session,
account_ids,
)
return [
_system_account_response_item(
session,
account,
memberships=memberships_by_account[account.id],
roles_by_user=roles_by_user,
groups_by_user=groups_by_user,
owner_ids_by_tenant=owner_ids_by_tenant,
system_roles=system_roles_by_account[account.id],
system_role_counts=system_role_counts,
)
for account in accounts
]
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
return _system_account_items(session, [account])[0]
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( return ApiKeyAdminItem(
id=item.id, id=item.id,
user_id=item.user_id, user_id=item.user_id,

View File

@@ -3,39 +3,10 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
class PermissionItem(BaseModel): class PermissionItem(BaseModel):
@@ -96,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
accounts: list[TenantOwnerCandidate] accounts: list[TenantOwnerCandidate]
class PagedListResponse(BaseModel):
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class TenantCreateRequest(BaseModel): class TenantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -128,6 +106,9 @@ class TenantSettingsItem(BaseModel):
slug: str slug: str
name: str name: str
default_locale: str = Field(default="en", min_length=1, max_length=20) 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) settings: dict[str, Any] = Field(default_factory=dict)
@@ -135,6 +116,7 @@ class TenantSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20) default_locale: str = Field(min_length=1, max_length=20)
enabled_language_codes: list[str] | None = None
class RoleSummary(BaseModel): class RoleSummary(BaseModel):
@@ -153,6 +135,265 @@ class RoleSummary(BaseModel):
system_required: bool = False 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(PagedListResponse):
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(PagedListResponse):
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(PagedListResponse):
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(PagedListResponse):
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(PagedListResponse):
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): class GroupSummary(BaseModel):
id: str id: str
slug: str = Field(min_length=1, max_length=100) slug: str = Field(min_length=1, max_length=100)
@@ -180,6 +421,8 @@ class UserAdminItem(BaseModel):
last_login_at: datetime | None = None last_login_at: datetime | None = None
groups: list[GroupSummary] = Field(default_factory=list) groups: list[GroupSummary] = Field(default_factory=list)
roles: list[RoleSummary] = 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) effective_scopes: list[str] = Field(default_factory=list)
is_owner: bool = False is_owner: bool = False
is_last_active_owner: bool = False is_last_active_owner: bool = False
@@ -187,10 +430,92 @@ class UserAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class UserListResponse(BaseModel): 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] 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): class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -218,10 +543,17 @@ class UserUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class GroupListResponse(BaseModel): class GroupListResponse(PagedListResponse):
groups: list[GroupSummary] 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): class GroupCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -243,10 +575,17 @@ class GroupUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class RoleListResponse(BaseModel): class RoleListResponse(PagedListResponse):
roles: list[RoleSummary] 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): class RoleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -275,11 +614,18 @@ class SystemAccountItem(BaseModel):
last_login_at: datetime | None = None last_login_at: datetime | None = None
class SystemAccountListResponse(BaseModel): class SystemAccountListResponse(PagedListResponse):
accounts: list[SystemAccountItem] accounts: list[SystemAccountItem]
roles: list[RoleSummary] 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): class SystemAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -334,42 +680,6 @@ class PolicySourceStepItem(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel): class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -396,12 +706,124 @@ class RetentionRunResponse(BaseModel):
result: dict[str, Any] 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" # noqa: S105 - policy vocabulary.
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): class SystemSettingsItem(BaseModel):
default_locale: str = "en" default_locale: str = "en"
allow_tenant_custom_groups: bool = True allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) 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) settings: dict[str, Any] = Field(default_factory=dict)
@@ -413,6 +835,8 @@ class SystemSettingsUpdateRequest(BaseModel):
allow_tenant_custom_roles: bool allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
available_languages: list[dict[str, Any]] | None = None
enabled_language_codes: list[str] | None = None
class ApiKeyAdminItem(BaseModel): class ApiKeyAdminItem(BaseModel):
@@ -428,10 +852,17 @@ class ApiKeyAdminItem(BaseModel):
created_at: datetime created_at: datetime
class ApiKeyListResponse(BaseModel): class ApiKeyListResponse(PagedListResponse):
api_keys: list[ApiKeyAdminItem] 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): class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -445,6 +876,55 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str secret: str
class ServiceAccountItem(BaseModel):
id: str
tenant_id: str
name: str
description: str | None = None
scope_ceiling: list[str] = Field(default_factory=list)
is_active: bool
revision: int
created_by_account_id: str | None = None
updated_by_account_id: str | None = None
retired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ServiceAccountListResponse(BaseModel):
items: list[ServiceAccountItem]
class ServiceAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] = Field(
default_factory=list,
max_length=200,
)
class ServiceAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] | None = Field(
default=None,
max_length=200,
)
is_active: bool | None = None
class ServiceAccountRetireRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class AuditAdminItem(BaseModel): class AuditAdminItem(BaseModel):
id: str id: str
scope: Literal["tenant", "system"] = "tenant" scope: Literal["tenant", "system"] = "tenant"
@@ -463,3 +943,5 @@ class AuditAdminListResponse(BaseModel):
page: int = 1 page: int = 1
page_size: int = 100 page_size: int = 100
pages: int = 1 pages: int = 1
cursor: str | None = None
next_cursor: str | None = None

View File

@@ -1,42 +1,88 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import ( from govoplan_core.api.v1.schemas import (
AuthGroupsResponse,
AuthProfileResponse,
AuthRolesResponse,
AuthShellResponse,
AuthSessionResponse,
AuthSessionUserInfo,
GroupInfo, GroupInfo,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
MeResponse, MeResponse,
PrincipalContextInfo,
ProfileUpdateRequest, ProfileUpdateRequest,
RoleInfo, RoleInfo,
SwitchTenantRequest, SwitchTenantRequest,
TenantInfo, TenantInfo,
TenantMembershipInfo, TenantMembershipInfo,
UserInfo, 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_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.audit.logging import audit_from_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_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User 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.db.session import get_session
from govoplan_core.security.permissions import normalize_email, scopes_grant 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.security.time import utc_now
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_access.backend.security.passwords import verify_password 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 DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.login_throttle import (
LoginThrottle,
LoginThrottleDecision,
build_login_throttle,
)
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_authorization_context,
collect_system_roles, collect_system_roles,
collect_tenant_memberships, collect_tenant_memberships,
collect_user_groups, collect_user_groups,
collect_user_roles, collect_user_roles,
collect_user_scopes, collect_user_scopes,
create_auth_session, create_auth_session,
switch_auth_session_tenant, verify_auth_session_csrf,
) )
router = APIRouter(prefix="/auth", tags=["auth"]) 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: def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip() value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}: if value not in {"lax", "strict", "none"}:
@@ -68,17 +114,32 @@ def _clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs) response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
def _tenant_info(tenant: Tenant) -> TenantInfo: def _tenant_info(tenant: Tenant, *, enabled_language_codes: list[str] | None = None) -> TenantInfo:
return TenantInfo( return TenantInfo(
id=tenant.id, id=tenant.id,
slug=tenant.slug, slug=tenant.slug,
name=tenant.name, name=tenant.name,
is_active=tenant.is_active, is_active=tenant.is_active,
default_locale=tenant.default_locale, default_locale=tenant.default_locale,
enabled_language_codes=enabled_language_codes or [],
) )
def _user_info(user: User, account: Account) -> UserInfo: 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( return UserInfo(
id=user.id, id=user.id,
account_id=account.id, account_id=account.id,
@@ -87,6 +148,21 @@ def _user_info(user: User, account: Account) -> UserInfo:
tenant_display_name=user.display_name, tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin, is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required, 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,
) )
@@ -107,10 +183,20 @@ def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups] return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]: 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] = [] memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account): for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user) 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( memberships.append(
TenantMembershipInfo( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
@@ -124,13 +210,58 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
return memberships 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]: def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = ( account = (
session.query(Account) session.query(Account)
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True)) .filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none() .one_or_none()
) )
if account is None or not verify_password(payload.password, account.password_hash): password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
password_matches = verify_password(payload.password, password_hash)
if account is None or not account.password_hash or not password_matches:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = ( query = (
@@ -146,10 +277,321 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
query = query.filter(Tenant.slug == payload.tenant_slug) query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first() row = query.order_by(Tenant.name.asc()).first()
if row is None: if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership") # Keep every authentication failure generic so callers cannot infer
# whether an account exists but lacks an active tenant membership.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
return account, row[0], row[1] return account, row[0], row[1]
@lru_cache(maxsize=1)
def _configured_login_throttle() -> LoginThrottle | None:
if not settings.auth_login_throttle_enabled:
return None
return build_login_throttle(
redis_url=settings.redis_url,
identity_limit=settings.auth_login_throttle_identity_limit,
client_limit=settings.auth_login_throttle_client_limit,
window_seconds=settings.auth_login_throttle_window_seconds,
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
)
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
retry_after = max(1, decision.retry_after_seconds)
# The detail deliberately matches every credential failure. Status and
# Retry-After communicate endpoint throttling without disclosing whether
# the supplied identity exists or has an active membership.
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Invalid login",
headers={"Retry-After": str(retry_after)},
)
def _resolve_throttled_login_user(
session: Session,
payload: LoginRequest,
request: Request,
) -> tuple[Account, User, Tenant]:
throttle = _configured_login_throttle()
if throttle is None:
return _resolve_login_user(session, payload)
normalized_email = normalize_email(payload.email)
client_address = request.client.host if request.client else None
context = {
"normalized_email": normalized_email,
"tenant_slug": payload.tenant_slug,
"client_address": client_address,
}
decision = throttle.check(**context)
if not decision.allowed:
_raise_login_throttled(decision)
try:
resolved = _resolve_login_user(session, payload)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
decision = throttle.record_failure(**context)
if not decision.allowed:
_raise_login_throttled(decision)
raise
throttle.record_success(
normalized_email=normalized_email,
tenant_slug=payload.tenant_slug,
)
return resolved
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( def _me_response(
session: Session, session: Session,
*, *,
@@ -157,14 +599,50 @@ def _me_response(
user: User, user: User,
tenant: Tenant, tenant: Tenant,
effective_scopes: list[str] | None = None, 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_system: bool = True,
include_all_memberships: 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: ) -> MeResponse:
tenant_roles = collect_user_roles(session, user) if tenant_roles is None:
system_roles = collect_system_roles(session, account) if include_system else [] tenant_roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user) if system_roles is None:
active_tenant = _tenant_info(tenant) system_roles = collect_system_roles(session, account) if include_system else []
memberships = _tenant_memberships(session, account) if include_all_memberships 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( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
slug=tenant.slug, slug=tenant.slug,
@@ -175,22 +653,54 @@ def _me_response(
) )
] ]
return MeResponse( return MeResponse(
user=_user_info(user, account), user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
tenant=active_tenant, tenant=active_tenant,
active_tenant=active_tenant, active_tenant=active_tenant,
tenants=memberships, tenants=memberships,
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system), scopes=scopes,
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"), roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
groups=_groups_info(groups), 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) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_throttled_login_user(session, payload, request)
me_payload = _me_response(session, account=account, user=user, tenant=tenant) 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) maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode), detail=maintenance_response_detail(maintenance_mode),
@@ -209,65 +719,166 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
return LoginResponse( return LoginResponse(
access_token=created.token, access_token=created.token,
expires_at=created.model.expires_at, expires_at=created.model.expires_at,
**me_payload.model_dump(), **_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) @router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id) tenant = session.get(Tenant, principal.tenant_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") 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( return _me_response(
session, session,
account=principal.account, account=principal.account,
user=principal.user, user=principal.user,
tenant=tenant, tenant=tenant,
effective_scopes=principal.scopes, 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_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None,
identity_id=principal.principal.identity_id,
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=MeResponse) @router.patch("/profile", response_model=AuthProfileResponse)
def update_profile( def update_profile(
payload: ProfileUpdateRequest, payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal), request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
if principal.auth_session is None: context = _resolve_auth_context(request, session)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") _verify_profile_mutation_allowed(request, context)
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None context.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account) session.add(context.account)
if "tenant_display_name" in payload.model_fields_set: if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user) 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_from_principal( audit_event(
session, session,
principal, tenant_id=context.tenant.id,
user_id=context.user.id,
action="profile.updated", action="profile.updated",
object_type="account", object_type="account",
object_id=principal.account.id, object_id=context.account.id,
details={"fields": sorted(payload.model_fields_set)}, details={"fields": sorted(payload.model_fields_set)},
) )
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")
session.commit() session.commit()
return _me_response( return _profile_response(session, context)
session,
account=principal.account,
user=principal.user,
tenant=tenant,
include_system=True,
include_all_memberships=True,
)
@router.post("/switch-tenant", response_model=MeResponse) @router.post("/switch-tenant", response_model=AuthShellResponse)
def switch_tenant( def switch_tenant(
payload: SwitchTenantRequest, payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
@@ -276,14 +887,31 @@ def switch_tenant(
if principal.auth_session is None: if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try: try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id) switched = AccessTenantContextSwitcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id)
except LookupError as exc: except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id) tenant = session.get(Tenant, switched.tenant_id)
membership = session.get(User, switched.membership_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if membership is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
session.commit() session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant) 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") @router.post("/logout")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
from govoplan_access.backend.api.v1.admin_schemas import (
ServiceAccountCreateRequest,
ServiceAccountItem,
ServiceAccountListResponse,
ServiceAccountRetireRequest,
ServiceAccountUpdateRequest,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
ServiceAccountError,
ServiceAccountNotFoundError,
create_service_account,
get_service_account,
list_service_accounts,
retire_service_account,
update_service_account,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.db.session import get_session
router = APIRouter(
prefix="/admin/service-accounts",
tags=["admin", "service-accounts"],
)
@router.get("", response_model=ServiceAccountListResponse)
def list_managed_service_accounts(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
return ServiceAccountListResponse(
items=[
_service_account_item(item)
for item in list_service_accounts(
session,
tenant_id=tenant.id,
)
]
)
@router.get(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def get_managed_service_account(
service_account_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = get_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
)
except ServiceAccountError as exc:
raise _service_account_http_error(exc) from exc
return _service_account_item(item)
@router.post(
"",
response_model=ServiceAccountItem,
status_code=status.HTTP_201_CREATED,
)
def create_managed_service_account(
payload: ServiceAccountCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = create_service_account(
session,
tenant=tenant,
principal=principal,
name=payload.name,
description=payload.description,
scope_ceiling=payload.scope_ceiling,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.created",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"name": item.name,
"scope_ceiling": list(item.scope_ceiling),
},
)
session.commit()
return _service_account_item(item)
@router.patch(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def update_managed_service_account(
service_account_id: str,
payload: ServiceAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
changes = {
field: getattr(payload, field)
for field in payload.model_fields_set
if field != "expected_revision"
}
for field in ("name", "scope_ceiling", "is_active"):
if field in changes and changes[field] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"{field} cannot be null",
)
try:
item = update_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
changes=changes,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.updated",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"changed_fields": sorted(changes),
"revision": item.revision,
},
)
session.commit()
return _service_account_item(item)
@router.post(
"/{service_account_id}/retire",
response_model=ServiceAccountItem,
)
def retire_managed_service_account(
service_account_id: str,
payload: ServiceAccountRetireRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = retire_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.retired",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={"revision": item.revision},
)
session.commit()
return _service_account_item(item)
def _service_account_item(item: object) -> ServiceAccountItem:
return ServiceAccountItem.model_validate(
item,
from_attributes=True,
)
def _service_account_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, ServiceAccountNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, ServiceAccountConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
if isinstance(exc, PermissionError):
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
__all__ = ["router"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
from threading import Lock
from time import monotonic
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.principal_cache import AuthPrincipalRevision
@dataclass(frozen=True, slots=True)
class CachedPrincipal:
principal: PrincipalRef
revision: AuthPrincipalRevision
stored_at: float
class PrincipalSummaryCache:
"""A bounded process-local cache containing no ORM or secret objects."""
def __init__(self) -> None:
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
self._lock = Lock()
def get(
self,
token_digest: str,
*,
session_ttl_seconds: int,
api_key_ttl_seconds: int,
) -> CachedPrincipal | None:
with self._lock:
entry = self._entries.get(token_digest)
if entry is None:
return None
ttl = (
api_key_ttl_seconds
if entry.principal.auth_method == "api_key"
else session_ttl_seconds
)
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
self._entries.pop(token_digest, None)
return None
self._entries.move_to_end(token_digest)
return entry
def put(
self,
token_digest: str,
*,
principal: PrincipalRef,
revision: AuthPrincipalRevision,
max_entries: int,
) -> None:
with self._lock:
self._entries[token_digest] = CachedPrincipal(
principal=principal,
revision=revision,
stored_at=monotonic(),
)
self._entries.move_to_end(token_digest)
while len(self._entries) > max(1, max_entries):
self._entries.popitem(last=False)
def discard(self, token_digest: str) -> None:
with self._lock:
self._entries.pop(token_digest, None)
def clear(self) -> None:
with self._lock:
self._entries.clear()
principal_summary_cache = PrincipalSummaryCache()
__all__ = [
"CachedPrincipal",
"PrincipalSummaryCache",
"principal_summary_cache",
]

View File

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

View File

@@ -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,
)

View File

@@ -4,11 +4,22 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_access.backend.db.base import AccessBase, TimestampMixin from govoplan_access.backend.db.base import AccessBase, TimestampMixin
from govoplan_tenancy.backend.db.models import Tenant from govoplan_core.tenancy.scope import Tenant
def new_uuid() -> str: def new_uuid() -> str:
@@ -18,7 +29,7 @@ def new_uuid() -> str:
class Account(AccessBase, TimestampMixin): class Account(AccessBase, TimestampMixin):
"""Global login identity shared by one or more tenant memberships.""" """Global login identity shared by one or more tenant memberships."""
__tablename__ = "accounts" __tablename__ = "access_accounts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(320), nullable=False) email: Mapped[str] = mapped_column(String(320), nullable=False)
@@ -31,22 +42,70 @@ class Account(AccessBase, TimestampMixin):
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
memberships: Mapped[list[User]] = relationship(back_populates="account") 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") auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship( system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
back_populates="account", cascade="all, delete-orphan" back_populates="account", cascade="all, delete-orphan"
) )
class Identity(AccessBase, TimestampMixin):
__tablename__ = "access_identities"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
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)
account_links: Mapped[list[IdentityAccountLink]] = relationship(
back_populates="identity", cascade="all, delete-orphan"
)
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)
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)
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): class User(AccessBase, TimestampMixin):
__tablename__ = "users" __tablename__ = "access_users"
__table_args__ = ( __table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"), UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"), UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), 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) email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -57,18 +116,102 @@ class User(AccessBase, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) 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) mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
tenant: Mapped[Tenant] = relationship(back_populates="users")
account: Mapped[Account] = relationship(back_populates="memberships") account: Mapped[Account] = relationship(back_populates="memberships")
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") 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") auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
class ServiceAccount(AccessBase, TimestampMixin):
"""Managed non-login principal for current-authority automation."""
__tablename__ = "access_service_accounts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
)
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,
)
membership_id: Mapped[str] = mapped_column(
ForeignKey("access_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
normalized_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
description: Mapped[str | None] = mapped_column(Text)
scope_ceiling: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
created_by_account_id: Mapped[str | None] = mapped_column(
ForeignKey("access_accounts.id", ondelete="SET NULL"),
nullable=True,
)
updated_by_account_id: Mapped[str | None] = mapped_column(
ForeignKey("access_accounts.id", ondelete="SET NULL"),
nullable=True,
)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
class Group(AccessBase, TimestampMixin): class Group(AccessBase, TimestampMixin):
__tablename__ = "groups" __tablename__ = "access_groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("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) slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -80,7 +223,7 @@ class Group(AccessBase, TimestampMixin):
class Role(AccessBase, TimestampMixin): class Role(AccessBase, TimestampMixin):
__tablename__ = "roles" __tablename__ = "access_roles"
__table_args__ = ( __table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"), UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
Index( Index(
@@ -93,7 +236,7 @@ class Role(AccessBase, TimestampMixin):
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("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) slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -104,54 +247,168 @@ class Role(AccessBase, TimestampMixin):
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
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__ = (
UniqueConstraint(
"tenant_id",
"source_module",
"function_id",
"role_id",
name="uq_external_function_role_assignments",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_module: Mapped[str] = mapped_column(String(50), default="organizations", nullable=False, index=True)
function_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class FunctionAssignment(AccessBase, TimestampMixin):
__tablename__ = "access_function_assignments"
__table_args__ = (
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)
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): class SystemRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "system_role_assignments" __tablename__ = "access_system_role_assignments"
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.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") account: Mapped[Account] = relationship(back_populates="system_role_assignments")
role: Mapped[Role] = relationship() role: Mapped[Role] = relationship()
class UserGroupMembership(AccessBase, TimestampMixin): class UserGroupMembership(AccessBase, TimestampMixin):
__tablename__ = "user_group_memberships" __tablename__ = "access_user_group_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), 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("groups.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): class UserRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "user_role_assignments" __tablename__ = "access_user_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), 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("roles.id", ondelete="CASCADE"), nullable=False, index=True) role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class GroupRoleAssignment(AccessBase, TimestampMixin): class GroupRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "group_role_assignments" __tablename__ = "access_group_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), 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("roles.id", ondelete="CASCADE"), nullable=False, index=True) role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ApiKey(AccessBase, TimestampMixin): class ApiKey(AccessBase, TimestampMixin):
__tablename__ = "api_keys" __tablename__ = "access_api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), 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) name: Mapped[str] = mapped_column(String(255), nullable=False)
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True) prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(128), nullable=False) key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
@@ -164,12 +421,12 @@ class ApiKey(AccessBase, TimestampMixin):
class AuthSession(AccessBase, TimestampMixin): class AuthSession(AccessBase, TimestampMixin):
__tablename__ = "auth_sessions" __tablename__ = "access_auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), 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("accounts.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(128), nullable=False, unique=True, index=True) 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) 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) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
@@ -186,9 +443,18 @@ __all__ = [
"Account", "Account",
"ApiKey", "ApiKey",
"AuthSession", "AuthSession",
"Function",
"FunctionAssignment",
"FunctionDelegation",
"FunctionRoleAssignment",
"ExternalFunctionRoleAssignment",
"Group", "Group",
"GroupRoleAssignment", "GroupRoleAssignment",
"Identity",
"IdentityAccountLink",
"OrganizationUnit",
"Role", "Role",
"ServiceAccount",
"SystemRoleAssignment", "SystemRoleAssignment",
"Tenant", "Tenant",
"User", "User",

View File

@@ -2,9 +2,38 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef from govoplan_core.core.access import (
from govoplan_access.backend.db.models import Account, Group, User 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_core.db.session import get_database
from govoplan_access.backend.semantic import active_function_assignments_for_account
def _status(active: bool) -> str: def _status(active: bool) -> str:
@@ -40,7 +69,122 @@ def _group_ref(group: Group) -> GroupRef:
) )
class SqlAccessDirectory(AccessDirectory): 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: def get_account(self, account_id: str) -> AccountRef | None:
with get_database().session() as session: with get_database().session() as session:
account = session.get(Account, account_id) account = session.get(Account, account_id)
@@ -113,6 +257,9 @@ class SqlAccessDirectory(AccessDirectory):
return tuple(_group_ref(group) for group in groups) return tuple(_group_ref(group) for group in groups)
def display_label(self, subject: AccessSubjectRef) -> str | None: 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": if subject.kind == "account":
account = self.get_account(subject.id) account = self.get_account(subject.id)
return account.display_name or account.email if account else subject.label return account.display_name or account.email if account else subject.label
@@ -122,4 +269,199 @@ class SqlAccessDirectory(AccessDirectory):
if subject.kind == "group": if subject.kind == "group":
group = self.get_group(subject.id) group = self.get_group(subject.id)
return group.name if group else subject.label 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 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()}

View 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

View File

@@ -1,17 +1,49 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
from govoplan_access.backend.db.base import AccessBase 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_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import ( from govoplan_core.core.access import (
CAPABILITY_ACCESS_ADMINISTRATION, CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_DIRECTORY, CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_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.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_core.core.views import ViewSurface
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
@@ -32,9 +64,20 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"), _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: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: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: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: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: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:system_credential:read", "View system credentials", "List instance-wide reusable credential envelopes without revealing secret values.", "Access", "system"),
_permission("access:system_credential:write", "Manage system credentials", "Create, update, and retire instance-wide reusable credential envelopes.", "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: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: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"), _permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"),
@@ -44,11 +87,22 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"), _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: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: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: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: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:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
_permission("access:service_account:read", "View service accounts", "List non-login automation principals and their current scope ceilings.", "Tenant access", "tenant"),
_permission("access:service_account:write", "Manage service accounts", "Create, update, suspend, and retire scope-bounded automation principals.", "Tenant access", "tenant"),
_permission("access:setting:read", "View settings", "Read access and governance settings.", "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:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
_permission("access:credential:read", "View credentials", "List reusable credential envelopes without revealing secret values.", "Tenant access", "tenant"),
_permission("access:credential:write", "Manage credentials", "Create, update, and retire reusable credential envelopes.", "Tenant access", "tenant"),
_permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "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: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"), _permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"),
) )
@@ -71,18 +125,52 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:tenant:read", "access:tenant:read",
"access:tenant:create", "access:tenant:create",
"access:tenant:update", "access:tenant:update",
"access:tenant:suspend",
"access:account:read", "access:account:read",
"access:account:create", "access:account:create",
"access:account:update", "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:system_credential:read",
"access:system_credential:write",
"access:governance:read", "access:governance:read",
"access:governance:write", "access:governance:write",
), ),
level="system", level="system",
managed=True, managed=False,
protected=False, protected=False,
), ),
RoleTemplate( 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", name="Tenant owner",
description="Protected full tenant administration and module access.", description="Protected full tenant administration and module access.",
permissions=("tenant:*",), permissions=("tenant:*",),
@@ -90,6 +178,45 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
managed=True, managed=True,
protected=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:service_account:read",
"access:service_account:write",
"access:setting:read",
"access:setting:write",
"access:credential:read",
"access:credential: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( RoleTemplate(
slug="access_admin", slug="access_admin",
name="Access administrator", name="Access administrator",
@@ -103,6 +230,10 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:group:manage_members", "access:group:manage_members",
"access:role:read", "access:role:read",
"access:role:assign", "access:role:assign",
"access:function:read",
"access:function:write",
"access:function:assign",
"access:function:delegate",
"access:api_key:read", "access:api_key:read",
"access:api_key:create", "access:api_key:create",
"access:api_key:revoke", "access:api_key:revoke",
@@ -118,6 +249,7 @@ ADMIN_READ_SCOPES = (
"admin:groups:read", "admin:groups:read",
"admin:roles:read", "admin:roles:read",
"admin:api_keys:read", "admin:api_keys:read",
"access:service_account:read",
"admin:settings:read", "admin:settings:read",
"system:tenants:read", "system:tenants:read",
"system:accounts:read", "system:accounts:read",
@@ -128,14 +260,230 @@ ADMIN_READ_SCOPES = (
"access:tenant:read", "access:tenant:read",
"access:account:read", "access:account:read",
"access:governance:read", "access:governance:read",
"access:function:read",
"views:definition:read",
"views:assignment:read",
"views:system_definition:read",
"views:system_assignment: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: def _legacy_principal_resolver(context: ModuleContext) -> object:
del context
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
return LegacyPrincipalResolver() 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: def _legacy_permission_evaluator(context: ModuleContext) -> object:
@@ -145,11 +493,108 @@ def _legacy_permission_evaluator(context: ModuleContext) -> object:
return LegacyPermissionEvaluator() return LegacyPermissionEvaluator()
def _access_directory(context: ModuleContext) -> object: def _api_principal_provider(context: ModuleContext) -> object:
del context del context
from govoplan_access.backend.auth.dependencies import AccessApiPrincipalProvider
return AccessApiPrincipalProvider()
def _automation_principal_provider(context: ModuleContext) -> object:
from govoplan_access.backend.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
return AccessAutomationPrincipalProvider(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _tenant_context_switcher(context: ModuleContext) -> object:
del context
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
return AccessTenantContextSwitcher()
def _access_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory() return SqlAccessDirectory(
idm_directory=_optional_idm_directory(context),
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 _access_reference_options(context: ModuleContext) -> object:
del context
from govoplan_access.backend.reference_options import (
SqlAccessReferenceOptionProvider,
)
return SqlAccessReferenceOptionProvider()
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: def _tenant_provisioner(context: ModuleContext) -> object:
@@ -173,6 +618,13 @@ def _governance_materializer(context: ModuleContext) -> object:
return 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): def _route_factory(context: ModuleContext):
from fastapi import APIRouter from fastapi import APIRouter
@@ -182,28 +634,62 @@ def _route_factory(context: ModuleContext):
from govoplan_access.backend.api.v1.auth import router as auth_router 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 from govoplan_access.backend.api.v1.routes import router as access_admin_router
from govoplan_access.backend.api.v1.service_accounts import (
router as service_account_router,
)
router = APIRouter() router = APIRouter()
router.include_router(auth_router) router.include_router(auth_router)
router.include_router(access_admin_router) router.include_router(access_admin_router)
router.include_router(service_account_router)
return router return router
def _people_search(context: ModuleContext) -> object:
from govoplan_access.backend.people_search import people_search_capability
return people_search_capability(context)
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.6", version="0.1.11",
dependencies=("tenancy",), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
version="0.1.0",
),
ModuleInterfaceProvider(
name="auth.automation_principal",
version="0.2.0",
),
),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
route_factory=_route_factory, route_factory=_route_factory,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata), migration_spec=MigrationSpec(
module_id="access",
metadata=AccessBase.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
access_models.Account, access_models.Account,
access_models.Identity,
access_models.IdentityAccountLink,
access_models.User, access_models.User,
access_models.Group, access_models.Group,
access_models.Role, access_models.Role,
access_models.ServiceAccount,
access_models.OrganizationUnit,
access_models.Function,
access_models.FunctionRoleAssignment,
access_models.ExternalFunctionRoleAssignment,
access_models.FunctionAssignment,
access_models.FunctionDelegation,
access_models.SystemRoleAssignment, access_models.SystemRoleAssignment,
access_models.UserGroupMembership, access_models.UserGroupMembership,
access_models.UserRoleAssignment, access_models.UserRoleAssignment,
@@ -219,15 +705,44 @@ manifest = ModuleManifest(
package_name="@govoplan/access-webui", package_name="@govoplan/access-webui",
routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),), 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),), nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
view_surfaces=(
ViewSurface(id="access.admin.system-tenants", module_id="access", kind="section", label="System tenants", order=10),
ViewSurface(id="access.admin.system-roles", module_id="access", kind="section", label="System roles", order=20),
ViewSurface(id="access.admin.system-users", module_id="access", kind="section", label="System users", order=50),
ViewSurface(id="access.admin.system-credentials", module_id="access", kind="section", label="System credentials", order=80),
ViewSurface(id="access.admin.tenant-roles", module_id="access", kind="section", label="Tenant roles", order=10),
ViewSurface(id="access.admin.tenant-function-mappings", module_id="access", kind="section", label="Function mappings", order=20),
ViewSurface(id="access.admin.tenant-groups", module_id="access", kind="section", label="Tenant groups", order=30),
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
ViewSurface(id="access.admin.tenant-settings", module_id="access", kind="section", label="Tenant settings", order=90),
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
),
), ),
capability_factories={ capability_factories={
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
_automation_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_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_ACCESS_DIRECTORY: _access_directory, CAPABILITY_ACCESS_DIRECTORY: _access_directory,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory,
CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service,
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner, CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration, CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
}, },
documentation=ACCESS_DOCUMENTATION,
) )

View File

@@ -0,0 +1 @@

View File

@@ -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)

View File

@@ -0,0 +1,141 @@
"""managed automation service accounts
Revision ID: b6d9f2a5c8e1
Revises: 4a5b6c7d8e9f
Create Date: 2026-07-29 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b6d9f2a5c8e1"
down_revision = "4a5b6c7d8e9f"
branch_labels = None
depends_on = None
def upgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
return
op.create_table(
"access_service_accounts",
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("membership_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("normalized_name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column(
"created_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column(
"updated_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
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_service_accounts_account_id_access_accounts"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_created_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["membership_id"],
["access_users.id"],
name=op.f(
"fk_access_service_accounts_membership_id_access_users"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_access_service_accounts_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["updated_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_updated_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_access_service_accounts"),
),
sa.UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
sa.UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
sa.UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
)
for name, columns in (
(
"ix_access_service_accounts_account_id",
["account_id"],
),
(
"ix_access_service_accounts_is_active",
["is_active"],
),
(
"ix_access_service_accounts_membership_id",
["membership_id"],
),
(
"ix_access_service_accounts_retired_at",
["retired_at"],
),
(
"ix_access_service_accounts_tenant_id",
["tenant_id"],
),
):
op.create_index(name, "access_service_accounts", columns)
def downgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
op.drop_table("access_service_accounts")

View File

@@ -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')

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,141 @@
"""managed automation service accounts
Revision ID: b6d9f2a5c8e1
Revises: 4a5b6c7d8e9f
Create Date: 2026-07-29 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b6d9f2a5c8e1"
down_revision = "4a5b6c7d8e9f"
branch_labels = None
depends_on = None
def upgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
return
op.create_table(
"access_service_accounts",
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("membership_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("normalized_name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column(
"created_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column(
"updated_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
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_service_accounts_account_id_access_accounts"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_created_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["membership_id"],
["access_users.id"],
name=op.f(
"fk_access_service_accounts_membership_id_access_users"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_access_service_accounts_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["updated_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_updated_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_access_service_accounts"),
),
sa.UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
sa.UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
sa.UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
)
for name, columns in (
(
"ix_access_service_accounts_account_id",
["account_id"],
),
(
"ix_access_service_accounts_is_active",
["is_active"],
),
(
"ix_access_service_accounts_membership_id",
["membership_id"],
),
(
"ix_access_service_accounts_retired_at",
["retired_at"],
),
(
"ix_access_service_accounts_tenant_id",
["tenant_id"],
),
):
op.create_index(name, "access_service_accounts", columns)
def downgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
op.drop_table("access_service_accounts")

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, User
from govoplan_core.core.people import (
PeopleSearchError,
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
def _principal_tenant_id(principal: object) -> str:
try:
tenant_id = getattr(principal, "tenant_id")
except (AttributeError, RuntimeError) as exc:
raise PeopleSearchError("People search requires an active tenant context.") from exc
normalized = str(tenant_id or "").strip()
if not normalized:
raise PeopleSearchError("People search requires an active tenant context.")
return normalized
def _like_pattern(query: str) -> str:
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
class AccessPeopleSearchProvider:
"""Search active accounts through their active tenant membership.
The active principal's tenant is the only accepted visibility boundary;
global accounts and memberships of other tenants are never candidates.
Feature routers authorize the surrounding task before invoking this
capability.
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
if not isinstance(session, Session):
raise PeopleSearchError("People search requires a database session.")
tenant_id = _principal_tenant_id(principal)
normalized_limit = max(1, min(int(limit), 100))
account_query = (
session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(
User.tenant_id == tenant_id,
User.is_active.is_(True),
Account.is_active.is_(True),
)
)
normalized_query = str(query or "").strip()
if normalized_query:
pattern = _like_pattern(normalized_query)
account_query = account_query.filter(
or_(
func.lower(User.display_name).like(pattern, escape="\\"),
func.lower(User.email).like(pattern, escape="\\"),
func.lower(Account.display_name).like(pattern, escape="\\"),
func.lower(Account.email).like(pattern, escape="\\"),
)
)
rows = (
account_query
.order_by(
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
Account.id.asc(),
)
.limit(normalized_limit)
.all()
)
candidates = tuple(
PersonSearchCandidate(
selection_key=person_selection_key("account", account.id),
kind="account",
reference_id=account.id,
display_name=user.display_name or account.display_name or user.email or account.email,
email=user.email or account.email,
source_module="access",
source_label="Accounts",
source_ref=f"access:account:{account.id}",
)
for user, account in rows
)
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
return AccessPeopleSearchProvider()
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]

View File

@@ -0,0 +1,232 @@
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)
# Preserve explicitly granted scopes in the presentation set. A
# canonical module scope can still match its legacy compatibility
# alias when the providing module is not loaded; treating that match
# as proof that the original scope is known would otherwise replace
# the canonical grant with only the deprecated alias.
if include_unknown or scope in catalog:
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()

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.references import (
ReferenceOption,
ReferenceSearchPage,
ReferenceSearchRequest,
)
class SqlAccessReferenceOptionProvider:
"""Principal-aware, bounded Access directory search."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> ReferenceSearchPage:
db = _session(session)
tenant_id = str(request.tenant_id or "").strip()
if not tenant_id:
return ReferenceSearchPage()
limit = max(1, min(int(request.limit), 200))
offset = _cursor_offset(request.cursor)
selected = tuple(
dict.fromkeys(
str(value).strip()
for value in request.selected_values
if str(value).strip()
)
)[:200]
administrative = request.context.get("administrative") is True
query = str(request.query or "").strip().casefold()
if request.kind in {"user", "membership"}:
return _search_users(
db,
principal,
tenant_id=tenant_id,
kind=request.kind,
query=query,
selected=selected,
limit=limit,
offset=offset,
administrative=administrative,
)
if request.kind == "group":
return _search_groups(
db,
principal,
tenant_id=tenant_id,
query=query,
selected=selected,
limit=limit,
offset=offset,
administrative=administrative,
)
raise ValueError(f"Unsupported Access reference kind: {request.kind}")
def _search_users(
session: Session,
principal: object,
*,
tenant_id: str,
kind: str,
query: str,
selected: Sequence[str],
limit: int,
offset: int,
administrative: bool,
) -> ReferenceSearchPage:
value_column = User.id if kind == "membership" else User.account_id
base = (
session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(User.tenant_id == tenant_id)
)
if not administrative:
account_id = str(getattr(principal, "account_id", "") or "")
if not account_id:
return ReferenceSearchPage()
base = base.filter(User.account_id == account_id)
selected_rows = (
base.filter(value_column.in_(selected)).all()
if selected
else []
)
search_query = base
if selected:
search_query = search_query.filter(value_column.notin_(selected))
if query:
search_query = search_query.filter(
or_(
func.lower(func.coalesce(User.display_name, "")).contains(
query,
autoescape=True,
),
func.lower(User.email).contains(query, autoescape=True),
func.lower(Account.email).contains(query, autoescape=True),
func.lower(value_column).contains(query, autoescape=True),
)
)
rows = (
search_query.order_by(
func.lower(func.coalesce(User.display_name, User.email)).asc(),
value_column.asc(),
)
.offset(offset)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
options = [
_user_option(user, account, kind=kind)
for user, account in rows[:limit]
]
selected_by_value = {
_user_value(user, kind=kind): _user_option(user, account, kind=kind)
for user, account in selected_rows
}
options.extend(
selected_by_value[value]
for value in selected
if value in selected_by_value
)
return ReferenceSearchPage(
options=tuple(options),
next_cursor=f"offset:{offset + limit}" if has_more else None,
has_more=has_more,
)
def _search_groups(
session: Session,
principal: object,
*,
tenant_id: str,
query: str,
selected: Sequence[str],
limit: int,
offset: int,
administrative: bool,
) -> ReferenceSearchPage:
base = session.query(Group).filter(Group.tenant_id == tenant_id)
if not administrative:
permitted = tuple(
dict.fromkeys(
str(group_id)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
)
if not permitted:
return ReferenceSearchPage()
base = base.filter(Group.id.in_(permitted))
selected_rows = base.filter(Group.id.in_(selected)).all() if selected else []
search_query = base
if selected:
search_query = search_query.filter(Group.id.notin_(selected))
if query:
search_query = search_query.filter(
or_(
func.lower(Group.name).contains(query, autoescape=True),
func.lower(Group.slug).contains(query, autoescape=True),
func.lower(Group.id).contains(query, autoescape=True),
)
)
rows = (
search_query.order_by(func.lower(Group.name).asc(), Group.id.asc())
.offset(offset)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
options = [_group_option(group) for group in rows[:limit]]
selected_by_value = {group.id: _group_option(group) for group in selected_rows}
options.extend(
selected_by_value[value]
for value in selected
if value in selected_by_value
)
return ReferenceSearchPage(
options=tuple(options),
next_cursor=f"offset:{offset + limit}" if has_more else None,
has_more=has_more,
)
def _user_value(user: User, *, kind: str) -> str:
return user.id if kind == "membership" else user.account_id
def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption:
inactive = not user.is_active or not account.is_active
value = _user_value(user, kind=kind)
description_parts = [
user.email,
"Inactive" if inactive else None,
]
return ReferenceOption(
value=value,
label=user.display_name or user.email or value,
description=" · ".join(
part for part in description_parts if part
) or None,
kind=kind,
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"tenant_id": user.tenant_id,
"membership_id": user.id,
"account_id": user.account_id,
},
)
def _group_option(group: Group) -> ReferenceOption:
inactive = not group.is_active
return ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if inactive else None,
kind="group",
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
def _cursor_offset(cursor: str | None) -> int:
if cursor is None:
return 0
prefix = "offset:"
if not cursor.startswith(prefix):
raise ValueError("Invalid reference search cursor.")
try:
offset = int(cursor[len(prefix):])
except ValueError as exc:
raise ValueError("Invalid reference search cursor.") from exc
if offset < 0:
raise ValueError("Invalid reference search cursor.")
return offset
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Access reference search requires a SQLAlchemy Session")
return session
__all__ = ["SqlAccessReferenceOptionProvider"]

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ApiKey, User from govoplan_access.backend.db.models import ApiKey, User
@@ -60,17 +60,36 @@ def create_api_key(
return CreatedApiKey(model=model, secret=secret) return CreatedApiKey(model=model, secret=secret)
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: def authenticate_api_key(
session: Session,
secret: str,
*,
touch_interval_seconds: int = 5 * 60,
) -> ApiKey | None:
prefix = api_key_prefix(secret) prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() candidates = (
session.query(ApiKey)
.options(joinedload(ApiKey.user).joinedload(User.account))
.filter(
ApiKey.prefix == prefix,
ApiKey.revoked_at.is_(None),
)
.all()
)
now = utc_now() now = utc_now()
for candidate in candidates: for candidate in candidates:
expires_at = ensure_aware_utc(candidate.expires_at) expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now: if expires_at and expires_at < now:
continue continue
if verify_api_key(secret, candidate.key_hash): if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now last_used_at = ensure_aware_utc(candidate.last_used_at)
session.add(candidate) if (
touch_interval_seconds <= 0
or last_used_at is None
or now - last_used_at >= timedelta(seconds=touch_interval_seconds)
):
candidate.last_used_at = now
session.add(candidate)
return candidate return candidate
return None return None
@@ -78,4 +97,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
def has_scope(api_key: ApiKey, required_scope: str) -> bool: def has_scope(api_key: ApiKey, required_scope: str) -> bool:
scopes = set(api_key.scopes or []) scopes = set(api_key.scopes or [])
return "*" in scopes or required_scope in scopes return "*" in scopes or required_scope in scopes

View File

@@ -0,0 +1,358 @@
from __future__ import annotations
import hashlib
import logging
import threading
import time
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class AttemptBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class LoginThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class LoginAttemptStore(Protocol):
def read(self, key: str) -> AttemptBucket: ...
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryLoginAttemptStore:
"""Bounded process-local fallback for development and Redis outages."""
def __init__(self, *, max_entries: int = 10_000) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
def read(self, key: str) -> AttemptBucket:
now = time.monotonic()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return AttemptBucket()
count, expires_at = entry
return AttemptBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
now = time.monotonic()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return AttemptBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
for key in expired:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisLoginAttemptStore:
"""Redis-backed fixed-window counters shared by all API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> AttemptBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
count = int(raw_count or 0)
ttl = int(raw_ttl or 0)
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected login throttle response from Redis")
return AttemptBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientLoginAttemptStore:
"""Prefer the distributed store and fail safely to a local bounded store."""
def __init__(
self,
primary: LoginAttemptStore | None,
fallback: LoginAttemptStore,
*,
retry_seconds: int = 30,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> AttemptBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
primary = self._available_primary()
if primary is not None:
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
try:
# Mirror the active process's failures so a later Redis outage
# or recovery cannot restart its protection window from zero.
primary_result = primary.increment(key, window_seconds=window_seconds)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
return self._fallback.increment(key, window_seconds=window_seconds)
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> LoginAttemptStore | None:
if self._primary is None:
return None
with self._state_lock:
if time.monotonic() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = time.monotonic()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Redis login throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
return AttemptBucket(
count=max(first.count, second.count),
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
)
class LoginThrottle:
def __init__(
self,
store: LoginAttemptStore,
*,
identity_limit: int,
client_limit: int,
window_seconds: int,
key_prefix: str = "govoplan:access:login:v1",
) -> None:
self._store = store
self._identity_limit = max(1, identity_limit)
self._client_limit = max(1, client_limit)
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> LoginThrottleDecision:
return self._decision(
self._buckets(
normalized_email=normalized_email,
tenant_slug=tenant_slug,
client_address=client_address,
),
increment=False,
)
def record_failure(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> LoginThrottleDecision:
return self._decision(
self._buckets(
normalized_email=normalized_email,
tenant_slug=tenant_slug,
client_address=client_address,
),
increment=True,
)
def record_success(
self,
*,
normalized_email: str,
tenant_slug: str | None,
) -> None:
del tenant_slug
identity_key, _ = self._keys(
normalized_email=normalized_email,
client_address=None,
)
self._store.delete(identity_key)
def _decision(
self,
buckets: tuple[tuple[str, int], ...],
*,
increment: bool,
) -> LoginThrottleDecision:
blocked_retry_after = 0
for key, limit in buckets:
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return LoginThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _buckets(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> tuple[tuple[str, int], ...]:
# Accounts and their passwords are global login identities. Do not put
# the caller-supplied tenant slug into the identity key: rotating fake
# slugs must not bypass the account-level limit.
del tenant_slug
identity_key, client_key = self._keys(
normalized_email=normalized_email,
client_address=client_address,
)
buckets = [(identity_key, self._identity_limit)]
if client_key is not None:
buckets.append((client_key, self._client_limit))
return tuple(buckets)
def _keys(
self,
*,
normalized_email: str,
client_address: str | None,
) -> tuple[str, str | None]:
identity = normalized_email.strip().casefold()
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
if not client_address:
return identity_key, None
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
return identity_key, f"{self._key_prefix}:client:{client_digest}"
def build_login_throttle(
*,
redis_url: str | None,
identity_limit: int,
client_limit: int,
window_seconds: int,
redis_retry_seconds: int,
) -> LoginThrottle:
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
resilient_store = ResilientLoginAttemptStore(
redis_store,
InMemoryLoginAttemptStore(),
retry_seconds=redis_retry_seconds,
)
return LoginThrottle(
resilient_store,
identity_limit=identity_limit,
client_limit=client_limit,
window_seconds=window_seconds,
)

View File

@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000 _DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16 _SALT_BYTES = 16
# A valid, fixed-cost hash used when a login identity has no local password hash.
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
# login attempts on the same verification path as existing local accounts.
DUMMY_PASSWORD_HASH = (
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
)
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str: def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES) salt = os.urandom(_SALT_BYTES)
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
return False return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from collections.abc import Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
@@ -18,7 +19,8 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_core.security.permissions import expand_scopes from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_core.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32 SESSION_RANDOM_BYTES = 32
@@ -32,6 +34,16 @@ class CreatedSession:
csrf_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: def generate_session_token() -> str:
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES) return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
@@ -92,17 +104,39 @@ def create_auth_session(
return CreatedSession(model=model, token=token, csrf_token=csrf_token) return CreatedSession(model=model, token=token, csrf_token=csrf_token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None: def authenticate_session_token(
session: Session,
token: str,
*,
touch_interval_seconds: int = 5 * 60,
) -> AuthSession | None:
token_hash = hash_session_token(token) token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none() model = (
session.query(AuthSession)
.options(
joinedload(AuthSession.user).joinedload(User.account),
joinedload(AuthSession.account),
)
.filter(
AuthSession.token_hash == token_hash,
AuthSession.revoked_at.is_(None),
)
.one_or_none()
)
if not model: if not model:
return None return None
now = utc_now() now = utc_now()
expires_at = ensure_aware_utc(model.expires_at) expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now: if expires_at is None or expires_at < now:
return None return None
model.last_seen_at = now last_seen_at = ensure_aware_utc(model.last_seen_at)
session.add(model) if (
touch_interval_seconds <= 0
or last_seen_at is None
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
):
model.last_seen_at = now
session.add(model)
return model return model
@@ -169,6 +203,10 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
) )
for role in group_roles: for role in group_roles:
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in collect_function_roles(session, user):
roles_by_id[role.id] = role
for role in _materialized_default_authenticated_roles(session, user):
roles_by_id[role.id] = role
return list(roles_by_id.values()) return list(roles_by_id.values())
@@ -196,16 +234,115 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
) )
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
for role in _materialized_default_authenticated_roles(session, user):
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 []
default_slugs = _default_authenticated_slugs()
scopes = {
scope
for role in tenant_roles
if role.slug not in default_slugs
for scope in (role.permissions or [])
}
scopes.update(
scope
for role in system_roles
for scope in (role.permissions or [])
)
scopes.update(_default_authenticated_scopes())
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]: def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set() scopes = _default_authenticated_scopes()
default_slugs = _default_authenticated_slugs()
for role in collect_user_roles(session, user): for role in collect_user_roles(session, user):
scopes.update(role.permissions or []) if role.slug not in default_slugs:
scopes.update(role.permissions or [])
if include_system and user.account: if include_system and user.account:
for role in collect_system_roles(session, user.account): for role in collect_system_roles(session, user.account):
scopes.update(role.permissions or []) scopes.update(role.permissions or [])
return expand_scopes(scopes) return expand_scopes(scopes)
def _default_authenticated_slugs() -> set[str]:
return {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
def _default_authenticated_scopes() -> set[str]:
return {
scope
for template in role_templates_for_level("tenant")
if template.default_authenticated
for scope in template.permissions
}
def _materialized_default_authenticated_roles(
session: Session,
user: User,
) -> list[Role]:
"""Return the optional database projection without mutating auth reads."""
default_slugs = _default_authenticated_slugs()
if not default_slugs:
return []
return (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.order_by(Role.name.asc(), Role.id.asc())
.all()
)
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]: def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
return ( return (
session.query(User, Tenant) session.query(User, Tenant)
@@ -218,4 +355,3 @@ def collect_tenant_memberships(session: Session, account: Account) -> list[tuple
.order_by(Tenant.name.asc()) .order_by(Tenant.name.asc())
.all() .all()
) )

View 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"

View File

@@ -0,0 +1,283 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_access.backend.db.base import utcnow
from govoplan_access.backend.db.models import (
Account,
ServiceAccount,
Tenant,
User,
new_uuid,
)
from govoplan_core.auth import ApiPrincipal
class ServiceAccountError(ValueError):
pass
class ServiceAccountNotFoundError(ServiceAccountError):
pass
class ServiceAccountConflictError(ServiceAccountError):
pass
def list_service_accounts(
session: Session,
*,
tenant_id: str,
) -> list[ServiceAccount]:
return list(
session.scalars(
select(ServiceAccount)
.where(ServiceAccount.tenant_id == tenant_id)
.order_by(
ServiceAccount.normalized_name,
ServiceAccount.id,
)
)
)
def get_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
lock: bool = False,
) -> ServiceAccount:
query = select(ServiceAccount).where(
ServiceAccount.id == service_account_id,
ServiceAccount.tenant_id == tenant_id,
)
if lock:
query = query.with_for_update()
item = session.scalar(query)
if item is None:
raise ServiceAccountNotFoundError(
"Service account was not found"
)
return item
def create_service_account(
session: Session,
*,
tenant: Tenant,
principal: ApiPrincipal,
name: str,
description: str | None,
scope_ceiling: Iterable[str],
) -> ServiceAccount:
clean_name = _service_account_name(name)
scopes = _service_account_scopes(
principal,
scope_ceiling,
)
service_account_id = new_uuid()
internal_email = (
f"service-account-{service_account_id}@govoplan.invalid"
)
account = Account(
id=new_uuid(),
email=internal_email,
normalized_email=internal_email,
display_name=clean_name,
is_active=True,
auth_provider="service_account",
password_hash=None,
password_reset_required=False,
)
membership = User(
id=new_uuid(),
tenant_id=tenant.id,
account=account,
email=internal_email,
display_name=clean_name,
is_active=True,
is_tenant_admin=False,
auth_provider="service_account",
password_hash=None,
settings={"managed_service_account": service_account_id},
)
item = ServiceAccount(
id=service_account_id,
tenant_id=tenant.id,
account_id=account.id,
membership_id=membership.id,
name=clean_name,
normalized_name=_normalized_name(clean_name),
description=_optional_text(description),
scope_ceiling=list(scopes),
is_active=True,
revision=1,
created_by_account_id=principal.account_id,
updated_by_account_id=principal.account_id,
settings={},
)
session.add_all((account, membership, item))
try:
session.flush()
except IntegrityError as exc:
raise ServiceAccountConflictError(
"A service account with this name already exists"
) from exc
return item
def update_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
principal: ApiPrincipal,
expected_revision: int,
changes: Mapping[str, object],
) -> ServiceAccount:
item = get_service_account(
session,
tenant_id=tenant_id,
service_account_id=service_account_id,
lock=True,
)
if item.revision != expected_revision:
raise ServiceAccountConflictError(
"Service account changed on the server; reload before saving"
)
account = session.get(Account, item.account_id)
membership = session.get(User, item.membership_id)
if account is None or membership is None:
raise ServiceAccountConflictError(
"Service account backing identity is missing"
)
if "name" in changes:
clean_name = _service_account_name(str(changes["name"]))
item.name = clean_name
item.normalized_name = _normalized_name(clean_name)
account.display_name = clean_name
membership.display_name = clean_name
if "description" in changes:
value = changes["description"]
item.description = _optional_text(
str(value) if value is not None else None
)
if "scope_ceiling" in changes:
raw_scopes = changes["scope_ceiling"]
if not isinstance(raw_scopes, Iterable) or isinstance(
raw_scopes,
(str, bytes),
):
raise ServiceAccountError(
"Service account scope ceiling is invalid"
)
item.scope_ceiling = list(
_service_account_scopes(
principal,
(str(scope) for scope in raw_scopes),
)
)
if "is_active" in changes:
active = bool(changes["is_active"])
item.is_active = active
account.is_active = active
membership.is_active = active
item.retired_at = None if active else utcnow()
item.revision += 1
item.updated_by_account_id = principal.account_id
try:
session.flush()
except IntegrityError as exc:
raise ServiceAccountConflictError(
"A service account with this name already exists"
) from exc
return item
def retire_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
principal: ApiPrincipal,
expected_revision: int,
) -> ServiceAccount:
return update_service_account(
session,
tenant_id=tenant_id,
service_account_id=service_account_id,
principal=principal,
expected_revision=expected_revision,
changes={"is_active": False},
)
def _service_account_name(value: str) -> str:
clean = " ".join(value.split())
if not 1 <= len(clean) <= 255:
raise ServiceAccountError(
"Service account name must contain between 1 and 255 characters"
)
return clean
def _normalized_name(value: str) -> str:
return value.casefold()
def _optional_text(value: str | None) -> str | None:
if value is None:
return None
clean = value.strip()
if len(clean) > 4000:
raise ServiceAccountError(
"Service account description is too long"
)
return clean or None
def _service_account_scopes(
principal: ApiPrincipal,
values: Iterable[str],
) -> tuple[str, ...]:
scopes = tuple(
sorted(
{
str(value).strip()
for value in values
if str(value).strip()
}
)
)
if len(scopes) > 200:
raise ServiceAccountError(
"Service accounts support at most 200 scope grants"
)
denied = tuple(
scope for scope in scopes
if not principal.has(scope)
)
if denied:
raise PermissionError(
"Cannot grant service-account scopes outside the current "
f"administrator authority: {', '.join(denied)}"
)
return scopes
__all__ = [
"ServiceAccountConflictError",
"ServiceAccountError",
"ServiceAccountNotFoundError",
"create_service_account",
"get_service_account",
"list_service_accounts",
"retire_service_account",
"update_service_account",
]

View File

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

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.administration import SqlAccessAdministration
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, ApiKey, 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)
self.session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
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)})
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
accounts = [
Account(
id=f"account-{index}",
email=f"user-{index}@example.test",
normalized_email=f"user-{index}@example.test",
)
for index in range(3)
]
users = [
User(
id="user-1",
tenant_id="tenant-1",
account_id=accounts[0].id,
email=accounts[0].email,
),
User(
id="user-2",
tenant_id="tenant-1",
account_id=accounts[1].id,
email=accounts[1].email,
is_active=False,
),
User(
id="user-3",
tenant_id="tenant-2",
account_id=accounts[2].id,
email=accounts[2].email,
),
]
self.session.add_all(
[
*accounts,
*users,
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
ApiKey(
id="key-1",
tenant_id="tenant-1",
user_id="user-1",
name="Active",
prefix="active",
key_hash="hash-1",
),
ApiKey(
id="key-2",
tenant_id="tenant-1",
user_id="user-2",
name="Revoked",
prefix="revoked",
key_hash="hash-2",
revoked_at=datetime.now(UTC),
),
]
)
self.session.commit()
query_count = 0
def count_query(*_args: object) -> None:
nonlocal query_count
query_count += 1
event.listen(self.engine, "before_cursor_execute", count_query)
try:
counts = SqlAccessAdministration().tenant_counts_many(
self.session,
["tenant-1", "tenant-2", "tenant-empty"],
)
finally:
event.remove(self.engine, "before_cursor_execute", count_query)
self.assertEqual(3, query_count)
self.assertEqual(
{
"users": 2,
"active_users": 1,
"groups": 1,
"api_keys": 2,
"active_api_keys": 1,
},
counts["tenant-1"],
)
self.assertEqual(1, counts["tenant-2"]["users"])
self.assertEqual(0, counts["tenant-empty"]["users"])
if __name__ == "__main__":
unittest.main()

View 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()

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import MagicMock, patch
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import authenticate_session_token
class AuthenticationActivityTouchTests(TestCase):
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
for age_seconds, should_touch in ((60, False), (301, True)):
with self.subTest(age_seconds=age_seconds):
model = SimpleNamespace(
expires_at=now + timedelta(hours=1),
last_seen_at=now - timedelta(seconds=age_seconds),
)
session = MagicMock()
(
session.query.return_value.options.return_value
.filter.return_value.one_or_none
).return_value = model
with (
patch(
"govoplan_access.backend.security.sessions.hash_session_token",
return_value="hashed",
),
patch(
"govoplan_access.backend.security.sessions.utc_now",
return_value=now,
),
):
result = authenticate_session_token(
session,
"token",
touch_interval_seconds=300,
)
self.assertIs(model, result)
if should_touch:
self.assertEqual(now, model.last_seen_at)
session.add.assert_called_once_with(model)
else:
self.assertEqual(
now - timedelta(seconds=age_seconds),
model.last_seen_at,
)
session.add.assert_not_called()
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
for age_seconds, should_touch in ((60, False), (301, True)):
with self.subTest(age_seconds=age_seconds):
model = SimpleNamespace(
expires_at=None,
last_used_at=now - timedelta(seconds=age_seconds),
key_hash="hashed",
)
session = MagicMock()
(
session.query.return_value.options.return_value
.filter.return_value.all
).return_value = [model]
with (
patch(
"govoplan_access.backend.security.api_keys.verify_api_key",
return_value=True,
),
patch(
"govoplan_access.backend.security.api_keys.utc_now",
return_value=now,
),
):
result = authenticate_api_key(
session,
"mm_test-token",
touch_interval_seconds=300,
)
self.assertIs(model, result)
if should_touch:
self.assertEqual(now, model.last_used_at)
session.add.assert_called_once_with(model)
else:
self.assertEqual(
now - timedelta(seconds=age_seconds),
model.last_used_at,
)
session.add.assert_not_called()
if __name__ == "__main__":
import unittest
unittest.main()

View File

@@ -0,0 +1,161 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from typing import Iterable
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.requests import Request
from govoplan_access.backend.auth.dependencies import (
_extract_token,
_requires_csrf,
_resolve_legacy_principal_context,
_resolve_legacy_principal_ref,
)
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.tokens import hash_secret
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.db.base import Base
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
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 tearDown(self) -> None:
principal_summary_cache.clear()
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")
def test_permission_revision_invalidates_cached_principal(self) -> None:
engine = create_engine("sqlite:///:memory:")
create_scope_tables(engine)
AccessBase.metadata.create_all(bind=engine)
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
SessionLocal = sessionmaker(bind=engine)
try:
with SessionLocal() as session:
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
account = Account(
id="account-1",
email="owner@example.test",
normalized_email="owner@example.test",
)
user = User(
id="user-1",
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
)
role = Role(
id="role-1",
tenant_id=tenant.id,
slug="reader",
name="Reader",
permissions=["files:file:read"],
)
assignment = UserRoleAssignment(
tenant_id=tenant.id,
user_id=user.id,
role_id=role.id,
)
token = "ms_test-session-token"
auth_session = AuthSession(
id="session-1",
tenant_id=tenant.id,
user_id=user.id,
account_id=account.id,
token_hash=hash_secret(token),
expires_at=utc_now() + timedelta(hours=1),
)
session.add_all(
[tenant, account, user, role, assignment, auth_session]
)
session.commit()
request = request_for()
first = _resolve_legacy_principal_context(
request,
session,
authorization=f"Bearer {token}",
x_api_key=None,
)
self.assertIn("files:file:read", first.principal.scopes)
role.permissions = ["files:file:write"]
session.add(role)
invalidate_auth_principals(
session,
tenant_id=tenant.id,
source_module="access",
resource_type="role",
resource_id=role.id,
)
session.commit()
second = _resolve_legacy_principal_context(
request,
session,
authorization=f"Bearer {token}",
x_api_key=None,
)
self.assertNotIn("files:file:read", second.principal.scopes)
self.assertIn("files:file:write", second.principal.scopes)
finally:
AccessBase.metadata.drop_all(bind=engine)
scope_registry.metadata.drop_all(bind=engine)
Base.metadata.drop_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,295 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ServiceAccount,
User,
)
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.security.sessions import (
UserAuthorizationContext,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.automation import AutomationPrincipalRequest
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class AutomationPrincipalTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.account = Account(
id="account-1",
email="owner@example.test",
normalized_email="owner@example.test",
)
self.tenant = Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
)
self.user = User(
id="user-1",
tenant_id=self.tenant.id,
account_id=self.account.id,
email=self.account.email,
)
self.session.add_all([self.tenant, self.account, self.user])
self.session.commit()
self.provider = AccessAutomationPrincipalProvider()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _request(self) -> AutomationPrincipalRequest:
return AutomationPrincipalRequest(
tenant_id=self.tenant.id,
account_id=self.account.id,
membership_id=self.user.id,
authorization_ref="dataflow-trigger:1",
grant_scopes=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
context={
"trigger_ref": "dataflow-trigger:1",
"delivery_ref": "dataflow-delivery:1",
"event_actor": {
"type": "user",
"id": "event-user-1",
},
"operator_override": {
"type": "user",
"id": "operator-1",
"reason": "approved replay",
},
},
)
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=[
"dataflow:pipeline:run",
"datasources:catalogue:read",
"system:settings:write",
],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertTrue(result.allowed)
self.assertIsInstance(result.principal, ApiPrincipal)
self.assertEqual(
frozenset(
{
"dataflow:pipeline:run",
"datasources:catalogue:read",
}
),
result.principal.scopes,
)
self.assertIsNone(
result.principal.principal.service_account_id
)
self.assertEqual(
self.account.id,
result.principal.principal.acting_for_account_id,
)
self.assertNotIn(
"system:settings:write",
result.principal.scopes,
)
self.assertEqual(
"delegated_user",
result.provenance["trigger_owner"]["kind"],
)
self.assertEqual(
"event-user-1",
result.provenance["event_actor"]["id"],
)
self.assertEqual(
"operator-1",
result.provenance["operator_override"]["id"],
)
self.assertEqual(
self.account.id,
result.provenance[
"current_automation_principal"
]["account_id"],
)
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=["dataflow:pipeline:run"],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(result.allowed)
self.assertEqual(
("datasources:catalogue:read",),
result.missing_scopes,
)
self.account.is_active = False
self.session.flush()
suspended = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(suspended.allowed)
self.assertEqual(
"inactive_or_inconsistent",
suspended.provenance["status"],
)
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
account = Account(
id="service-account-backing",
email="service@example.invalid",
normalized_email="service@example.invalid",
display_name="Import worker",
auth_provider="service_account",
)
membership = User(
id="service-membership",
tenant_id=self.tenant.id,
account_id=account.id,
email=account.email,
display_name=account.display_name,
auth_provider="service_account",
)
service_account = ServiceAccount(
id="service-1",
tenant_id=self.tenant.id,
account_id=account.id,
membership_id=membership.id,
name="Import worker",
normalized_name="import worker",
scope_ceiling=[
"dataflow:pipeline:run",
"datasources:catalogue:read",
"system:settings:write",
],
is_active=True,
revision=1,
settings={},
)
self.session.add_all(
(account, membership, service_account)
)
self.session.flush()
request = AutomationPrincipalRequest.service_account(
tenant_id=self.tenant.id,
service_account_id=service_account.id,
authorization_ref="dataflow-trigger:service",
grant_scopes=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
result = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertTrue(result.allowed)
self.assertEqual(
service_account.id,
result.principal.principal.service_account_id,
)
self.assertEqual(
frozenset(request.grant_scopes),
result.principal.scopes,
)
self.assertNotIn(
"system:settings:write",
result.principal.scopes,
)
self.assertEqual(
"service_account",
result.provenance["trigger_owner"]["kind"],
)
service_account.scope_ceiling = [
"dataflow:pipeline:run"
]
self.session.flush()
reduced = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertFalse(reduced.allowed)
self.assertEqual(
("datasources:catalogue:read",),
reduced.missing_scopes,
)
service_account.is_active = False
self.session.flush()
inactive = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertFalse(inactive.allowed)
self.assertEqual(
"inactive_or_inconsistent",
inactive.provenance["status"],
)
def test_manifest_registers_automation_resolution(self) -> None:
self.assertIn(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
manifest.capability_factories,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
from govoplan_access.backend.security.sessions import collect_user_authorization_context
from govoplan_core.core.modules import RoleTemplate
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
DOCS_READER = RoleTemplate(
slug="docs_reader",
name="Documentation reader",
description="Authenticated documentation baseline.",
permissions=("docs:documentation:read",),
default_authenticated=True,
)
class DefaultAuthenticatedRoleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
self.session.add(self.tenant)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(self, suffix: str) -> User:
account = Account(
id=f"account-{suffix}",
email=f"{suffix}@example.test",
normalized_email=f"{suffix}@example.test",
)
user = User(
id=f"user-{suffix}",
tenant_id=self.tenant.id,
account_id=account.id,
email=account.email,
)
self.session.add_all([account, user])
self.session.flush()
return user
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
first = self._add_user("first")
self._add_user("second")
with (
patch(
"govoplan_access.backend.admin.service.role_templates_for_level",
return_value=(DOCS_READER,),
),
patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
),
):
roles = ensure_default_roles(self.session, self.tenant)
default_role = roles["docs_reader"]
self.assertFalse(default_role.is_assignable)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
# The database row is only an administration projection. Even if
# stale or tampered, it must never broaden the manifest baseline.
default_role.permissions = ["mail:profile:write"]
self.session.flush()
set_user_roles(self.session, user=first, role_ids=[])
context = collect_user_authorization_context(
self.session,
first,
account=first.account,
include_system=False,
)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertIn("docs:documentation:read", context.scopes)
self.assertNotIn("mail:profile:write", context.scopes)
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
user = self._add_user("new")
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
context = collect_user_authorization_context(
self.session,
user,
account=user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", context.scopes)
self.assertEqual(context.tenant_roles, [])
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertFalse(self.session.new)
self.assertFalse(self.session.dirty)
user_id = user.id
self.session.commit()
self.session.close()
self.session = self.Session()
persisted_user = self.session.get(User, user_id)
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
reopened_context = collect_user_authorization_context(
self.session,
persisted_user,
account=persisted_user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", reopened_context.scopes)
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from govoplan_access.backend.api.v1 import auth
from govoplan_access.backend.security.passwords import (
DUMMY_PASSWORD_HASH,
verify_password,
)
from govoplan_core.api.v1.schemas import LoginRequest
class LoginSecurityTests(unittest.TestCase):
@staticmethod
def _session_with_account(account: object | None) -> MagicMock:
session = MagicMock()
session.query.return_value.filter.return_value.one_or_none.return_value = (
account
)
return session
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
self,
) -> None:
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
payload = LoginRequest(
email="missing@example.test", password="attempted-password"
)
with patch.object(auth, "verify_password", return_value=True) as verifier:
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(None), payload)
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_present_identity_verifies_account_hash_before_same_generic_failure(
self,
) -> None:
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
account = SimpleNamespace(password_hash=account_hash)
payload = LoginRequest(email="known@example.test", password="wrong-password")
with patch.object(auth, "verify_password", return_value=False) as verifier:
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
verifier.assert_called_once_with(payload.password, account_hash)
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
account = SimpleNamespace(password_hash=None)
payload = LoginRequest(
email="passwordless@example.test", password="not-a-user-password"
)
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
account = SimpleNamespace(
id="account-1",
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
)
session = self._session_with_account(account)
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
payload = LoginRequest(email="known@example.test", password="correct-password")
with patch.object(auth, "verify_password", return_value=True):
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from redis.exceptions import RedisError
from govoplan_access.backend.api.v1 import auth
from govoplan_access.backend.security.login_throttle import (
AttemptBucket,
InMemoryLoginAttemptStore,
LoginThrottle,
LoginThrottleDecision,
ResilientLoginAttemptStore,
)
from govoplan_core.api.v1.schemas import LoginRequest
class LoginThrottleTests(unittest.TestCase):
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=3,
window_seconds=60,
)
context = {
"normalized_email": "person@example.test",
"tenant_slug": "tenant-a",
"client_address": "192.0.2.4",
}
self.assertTrue(throttle.record_failure(**context).allowed)
self.assertFalse(throttle.record_failure(**context).allowed)
other_identity = {**context, "normalized_email": "other@example.test"}
self.assertFalse(throttle.record_failure(**other_identity).allowed)
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=3,
window_seconds=60,
)
context = {
"normalized_email": "person@example.test",
"tenant_slug": "tenant-a",
"client_address": "192.0.2.8",
}
self.assertTrue(throttle.record_failure(**context).allowed)
throttle.record_success(
normalized_email=context["normalized_email"],
tenant_slug=context["tenant_slug"],
)
self.assertTrue(throttle.check(**context).allowed)
other_identity = {**context, "normalized_email": "other@example.test"}
self.assertTrue(throttle.record_failure(**other_identity).allowed)
self.assertFalse(throttle.record_failure(**other_identity).allowed)
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=100,
window_seconds=60,
)
self.assertTrue(
throttle.record_failure(
normalized_email="person@example.test",
tenant_slug="tenant-a",
client_address="192.0.2.1",
).allowed
)
self.assertFalse(
throttle.record_failure(
normalized_email="person@example.test",
tenant_slug="made-up-tenant",
client_address="198.51.100.9",
).allowed
)
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
store = MagicMock()
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
throttle = LoginThrottle(
store,
identity_limit=10,
client_limit=100,
window_seconds=60,
)
throttle.record_failure(
normalized_email="private.person@example.test",
tenant_slug="private-tenant",
client_address="192.0.2.9",
)
keys = [call.args[0] for call in store.increment.call_args_list]
self.assertEqual(len(keys), 2)
for key in keys:
self.assertNotIn("private", key)
self.assertNotIn("example", key)
self.assertNotIn("192.0.2.9", key)
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
primary = MagicMock()
primary.read.side_effect = RedisError("not available")
fallback = InMemoryLoginAttemptStore()
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
with self.assertLogs(
"govoplan_access.backend.security.login_throttle",
level="WARNING",
) as captured:
self.assertEqual(store.read("bucket"), AttemptBucket())
result = store.increment("bucket", window_seconds=60)
self.assertEqual(result.count, 1)
self.assertEqual(primary.read.call_count, 1)
primary.increment.assert_not_called()
self.assertIn("process-local fallback", captured.output[0])
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
primary = MagicMock()
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
fallback = InMemoryLoginAttemptStore()
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
store.read("bucket")
store.increment("bucket", window_seconds=60)
store.increment("bucket", window_seconds=60)
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
recovered = store.read("bucket")
incremented = store.increment("bucket", window_seconds=60)
self.assertEqual(recovered.count, 2)
self.assertEqual(incremented.count, 3)
def test_in_memory_store_is_bounded(self) -> None:
store = InMemoryLoginAttemptStore(max_entries=2)
for key in ("one", "two", "three"):
store.increment(key, window_seconds=60)
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
self.assertEqual(active, 2)
class LoginThrottleRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.payload = LoginRequest(
email="Person@Example.Test",
password="attempt",
tenant_slug="tenant-a",
)
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(False, 42)
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
with self.assertRaises(HTTPException) as raised:
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, "Invalid login")
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(True)
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
generic_failure = HTTPException(status_code=401, detail="Invalid login")
with (
patch.object(auth, "_configured_login_throttle", return_value=throttle),
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
):
with self.assertRaises(HTTPException) as raised:
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, "Invalid login")
throttle.record_failure.assert_called_once_with(
normalized_email="person@example.test",
tenant_slug="tenant-a",
client_address="192.0.2.10",
)
def test_success_clears_only_the_identity_bucket(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(True)
resolved = (object(), object(), object())
with (
patch.object(auth, "_configured_login_throttle", return_value=throttle),
patch.object(auth, "_resolve_login_user", return_value=resolved),
):
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(result, resolved)
throttle.record_failure.assert_not_called()
throttle.record_success.assert_called_once_with(
normalized_email="person@example.test",
tenant_slug="tenant-a",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import ast
import pathlib
import tomllib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
class OptionalTenancyContractTests(unittest.TestCase):
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.11", 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()

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
from govoplan_core.db.base import Base
class AccessPeopleSearchTests(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)
self.session = self.Session()
self.provider = AccessPeopleSearchProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(
self,
suffix: str,
*,
tenant_id: str = "tenant-1",
display_name: str | None = None,
user_active: bool = True,
account_active: bool = True,
) -> None:
email = f"{suffix}@example.test"
account = Account(
id=f"account-{suffix}",
email=email,
normalized_email=email,
display_name=display_name,
is_active=account_active,
)
self.session.add_all([
account,
User(
id=f"user-{suffix}",
tenant_id=tenant_id,
account_id=account.id,
email=email,
display_name=display_name,
is_active=user_active,
),
])
self.session.flush()
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="ada",
)
self.assertEqual([group.key for group in groups], ["accounts"])
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="%",
)
self.assertEqual(groups[0].candidates, ())
with self.assertRaises(PeopleSearchError):
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
def test_manifest_exposes_the_shared_interface(self) -> None:
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
self.assertIsInstance(self.provider, PeopleSearchProvider)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,35 @@
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)
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
scopes = access_catalog.expand_scopes(("files:file:read",))
self.assertIn("files:file:read", scopes)
self.assertIn("files:read", scopes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_access.backend.reference_options import (
SqlAccessReferenceOptionProvider,
)
from govoplan_core.core.references import ReferenceSearchRequest
from govoplan_core.db.base import Base
class AccessReferenceOptionProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[Account.__table__, User.__table__, Group.__table__],
)
self.session = Session(self.engine)
for index in range(120):
account = Account(
id=f"account-{index:03}",
email=f"person-{index:03}@example.test",
normalized_email=f"person-{index:03}@example.test",
)
self.session.add(account)
self.session.add(
User(
id=f"membership-{index:03}",
tenant_id="tenant-1",
account_id=account.id,
email=account.email,
display_name=f"Person {index:03}",
)
)
for index in range(75):
self.session.add(
Group(
id=f"group-{index:03}",
tenant_id="tenant-1",
slug=f"group-{index:03}",
name=f"Group {index:03}",
)
)
self.session.commit()
self.provider = SqlAccessReferenceOptionProvider()
self.admin = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-000",
group_ids=frozenset(),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_large_directory_search_is_bounded_and_paged(self) -> None:
first = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
limit=25,
context={"administrative": True},
),
)
second = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
limit=25,
cursor=first.next_cursor,
context={"administrative": True},
),
)
self.assertEqual(25, len(first.options))
self.assertTrue(first.has_more)
self.assertEqual("offset:25", first.next_cursor)
self.assertEqual("membership-000", first.options[0].value)
self.assertEqual("membership-025", second.options[0].value)
def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None:
page = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
query="PERSON 119",
selected_values=("membership-005", "removed-membership"),
limit=10,
context={"administrative": True},
),
)
self.assertEqual(
["membership-119", "membership-005"],
[option.value for option in page.options],
)
self.assertFalse(page.has_more)
def test_non_administrators_only_search_their_permitted_references(self) -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-004",
group_ids=frozenset({"group-007"}),
)
users = self.provider.search_reference_options(
self.session,
principal,
request=ReferenceSearchRequest(
kind="user",
tenant_id="tenant-1",
),
)
groups = self.provider.search_reference_options(
self.session,
principal,
request=ReferenceSearchRequest(
kind="group",
tenant_id="tenant-1",
),
)
self.assertEqual(["account-004"], [option.value for option in users.options])
self.assertEqual(["group-007"], [option.value for option in groups.options])
def test_invalid_cursor_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"):
self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="group",
tenant_id="tenant-1",
cursor="page:2",
context={"administrative": True},
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
User,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
create_service_account,
retire_service_account,
update_service_account,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class ServiceAccountTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.tenant = Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
)
self.account = Account(
id="account-1",
email="admin@example.test",
normalized_email="admin@example.test",
)
self.user = User(
id="user-1",
tenant_id=self.tenant.id,
account_id=self.account.id,
email=self.account.email,
)
self.session.add_all(
(self.tenant, self.account, self.user)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _principal(
self,
scopes: frozenset[str] = frozenset({"tenant:*"}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=self.account.id,
membership_id=self.user.id,
tenant_id=self.tenant.id,
scopes=scopes,
),
account=self.account,
user=self.user,
)
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
item = create_service_account(
self.session,
tenant=self.tenant,
principal=self._principal(),
name=" Monthly import ",
description="Runs the governed monthly import.",
scope_ceiling=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
backing_account = self.session.get(
Account,
item.account_id,
)
membership = self.session.get(
User,
item.membership_id,
)
self.assertEqual("Monthly import", item.name)
self.assertEqual(
[
"dataflow:pipeline:run",
"datasources:catalogue:read",
],
item.scope_ceiling,
)
self.assertEqual(
"service_account",
backing_account.auth_provider,
)
self.assertIsNone(backing_account.password_hash)
self.assertEqual(
"service_account",
membership.auth_provider,
)
self.assertIsNone(membership.password_hash)
self.assertEqual(
0,
self.session.query(ApiKey)
.filter(ApiKey.user_id == membership.id)
.count(),
)
self.assertEqual(
0,
self.session.query(AuthSession)
.filter(AuthSession.user_id == membership.id)
.count(),
)
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
principal = self._principal(
frozenset({"dataflow:pipeline:run"})
)
with self.assertRaises(PermissionError):
create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Escalating worker",
description=None,
scope_ceiling=("system:settings:write",),
)
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Bounded worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
updated = update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Updated"},
)
self.assertEqual(2, updated.revision)
with self.assertRaises(ServiceAccountConflictError):
update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Stale"},
)
def test_retirement_revokes_the_backing_principal(self) -> None:
principal = self._principal()
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Retired worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
retired = retire_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
)
self.assertFalse(retired.is_active)
self.assertIsNotNone(retired.retired_at)
self.assertFalse(
self.session.get(Account, retired.account_id).is_active
)
self.assertFalse(
self.session.get(User, retired.membership_id).is_active
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.6", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,11 +13,11 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -1,47 +1,24 @@
import type { ApiSettings } from "@govoplan/core-webui"; import type {
import { apiFetch } from "@govoplan/core-webui"; AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
ApiSettings,
export type PermissionItem = { DeltaDeletedItem,
scope: string; PrivacyRetentionPolicy,
label: string; ResourceAccessExplanationOptions,
description: string; ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
category: string; TenantAdminItem
level: "tenant" | "system"; } from "@govoplan/core-webui";
system_template_id?: string | null; import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
system_required?: boolean; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
}; export type {
AdminOverview,
export type AdminOverview = { PrivacyRetentionLimitPermissionPatch,
active_tenant_id: string; PrivacyRetentionLimitPermissions,
active_tenant_name: string; PrivacyRetentionPolicy,
tenant_count?: number | null; PrivacyRetentionPolicyFieldKey,
system_account_count?: number | null; PrivacyRetentionPolicyPatch,
system_group_template_count?: number | null; PermissionItem,
system_role_template_count?: number | null; TenantAdminItem
user_count: number; } from "@govoplan/core-webui";
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
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;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = { export type TenantOwnerCandidate = {
account_id: string; account_id: string;
@@ -92,6 +69,8 @@ export type UserAdminItem = {
last_login_at?: string | null; last_login_at?: string | null;
groups: GroupSummary[]; groups: GroupSummary[];
roles: RoleSummary[]; roles: RoleSummary[];
function_assignment_ids: string[];
function_delegation_ids: string[];
effective_scopes: string[]; effective_scopes: string[];
is_owner: boolean; is_owner: boolean;
is_last_active_owner: boolean; is_last_active_owner: boolean;
@@ -99,6 +78,69 @@ export type UserAdminItem = {
updated_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 = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
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 = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
export type SystemAccountItem = { export type SystemAccountItem = {
account_id: string; account_id: string;
email: string; email: string;
@@ -119,78 +161,40 @@ export type SystemMembershipDraft = {
is_last_active_owner?: boolean; is_last_active_owner?: boolean;
}; };
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type SystemSettingsItem = { export type SystemSettingsItem = {
default_locale: string; default_locale: string;
allow_tenant_custom_groups: boolean; allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean; allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean; allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy; privacy_retention_policy: PrivacyRetentionPolicy;
available_languages?: LanguagePackage[];
enabled_language_codes?: string[];
settings: Record<string, unknown>; settings: Record<string, unknown>;
}; };
export type LanguagePackage = {
code: string;
label: string;
native_label?: string | null;
};
export type TenantSettingsItem = { export type TenantSettingsItem = {
id: string; id: string;
slug: string; slug: string;
name: string; name: string;
default_locale: string; default_locale: string;
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
settings: Record<string, unknown>; settings: Record<string, unknown>;
}; };
export type RetentionRunResponse = { export type TenantSettingsDeltaSections = Partial<{
result: { identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
dry_run: boolean; locale: Pick<TenantSettingsItem, "default_locale">;
policy: PrivacyRetentionPolicy; languages: Pick<TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes">;
cutoffs: Record<string, string | null>; settings: Pick<TenantSettingsItem, "settings">["settings"];
effective_policy_scope?: string; }>;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = { export type GovernanceAssignment = {
tenant_id: string; tenant_id: string;
@@ -224,37 +228,51 @@ export type ApiKeyAdminItem = {
created_at: string; created_at: string;
}; };
export type AuditAdminItem = { export type ExternalFunctionRoleMappingItem = {
id: string; id: string;
scope: "tenant" | "system"; tenant_id: string;
tenant_id?: string | null; source_module: string;
actor_email?: string | null; function_id: string;
action: string; role_id: string;
object_type?: string | null; settings: Record<string, unknown>;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string; 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;
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> { function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
return apiFetch(settings, "/api/v1/admin/overview"); return apiQuery(options);
} }
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
return response.tenants; const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
} }
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> { export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
return response.accounts;
} }
export function createTenant(settings: ApiSettings, payload: { export function createTenant(settings: ApiSettings, payload: {
@@ -289,7 +307,12 @@ export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettin
return apiFetch(settings, "/api/v1/admin/tenant/settings"); return apiFetch(settings, "/api/v1/admin/tenant/settings");
} }
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> { 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) }); return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
} }
@@ -298,6 +321,11 @@ export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]
return response.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: { export function createUser(settings: ApiSettings, payload: {
email: string; email: string;
display_name?: string | null; display_name?: string | null;
@@ -319,9 +347,24 @@ export function updateUser(settings: ApiSettings, userId: string, payload: Parti
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); 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: ResourceAccessExplanationOptions
): Promise<ResourceAccessExplanationResponse> {
return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
return response.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: { export function createGroup(settings: ApiSettings, payload: {
@@ -346,8 +389,12 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
} }
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
return response.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: { export function createRole(settings: ApiSettings, payload: {
@@ -372,9 +419,38 @@ export function deleteRole(settings: ApiSettings, roleId: string): Promise<void>
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
} }
export function fetchExternalFunctionRoleMappingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<ExternalFunctionRoleMappingListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/delta${suffix}`);
}
export function createExternalFunctionRoleMapping(settings: ApiSettings, payload: {
source_module: string;
function_id: string;
role_id: string;
settings?: Record<string, unknown>;
}): Promise<ExternalFunctionRoleMappingItem> {
return apiFetch(settings, "/api/v1/admin/external-function-role-mappings", { method: "POST", body: JSON.stringify(payload) });
}
export function updateExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string, payload: {
role_id?: string;
settings?: Record<string, unknown>;
}): Promise<ExternalFunctionRoleMappingItem> {
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
return response.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: { export function createSystemRole(settings: ApiSettings, payload: {
@@ -403,6 +479,11 @@ export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ acco
return apiFetch(settings, "/api/v1/admin/system/accounts"); 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: { export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null; display_name?: string | null;
is_active?: boolean; is_active?: boolean;
@@ -422,11 +503,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
} }
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> { export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams(); return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
if (includeRevoked) params.set("include_revoked", "true"); }
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`); export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
return response.api_keys; 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: { export function createApiKey(settings: ApiSettings, payload: {
@@ -442,38 +527,6 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" }); return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
} }
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: { export function createSystemAccount(settings: ApiSettings, payload: {
email: string; email: string;
display_name?: string | null; display_name?: string | null;
@@ -502,34 +555,21 @@ export type SystemSettingsUpdatePayload = {
allow_tenant_custom_roles: boolean; allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean; allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null; privacy_retention_policy?: PrivacyRetentionPolicy | null;
available_languages?: LanguagePackage[] | null;
enabled_language_codes?: string[] | null;
}; };
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> { export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
} }
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> { export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); }
return response.templates;
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> { export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {

View File

@@ -1,121 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Search } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
const DEFAULT_QUERY: DataGridQueryState = {
sort: { columnId: "time", direction: "desc" },
filters: {}
};
export default function AdminAuditPanel({
settings,
auth,
systemMode = false
}: {
settings: ApiSettings;
auth: AuthInfo;
systemMode?: boolean;
}) {
const [items, setItems] = useState<AuditAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const sortColumn = query.sort?.columnId;
const response = await fetchAdminAudit(settings, {
scope: systemMode ? "system" : "tenant",
page,
pageSize,
sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
? sortColumn as "time" | "actor" | "action" | "object" | "tenant"
: "time",
sortDirection: query.sort?.direction ?? "desc",
filters: query.filters
});
setItems(response.items);
setTotal(response.total);
if (response.page !== page) setPage(response.page);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]);
useEffect(() => { void load(); }, [load]);
const handleQueryChange = useCallback((next: DataGridQueryState) => {
setQuery((current) => {
if (JSON.stringify(current) === JSON.stringify(next)) return current;
setPage(1);
return next;
});
}, []);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
], [systemMode]);
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastShown = Math.min(total, page * pageSize);
return (
<>
<AdminPageLayout
title={systemMode ? "System audit" : "Tenant audit"}
description={systemMode
? `System-level administrative history. Showing ${firstShown}${lastShown} of ${total} records.`
: `Tenant-level administrative history for the active tenant. Showing ${firstShown}${lastShown} of ${total} records.`}
loading={loading}
error={error}
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}
>
<div className="admin-table-surface">
<DataGrid
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
rows={items}
columns={columns}
initialFit="container" getRowKey={(row) => row.id}
emptyText="No administrative audit records found."
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
mode: "server",
page,
pageSize,
totalRows: total,
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
}}
onQueryChange={handleQueryChange}
/>
</div>
</AdminPageLayout>
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
{selected && <><dl className="admin-details-grid"><div><dt>Scope</dt><dd>{selected.scope}</dd></div><div><dt>Action</dt><dd>{selected.action}</dd></div><div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div><div><dt>Object</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>Tenant context</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
</Dialog>
</>
);
}

View File

@@ -1,8 +1,18 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui"; import type {
import { fetchMe } from "@govoplan/core-webui"; 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 { Card } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
import SystemUsersPanel from "./SystemUsersPanel"; import SystemUsersPanel from "./SystemUsersPanel";
@@ -12,15 +22,75 @@ import TenantsPanel from "./TenantsPanel";
import UsersPanel from "./UsersPanel"; import UsersPanel from "./UsersPanel";
import GroupsPanel from "./GroupsPanel"; import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel"; import RolesPanel from "./RolesPanel";
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
import ApiKeysPanel from "./ApiKeysPanel"; import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel"; import FileConnectorsPanel from "./FileConnectorsPanel";
import MailProfilesPanel from "./MailProfilesPanel"; import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui"; import {
isViewSurfaceVisible,
useEffectiveView,
usePlatformUiCapabilities,
usePlatformUiCapability,
useViewSurfaces
} from "@govoplan/core-webui";
type AdminSection = string; type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number }; type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
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",
"system-credentials",
"tenant-settings",
"tenant-roles",
"tenant-function-role-mappings",
"tenant-groups",
"tenant-users",
"tenant-file-connectors",
"tenant-mail-servers",
"tenant-credentials",
"tenant-api-keys",
"tenant-group-file-connectors",
"tenant-group-mail-servers",
"tenant-group-credentials",
"tenant-user-file-connectors",
"tenant-user-mail-servers",
"tenant-user-credentials"
]);
const builtInAdminSurfaceIds: Record<string, string> = {
"system-tenants": "access.admin.system-tenants",
"system-roles": "access.admin.system-roles",
"system-users": "access.admin.system-users",
"system-credentials": "access.admin.system-credentials",
"tenant-roles": "access.admin.tenant-roles",
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
"tenant-groups": "access.admin.tenant-groups",
"tenant-users": "access.admin.tenant-users",
"tenant-credentials": "access.admin.tenant-credentials",
"tenant-api-keys": "access.admin.tenant-api-keys",
"tenant-settings": "access.admin.tenant-settings",
"tenant-group-credentials": "access.admin.group-credentials",
"tenant-user-credentials": "access.admin.user-credentials",
"system-mail-servers": "mail.admin.system-servers",
"tenant-mail-servers": "mail.admin.tenant-servers",
"tenant-group-mail-servers": "mail.admin.group-servers",
"tenant-user-mail-servers": "mail.admin.user-servers",
"tenant-group-file-connectors": "files.admin.group-connectors",
"tenant-user-file-connectors": "files.admin.user-connectors"
};
export default function AdminPage({ export default function AdminPage({
settings, settings,
auth, auth,
@@ -28,19 +98,30 @@ export default function AdminPage({
}: { }: {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
}) { }) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); 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 adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces();
const mailProfilesAvailable = Boolean(mailProfilesUi); const mailProfilesAvailable = Boolean(mailProfilesUi);
const auditAvailable = usePlatformModuleInstalled("audit"); const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const policyAvailable = usePlatformModuleInstalled("policy"); const contributedSections = useMemo(
const tenancyAvailable = usePlatformModuleInstalled("tenancy"); () =>
const contributedSections = useMemo(() => ( adminSectionCapabilities
adminSectionCapabilities .flatMap((capability) => capability.sections)
.flatMap((capability) => capability.sections) .filter((section) =>
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)) isViewSurfaceVisible(
), [adminSectionCapabilities]); effectiveView,
section.surfaceId,
viewSurfaces
)
)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[adminSectionCapabilities, effectiveView, viewSurfaces]
);
const contributionById = useMemo(() => { const contributionById = useMemo(() => {
const mapped = new Map<string, AdminSectionContribution>(); const mapped = new Map<string, AdminSectionContribution>();
for (const section of contributedSections) { for (const section of contributedSections) {
@@ -55,31 +136,44 @@ export default function AdminPage({
if (canUseContributedSection(auth, section)) sections.add(section.id); if (canUseContributedSection(auth, section)) sections.add(section.id);
} }
if (hasScope(auth, "system:settings:read")) { if (hasScope(auth, "system:settings:read")) {
if (policyAvailable) sections.add("system-retention");
if (mailProfilesAvailable) sections.add("system-mail-servers"); if (mailProfilesAvailable) sections.add("system-mail-servers");
} }
if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
sections.add("system-credentials");
}
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles"); if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys"); if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) { if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
sections.add("tenant-mail-servers"); sections.add("tenant-mail-servers");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-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 (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
} }
if (policyAvailable && hasScope(auth, "admin:policies:read")) { if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) {
sections.add("tenant-retention"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention");
} }
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit"); if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
return sections; sections.add("tenant-credentials");
}, [auth, auditAvailable, contributedSections, mailProfilesAvailable, policyAvailable, tenancyAvailable]); if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
}
return new Set(
[...sections].filter((sectionId) =>
isViewSurfaceVisible(
effectiveView,
builtInAdminSurfaceIds[sectionId],
viewSurfaces
)
)
);
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null; const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -97,58 +191,90 @@ export default function AdminPage({
}, [requestedSection, available, fallbackSection]); }, [requestedSection, available, fallbackSection]);
async function refreshAuth() { async function refreshAuth() {
onAuthChange(await fetchMe(settings)); onAuthChange(await fetchShellAuth(settings));
} }
useEffect(() => {
void refreshAuth().catch(() => undefined);
}, [settings.accessToken, settings.apiBaseUrl]);
if (!hasAnyScope(auth, adminReadScopes)) { if (!hasAnyScope(auth, adminReadScopes)) {
return <div className="content-pad"><Card title="Administration unavailable"><p>Your current roles do not grant administrative access.</p></Card></div>; return (
<PageScrollViewport>
<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>
</PageScrollViewport>
);
} }
const rootItems = contributedNavItems(contributedSections, available, "ROOT");
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [ const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
{ items: asSubnavItems(rootItems) },
{ {
title: "SYSTEM", title: "ADMINISTRATION",
items: asSubnavItems(sortNavItems([ items: asSubnavItems(
...contributedNavItems(contributedSections, available, "SYSTEM"), sortNavItems([
visibleNavItem(available, "system-tenants", "Tenants", 20), ...contributedNavItems(contributedSections, available, "ROOT"),
visibleNavItem(available, "system-roles", "System roles", 30), visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
visibleNavItem(available, "system-users", "Users", 60), visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
visibleNavItem(available, "system-mail-servers", "Mail servers", 70), visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
visibleNavItem(available, "system-retention", "Retention", 80), visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
visibleNavItem(available, "system-audit", "Audit", 90) ...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),
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
])
)
}, },
{ {
title: "TENANT", title: "TENANT",
items: [ items: asSubnavItems(
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []), sortNavItems([
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []), visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []), visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []), visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []), visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []), visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []), visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : []) visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
] visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
])
)
}, },
{ {
title: "GROUP", title: "GROUP",
items: [ items: asSubnavItems(
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []), sortNavItems([
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []), visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
] visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
])
)
}, },
{ {
title: "USER", title: "USER",
items: [ items: asSubnavItems(
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []), sortNavItems([
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []), visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
] visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
])
)
} }
].filter((group) => group.items.length > 0); ].filter((group) => group.items.length > 0);
const contributedSection = contributionById.get(active); const contributedSection = contributionById.get(active);
@@ -160,36 +286,45 @@ export default function AdminPage({
<section className="workspace-content"> <section className="workspace-content">
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
{contributedSection && contributedSection.render(contributionContext)} {contributedSection && contributedSection.render(contributionContext)}
{!contributedSection && active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />} {!contributedSection && active === "system-mail-servers" && (
{!contributedSection && active === "system-mail-servers" && <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />} <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
{!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 {!contributedSection && active === "system-credentials" && (
settings={settings} <CredentialEnvelopesPanel
canCreate={hasScope(auth, "system:accounts:create")} settings={settings}
canUpdate={hasScope(auth, "system:accounts:update")} scopeType="system"
canSuspend={hasScope(auth, "system:accounts:suspend")} canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
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-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-roles" && <SystemRolesPanel )}
settings={settings} {!contributedSection && active === "system-users" && (
canWrite={hasScope(auth, "system:roles:write")} <SystemUsersPanel
onAuthRefresh={refreshAuth} settings={settings}
/>} canCreate={hasScope(auth, "system:accounts:create")}
{!contributedSection && active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />} 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-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />} {!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />} {!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />} {!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />} {!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />} {!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />} {!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies: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} />} {!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
</div> </div>
</section> </section>
</div> </div>
@@ -202,9 +337,14 @@ function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribut
return true; return true;
} }
function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet<string>, group: string): OrderedAdminNavItem[] { function contributedNavItems(
sections: AdminSectionContribution[],
available: ReadonlySet<string>,
group: string,
excludedIds: ReadonlySet<string> = new Set()
): OrderedAdminNavItem[] {
return sections return sections
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id)) .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 })); .map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
} }

View File

@@ -1,80 +1,139 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin"; import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { DateTimeField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
import { scopeGrants } from "@govoplan/core-webui"; import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) { 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 [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]); const [users, setUsers] = useState<UserAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]); const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const keysRef = useRef<ApiKeyAdminItem[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [showRevoked, setShowRevoked] = useState(false); const [showRevoked, setShowRevoked] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null); const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" }); const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null); 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 [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = creating && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeCreate
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]); 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); setKeys(nextKeys);
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active)); setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant")); setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setLoading(false); } {setLoading(false);}
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]); 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 selectedUser = users.find((user) => user.id === draft.userId);
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))); const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [ const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
{ id: "name", header: "Name", 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: "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: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email }, { 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: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) }, { 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: "Status", 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: "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: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) }, { 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: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" }, { 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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled /> { id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, onClick: () => setRevoking(row) }
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} /> ]} /> }],
</div> } [canRevoke]);
], [canRevoke]);
function openCreate() { function openCreate() {
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0]; const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" }); const nextDraft = defaultDraft(defaultUser?.id || "");
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setCreating(true); setCreating(true);
setError(""); setError("");
} }
async function save() { function closeCreate() {
setCreating(false);
const nextDraft = defaultDraft(auth.user.id);
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
}
async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { 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 }); 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 }); setSecret({ name: created.name, value: created.secret });
setCreating(false); setCreating(false);
setSuccess(`API key ${created.name} created.`); setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } return true;
finally { setBusy(false); } } catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
} }
async function revoke() { async function revoke() {
@@ -83,42 +142,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
setError(""); setError("");
try { try {
await revokeApiKey(settings, revoking.id); await revokeApiKey(settings, revoking.id);
setSuccess(`API key ${revoking.name} revoked.`); setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
setRevoking(null); setRevoking(null);
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setBusy(false); } {setBusy(false);}
} }
return ( return (
<> <>
<AdminPageLayout title="Tenant API keys" description="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." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}> <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={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><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="No API keys found." /></div> <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> </AdminPageLayout>
<Dialog open={creating} title="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}> <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"> <div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Owner"><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="">Select user</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.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="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
</div> </div>
<div className="form-field"><span className="form-label">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope}${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></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>
<Dialog open={Boolean(viewing)} title="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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"> {viewing && <><dl className="admin-details-grid">
<div><dt>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}</dd></div> <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>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</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>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</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>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_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>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</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>
<Dialog open={Boolean(secret)} title="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}> <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>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>} {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> </Dialog>
<ConfirmDialog open={Boolean(revoking)} title="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} /> <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);
} }

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import {
AdminPageLayout,
CredentialEnvelopeManager,
adminErrorMessage,
useDeltaWatermarks,
type ApiSettings,
type CredentialEnvelopeTargetOption,
type MailProfileScope
} from "@govoplan/core-webui";
import {
fetchGroupsDelta,
fetchUsersDelta,
type GroupSummary,
type UserAdminItem
} from "../../api/admin";
import { loadDeltaRows } from "./utils/deltaRows";
type ScopeType = Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
export default function CredentialEnvelopesPanel({
settings,
scopeType,
canWrite
}: {
settings: ApiSettings;
scopeType: ScopeType;
canWrite: boolean;
}) {
const [targets, setTargets] = useState<CredentialEnvelopeTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(
scopeType === "user" || scopeType === "group"
);
const [targetError, setTargetError] = useState("");
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } =
useDeltaWatermarks();
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [
resetDeltaWatermark,
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
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:credential-users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
(left, right) => left.email.localeCompare(right.email)
);
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:credential-groups",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchGroupsDelta(settings, { since }),
(response) => response.groups,
(group) => group.id,
"access_group",
(left, right) =>
left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
);
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);
}
}
return (
<AdminPageLayout
title={scopeTitle(scopeType)}
description={scopeDescription(scopeType)}
loading={loadingTargets}
error={targetError}
>
<CredentialEnvelopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={scopeType === "group" ? "Group" : "User"}
title="Reusable credentials"
canWrite={canWrite}
/>
</AdminPageLayout>
);
}
function scopeTitle(scopeType: ScopeType): string {
if (scopeType === "system") return "System credentials";
if (scopeType === "tenant") return "Tenant credentials";
if (scopeType === "group") return "Group credentials";
return "User credentials";
}
function scopeDescription(scopeType: ScopeType): string {
if (scopeType === "system") {
return "Instance credentials that can be inherited by tenants and limited to selected modules or servers.";
}
if (scopeType === "tenant") {
return "Tenant credentials shared by Mail, Files, Calendar, Addresses, and other permitted modules.";
}
return `Reusable credentials owned by the selected ${scopeType}.`;
}

View File

@@ -0,0 +1,375 @@
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, TableActionGroup, 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) => <TableActionGroup actions={[
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
]} />
}
],
[auth, canWrite, functionPicker, roleById, settings]
);
return (
<>
<AdminPageLayout
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
loading={loading}
error={error}
success={success}
actions={
<>
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} />
</>
}
>
<div className="admin-table-surface">
<DataGrid
id="admin-external-function-role-mappings-v1"
rows={mappings}
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54"
/>
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
onClose={() => !busy && closeEditor()}
className="admin-dialog"
footer={
<>
<Button onClick={closeEditor} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.functionId.trim() || !draft.roleId}>
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
</Button>
</>
}
>
<div className="admin-form-grid">
<FormField label="i18n:govoplan-access.function_id.e5e08937">
{functionPicker.renderPicker({
settings,
auth,
disabled: editing !== "new",
value: draft.functionId ? draftSelection(draft) : null,
onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) })
})}
</FormField>
<FormField label="i18n:govoplan-access.role.c3f104d1">
<select value={draft.roleId} onChange={(event) => setDraft({ ...draft, roleId: event.target.value })}>
<option value="">i18n:govoplan-access.select_role.c543f191</option>
{assignableRoles.map((role) => (
<option key={role.id} value={role.id}>{role.name} ({role.slug})</option>
))}
</select>
</FormField>
</div>
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
</Dialog>
<ConfirmDialog
open={Boolean(deleting)}
title="i18n:govoplan-access.delete_function_role_mapping.0c0eec6e"
message={i18nMessage("i18n:govoplan-access.delete_function_role_mapping_value.419da2aa", { value0: deleting?.function_id })}
confirmLabel="i18n:govoplan-access.delete_mapping.0d27d92a"
tone="danger"
busy={busy}
onCancel={() => setDeleting(null)}
onConfirm={() => void remove()}
/>
</>
);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function draftSelection(draft: typeof emptyDraft): OrganizationFunctionSelection {
return {
sourceModule: "organizations",
functionId: draft.functionId,
label: draft.functionLabel || null,
organizationUnitLabel: draft.organizationUnitLabel || null
};
}
function selectionDraft(selection: OrganizationFunctionSelection | null): Pick<typeof emptyDraft, "sourceModule" | "functionId" | "functionLabel" | "organizationUnitLabel"> {
return {
sourceModule: selection?.sourceModule ?? "organizations",
functionId: selection?.functionId ?? "",
functionLabel: selection?.label ?? "",
organizationUnitLabel: selection?.organizationUnitLabel ?? ""
};
}
function sortMappings(left: ExternalFunctionRoleMappingItem, right: ExternalFunctionRoleMappingItem): number {
const sourceDelta = left.source_module.localeCompare(right.source_module);
return sourceDelta !== 0 ? sourceDelta : left.function_id.localeCompare(right.function_id);
}
function sortRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -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);
}

View File

@@ -1,65 +1,99 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin"; import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, 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[] }; const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: { export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh
settings: ApiSettings;
auth: AuthInfo;
canDefine: boolean;
canManageMembers: boolean;
canAssignRoles: boolean;
onAuthRefresh: () => Promise<void>;
}) { }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [groups, setGroups] = useState<GroupSummary[]>([]); const [groups, setGroups] = useState<GroupSummary[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]); const [users, setUsers] = useState<UserAdminItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]); 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 [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
const [viewing, setViewing] = useState<GroupSummary | null>(null); const [viewing, setViewing] = useState<GroupSummary | null>(null);
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null); const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]); 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); setGroups(nextGroups);
setUsers(nextUsers); setUsers(nextUsers);
setRoles(nextRoles.filter((role) => role.is_assignable)); setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setLoading(false); } {setLoading(false);}
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]); 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); setEditing("new"); setError(""); } function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(group: GroupSummary) { function openEdit(group: GroupSummary) {
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) }); 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); setEditing(group);
setError(""); setError("");
} }
async function save() { function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
if (editing === "new") { 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 : [] }); 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(`Group ${draft.name} created.`); setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name }));
} else if (editing) { } else if (editing) {
const managed = Boolean(editing.system_template_id); const managed = Boolean(editing.system_template_id);
await updateGroup(settings, editing.id, { await updateGroup(settings, editing.id, {
@@ -68,13 +102,14 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
...(canManageMembers ? { member_ids: draft.memberIds } : {}), ...(canManageMembers ? { member_ids: draft.memberIds } : {}),
...(canAssignRoles ? { role_ids: draft.roleIds } : {}) ...(canAssignRoles ? { role_ids: draft.roleIds } : {})
}); });
setSuccess(`Group ${draft.name} updated.`); setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name }));
} }
setEditing(null); setEditing(null);
await onAuthRefresh(); await onAuthRefresh();
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } return true;
finally { setBusy(false); } } catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
} }
async function deactivate() { async function deactivate() {
@@ -83,57 +118,75 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
setError(""); setError("");
try { try {
await updateGroup(settings, deactivating.id, { is_active: false }); await updateGroup(settings, deactivating.id, { is_active: false });
setSuccess(`Group ${deactivating.name} deactivated.`); setSuccess(i18nMessage("i18n:govoplan-access.group_value_deactivated.8512bf9c", { value0: deactivating.name }));
setDeactivating(null); setDeactivating(null);
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh(); if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setBusy(false); } {setBusy(false);}
} }
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [ const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
{ id: "group", header: "Group", 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">System{row.system_required ? " · required" : ""}</span>}</div></div> }, { 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 && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
{ id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count }, { 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: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { 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: "Status", 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: "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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} /> { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
</div> } ]} /> }],
], [canAssignRoles, canDefine, canManageMembers]); [canAssignRoles, canDefine, canManageMembers]);
return ( return (
<> <>
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}> <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="No groups found." /></div> <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> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}> <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">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>} {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"> <div className="admin-form-grid two-columns">
<FormField label="Name"><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.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="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: 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="Status"><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">Active</option><option value="inactive">Inactive</option></select></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="Description"><textarea rows={3} value={draft.description} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></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>
<div className="admin-assignment-grid"> <div className="admin-assignment-grid">
<div><span className="form-label">Members</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="No tenant users exist." /></div> <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">Inherited roles</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="No assignable roles exist." /></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> </div>
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p> <p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="Group details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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"> {viewing && <dl className="admin-details-grid">
<div><dt>Group</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div> <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>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</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>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</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>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</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>} </dl>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} /> <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);
} }

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui"; import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers } from "../../api/admin"; import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { usePlatformUiCapability } from "@govoplan/core-webui"; import { usePlatformUiCapability } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = { type Props = {
settings: ApiSettings; settings: ApiSettings;
@@ -13,32 +14,32 @@ type Props = {
canWritePolicy: boolean; canWritePolicy: boolean;
}; };
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; profileTitle: string; policyTitle: string }> = { const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;profileTitle: string;policyTitle: string;}> = {
system: { system: {
title: "System mail profiles", title: "i18n:govoplan-access.system_mail_profiles.5af3eb64",
description: "Instance-level mail server profiles and policy limits inherited by every tenant.", description: "i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7",
profileTitle: "System profiles", profileTitle: "i18n:govoplan-access.system_profiles.98adae54",
policyTitle: "System mail profile policy" policyTitle: "i18n:govoplan-access.system_mail_profile_policy.7edd7fcf"
}, },
tenant: { tenant: {
title: "Tenant mail profiles", title: "i18n:govoplan-access.tenant_mail_profiles.5132a623",
description: "Tenant-level mail server profiles and policy limits for the active tenant.", description: "i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f",
profileTitle: "Tenant profiles", profileTitle: "i18n:govoplan-access.tenant_profiles.4d7281ce",
policyTitle: "Tenant mail profile policy" policyTitle: "i18n:govoplan-access.tenant_mail_profile_policy.239298a1"
}, },
user: { user: {
title: "User mail profiles", title: "i18n:govoplan-access.user_mail_profiles.f54a845a",
description: "User-scoped profiles and policy limits for campaign owners in the active tenant.", description: "i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf",
targetLabel: "User", targetLabel: "i18n:govoplan-access.user.9f8a2389",
profileTitle: "User profiles", profileTitle: "i18n:govoplan-access.user_profiles.57730285",
policyTitle: "User mail profile policy" policyTitle: "i18n:govoplan-access.user_mail_profile_policy.529e035b"
}, },
group: { group: {
title: "Group mail profiles", title: "i18n:govoplan-access.group_mail_profiles.ebf1b5ba",
description: "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.", description: "i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66",
targetLabel: "Group", targetLabel: "i18n:govoplan-access.group.171a0606",
profileTitle: "Group profiles", profileTitle: "i18n:govoplan-access.group_profiles.74568838",
policyTitle: "Group mail profile policy" policyTitle: "i18n:govoplan-access.group_mail_profile_policy.d98ef5a2"
} }
}; };
@@ -46,6 +47,9 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null; const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]); 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 [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
const [targetError, setTargetError] = useState(""); const [targetError, setTargetError] = useState("");
@@ -56,8 +60,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError(""); setTargetError("");
return; return;
} }
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets(); void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager, resetDeltaWatermark]);
async function loadTargets() { async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") { if (scopeType !== "user" && scopeType !== "group") {
@@ -70,14 +77,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError(""); setTargetError("");
try { try {
if (scopeType === "user") { if (scopeType === "user") {
const users = await fetchUsers(settings); 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) => ({ setTargets(users.map((user) => ({
id: user.id, id: user.id,
label: user.display_name || user.email, label: user.display_name || user.email,
secondary: user.display_name ? user.email : null secondary: user.display_name ? user.email : null
}))); })));
} else { } else {
const groups = await fetchGroups(settings); 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) => ({ setTargets(groups.map((group) => ({
id: group.id, id: group.id,
label: group.name, label: group.name,
@@ -97,11 +106,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
if (!MailProfileScopeManager) { if (!MailProfileScopeManager) {
return ( return (
<AdminPageLayout title={labels.title} description={labels.description}> <AdminPageLayout title={labels.title} description={labels.description}>
<Card title="Mail module unavailable"> <Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p> <p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
</Card> </Card>
</AdminPageLayout> </AdminPageLayout>);
);
} }
return ( return (
@@ -115,8 +124,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
policyTitle={labels.policyTitle} policyTitle={labels.policyTitle}
canWriteProfiles={canWriteProfiles} canWriteProfiles={canWriteProfiles}
canManageCredentials={canManageCredentials} canManageCredentials={canManageCredentials}
canWritePolicy={canWritePolicy} canWritePolicy={canWritePolicy} />
/>
</AdminPageLayout> </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);
} }

View File

@@ -1,142 +0,0 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers, runRetentionPolicy, type PrivacyRetentionPolicyScope, type RetentionRunResponse } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
type Props = {
settings: ApiSettings;
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
canWrite: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: {
title: "System retention",
description: "Instance-wide privacy retention policy and lower-level override permissions.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
},
tenant: {
title: "Tenant retention",
description: "Tenant-level privacy and retention limits for the active tenant.",
policyTitle: "Tenant retention policy",
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
},
user: {
title: "User retention",
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
targetLabel: "User",
policyTitle: "User retention policy",
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
},
group: {
title: "Group retention",
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
policyTitle: "Group retention policy",
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
}
};
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
const [targetError, setTargetError] = useState("");
const [busy, setBusy] = useState(false);
const [success, setSuccess] = useState("");
const [runError, setRunError] = useState("");
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await fetchUsers(settings);
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await fetchGroups(settings);
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
})));
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
async function runRetention(dryRun: boolean) {
setBusy(true);
setRunError("");
setSuccess("");
try {
const response = await runRetentionPolicy(settings, dryRun);
setRetentionResult(response);
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
setConfirmRetentionRun(false);
} catch (err) { setRunError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
const labels = copy[scopeType];
return (
<>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
<RetentionPolicyScopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={labels.targetLabel}
title={labels.policyTitle}
description={labels.policyDescription}
canWrite={canWrite}
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<p className="muted small-note">Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
</div>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="Apply retention policy"
message="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."
confirmLabel="Apply retention"
tone="danger"
busy={busy}
onCancel={() => setConfirmRetentionRun(false)}
onConfirm={() => void runRetention(false)}
/>
</>
);
}

View File

@@ -1,42 +1,61 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin"; import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
import { hasTenantWildcard } 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 }; 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> }) { export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]); const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]); const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null); const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null); const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [deleting, setDeleting] = useState<RoleSummary | null>(null); const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]); 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); setRoles(nextRoles);
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant")); setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setLoading(false); } {setLoading(false);}
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]); useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
const permissionGroups = useMemo(() => { const permissionGroups = useMemo(() => {
const groups = new Map<string, PermissionItem[]>(); const groups = new Map<string, PermissionItem[]>();
@@ -44,35 +63,46 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
return Array.from(groups.entries()); return Array.from(groups.entries());
}, [permissions]); }, [permissions]);
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); } function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(role: RoleSummary) { function openEdit(role: RoleSummary) {
if (role.is_builtin || role.system_template_id) return; if (role.is_builtin || role.system_template_id) return;
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable }); 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); setEditing(role);
setError(""); setError("");
} }
function togglePermission(scope: string, checked: boolean) {
const next = new Set(draft.permissions); function closeEditor() {
if (checked) next.add(scope); else next.delete(scope); setEditing(null);
setDraft({ ...draft, permissions: Array.from(next) }); setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function setPermissionGroup(scopes: string[], selected: string[]) {
const groupScopes = new Set(scopes);
setDraft({
...draft,
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
});
} }
async function save() { async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
if (editing === "new") { if (editing === "new") {
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions }); await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
setSuccess(`Role ${draft.name} created.`); setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
} else if (editing) { } else if (editing) {
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable }); await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
setSuccess(`Role ${draft.name} updated.`); setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
} }
setEditing(null); setEditing(null);
await onAuthRefresh(); await onAuthRefresh();
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } return true;
finally { setBusy(false); } } catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
} }
async function remove() { async function remove() {
@@ -81,51 +111,64 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
setError(""); setError("");
try { try {
await deleteRole(settings, deleting.id); await deleteRole(settings, deleting.id);
setSuccess(`Role ${deleting.name} deleted.`); setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
setDeleting(null); setDeleting(null);
await onAuthRefresh(); await onAuthRefresh();
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setBusy(false); } {setBusy(false);}
} }
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [ const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ id: "role", header: "Role", 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">System{row.system_required ? " · required" : ""}</span>}</div></div> }, { 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 && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
{ id: "permissions", header: "Permissions", 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: "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: "Assignments", 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: "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: "Type", 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 ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> }, { 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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
<AdminIconButton label={`Delete ${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} /> { id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
</div> } ]} /> }],
], [canDefine, permissions]); [canDefine, permissions]);
return ( return (
<> <>
<AdminPageLayout title="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}> <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="No roles found." /></div> <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> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit role"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}> <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"> <div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: 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="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: 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="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></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>
<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> <div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
const scopes = items.map((permission) => permission.scope);
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
})}</div>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="Role details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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"> {viewing && <><dl className="admin-details-grid">
<div><dt>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div> <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>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</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>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</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>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</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> </Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} /> <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);
} }

View File

@@ -1,22 +1,23 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { import {
createSystemRole, createSystemRole,
deleteSystemRole, deleteSystemRole,
fetchPermissionCatalog, fetchPermissionCatalog,
fetchSystemRoles, fetchSystemRolesDelta,
updateSystemRole, updateSystemRole,
type PermissionItem, type PermissionItem,
type RoleSummary type RoleSummary } from
} from "../../api/admin"; "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { const emptyDraft = {
slug: "", slug: "",
@@ -30,30 +31,41 @@ export default function SystemRolesPanel({
settings, settings,
canWrite, canWrite,
onAuthRefresh onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) { }: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]); const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]); const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null); const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null); const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [deleting, setDeleting] = useState<RoleSummary | null>(null); const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextRoles, catalogue] = await Promise.all([ const [nextRoles, catalogue] = await Promise.all([
fetchSystemRoles(settings), loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
fetchPermissionCatalog(settings) fetchPermissionCatalog(settings)]
]); );
rolesRef.current = nextRoles;
setRoles(nextRoles); setRoles(nextRoles);
setPermissions(catalogue.filter((item) => item.level === "system")); setPermissions(catalogue.filter((item) => item.level === "system"));
} catch (err) { } catch (err) {
@@ -63,25 +75,38 @@ export default function SystemRolesPanel({
} }
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() { function openCreate() {
setDraft(emptyDraft); setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new"); setEditing("new");
} }
function openEdit(role: RoleSummary) { function openEdit(role: RoleSummary) {
setDraft({ const nextDraft = {
slug: role.slug, slug: role.slug,
name: role.name, name: role.name,
description: role.description || "", description: role.description || "",
permissions: role.permissions, permissions: role.permissions,
isAssignable: role.is_assignable isAssignable: role.is_assignable
}); };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(role); setEditing(role);
} }
async function save() { function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -92,7 +117,7 @@ export default function SystemRolesPanel({
description: draft.description || null, description: draft.description || null,
permissions: draft.permissions permissions: draft.permissions
}); });
setSuccess(`System role ${draft.name} created.`); setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
} else if (editing) { } else if (editing) {
await updateSystemRole(settings, editing.id, { await updateSystemRole(settings, editing.id, {
name: draft.name, name: draft.name,
@@ -100,13 +125,15 @@ export default function SystemRolesPanel({
permissions: draft.permissions, permissions: draft.permissions,
is_assignable: draft.isAssignable is_assignable: draft.isAssignable
}); });
setSuccess(`System role ${draft.name} updated.`); setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
} }
setEditing(null); setEditing(null);
await load(); await load();
await onAuthRefresh(); await onAuthRefresh();
return true;
} catch (err) { } catch (err) {
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -118,7 +145,7 @@ export default function SystemRolesPanel({
setError(""); setError("");
try { try {
await deleteSystemRole(settings, deleting.id); await deleteSystemRole(settings, deleting.id);
setSuccess(`System role ${deleting.name} deleted.`); setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
setDeleting(null); setDeleting(null);
await load(); await load();
await onAuthRefresh(); await onAuthRefresh();
@@ -130,125 +157,133 @@ export default function SystemRolesPanel({
} }
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [ const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ {
id: "role", id: "role",
header: "System role", header: "i18n:govoplan-access.system_role.91762640",
width: 240, width: 240,
minWidth: 180, minWidth: 180,
maxWidth: 380, maxWidth: 380,
resizable: true, resizable: true,
sticky: "start", sticky: "start",
sortable: true, sortable: true,
filterable: true, filterable: true,
value: (row) => `${row.name} ${row.slug}`, value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
}, },
{ {
id: "description", id: "description",
header: "Description", header: "i18n:govoplan-access.description.55f8ebc8",
width: 360, width: 360,
fill: true, fill: true,
minWidth: 220, minWidth: 220,
maxWidth: 640, maxWidth: 640,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
value: (row) => row.description || "", value: (row) => row.description || "",
render: (row) => row.description || "—" render: (row) => row.description || "—"
}, },
{ {
id: "permissions", id: "permissions",
header: "Permissions", header: "i18n:govoplan-access.permissions.d06d5557",
width: 120, width: 120,
resizable: false, resizable: false,
sortable: true, sortable: true,
filterable: true, filterable: true,
filterType: "integer", filterType: "integer",
value: (row) => row.effective_permission_count, value: (row) => row.effective_permission_count,
render: (row) => String(row.effective_permission_count) render: (row) => String(row.effective_permission_count)
}, },
{ {
id: "assignable", id: "assignable",
header: "Assignable", header: "i18n:govoplan-access.assignable.a88debc5",
width: 120, width: 120,
resizable: false, resizable: false,
sortable: true, sortable: true,
filterable: true, filterable: true,
value: (row) => row.is_assignable ? "yes" : "no", value: (row) => row.is_assignable ? "yes" : "no",
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={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", id: "assignments",
header: "Accounts", header: "i18n:govoplan-access.accounts.36bae316",
width: 110, width: 110,
resizable: false, resizable: false,
sortable: true, sortable: true,
filterable: true, filterable: true,
filterType: "integer", filterType: "integer",
value: (row) => row.user_assignments value: (row) => row.user_assignments
}, },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-access.actions.c3cd636a",
width: 150, width: 150,
sticky: "end", sticky: "end",
resizable: false, resizable: false,
align: "right", align: "right",
render: (row) => { render: (row) => {
const protectedOwner = row.slug === "system_owner"; const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions"> return <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} /> { id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
</div>; ]} />;
}
} }
], [canWrite]); }],
[canWrite]);
return ( return (
<> <>
<AdminPageLayout <AdminPageLayout
title="System roles" title="i18n:govoplan-access.system_roles.a9461aa6"
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users." description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>} 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"> <div className="admin-table-surface">
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No system roles found." /> <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> </div>
</AdminPageLayout> </AdminPageLayout>
<Dialog <Dialog
open={editing !== null} open={editing !== null}
title={editing === "new" ? "Create system role" : "Edit system role"} title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
onClose={() => !busy && setEditing(null)} onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide" className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>} 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"> <div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: 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="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></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="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: 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>
</div> </div>
<div className="form-field"> <div className="form-field">
<span className="form-label">System permissions</span> <span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
<AdminSelectionList <AdminSelectionList
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
selected={draft.permissions} selected={draft.permissions}
onChange={(next) => setDraft({ ...draft, permissions: next })} onChange={(next) => setDraft({ ...draft, permissions: next })} />
/>
<p className="muted small-note">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p> <p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
</div> </div>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>} {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> </Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} /> <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);
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react"; import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -10,7 +10,7 @@ import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { import {
createSystemAccount, createSystemAccount,
fetchSystemAccounts, fetchSystemAccountsDelta,
fetchTenants, fetchTenants,
updateSystemAccount, updateSystemAccount,
updateSystemAccountRoles, updateSystemAccountRoles,
@@ -20,7 +20,7 @@ import {
type SystemMembershipDraft, type SystemMembershipDraft,
type TenantAdminItem type TenantAdminItem
} from "../../api/admin"; } from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const emptyDraft = { const emptyDraft = {
email: "", email: "",
@@ -40,49 +40,89 @@ export default function SystemUsersPanel({
canAssignRoles, canAssignRoles,
canManageMemberships, canManageMemberships,
onAuthRefresh onAuthRefresh
}: {
settings: ApiSettings;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
canAssignRoles: boolean;
canManageMemberships: boolean;
onAuthRefresh: () => Promise<void>;
}) { }: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]); const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]); const [roles, setRoles] = useState<RoleSummary[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]); 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 [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
const [viewing, setViewing] = useState<SystemAccountItem | null>(null); const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null); const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | null>(null); const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]); let nextWatermark = getDeltaWatermark("access:system-accounts");
setAccounts(access.accounts); let nextAccounts = accountsRef.current;
setRoles(access.roles); 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); setTenants(nextTenants);
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setLoading(false); } {setLoading(false);}
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); useEffect(() => {
accountsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() { function openCreate() {
setDraft(emptyDraft); setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new"); setEditing("new");
} }
function openEdit(item: SystemAccountItem) { function openEdit(item: SystemAccountItem) {
setDraft({ const nextDraft = {
email: item.email, email: item.email,
displayName: item.display_name || "", displayName: item.display_name || "",
password: "", password: "",
@@ -97,24 +137,29 @@ export default function SystemUsersPanel({
is_owner: membership.is_owner, is_owner: membership.is_owner,
is_last_active_owner: membership.is_last_active_owner is_last_active_owner: membership.is_last_active_owner
})) }))
}); };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(item); setEditing(item);
} }
function membership(tenantId: string) { function closeEditor() {
return draft.memberships.find((item) => item.tenant_id === tenantId); setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
} }
function setMembership(tenantId: string, enabled: boolean) { function setMembershipSelection(tenantIds: string[]) {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
memberships: enabled memberships: tenantIds.map((tenantId) =>
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] current.memberships.find((item) => item.tenant_id === tenantId) ??
: current.memberships.filter((item) => item.tenant_id !== tenantId) { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
)
})); }));
} }
async function save() { async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -129,21 +174,22 @@ export default function SystemUsersPanel({
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : [] 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 }); if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
setSuccess(`Global account ${response.account.email} created.`); setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
} else if (editing) { } else if (editing) {
const accountChanges: { display_name?: string | null; is_active?: boolean } = {}; const accountChanges: {display_name?: string | null;is_active?: boolean;} = {};
if (canUpdate) accountChanges.display_name = draft.displayName || null; if (canUpdate) accountChanges.display_name = draft.displayName || null;
if (canSuspend) accountChanges.is_active = draft.isActive; if (canSuspend) accountChanges.is_active = draft.isActive;
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges); if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds); 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 }))); 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(`Global account ${editing.email} updated.`); setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
} }
setEditing(null); setEditing(null);
await load(); await load();
await onAuthRefresh(); await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); } return true;
finally { setBusy(false); } } catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
} }
async function deactivate() { async function deactivate() {
@@ -152,73 +198,85 @@ export default function SystemUsersPanel({
setError(""); setError("");
try { try {
await updateSystemAccount(settings, deactivating.account_id, { is_active: false }); await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
setSuccess(`${deactivating.email} deactivated.`); setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
setDeactivating(null); setDeactivating(null);
await load(); await load();
await onAuthRefresh(); await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setBusy(false); } {setBusy(false);}
} }
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [ const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
{ id: "account", header: "Account", 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: "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: "Tenant memberships", 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: "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: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { 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: "Status", 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: "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: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) }, { 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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} /> { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
</div> } ]} /> }],
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]); [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
return ( return (
<> <>
<AdminPageLayout <AdminPageLayout
title="Central users" title="i18n:govoplan-access.central_users.91ac1b51"
description="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." description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>} 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="No global accounts found." /></div> <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> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}> <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"> <div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField> <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="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: 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" && ( {editing === "new" &&
<FormField label="Initial password"> <FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField <PasswordField
value={draft.password} value={draft.password}
placeholder="Leave empty to generate" placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password" autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} onValueChange={(password) => setDraft({ ...draft, password })} />
/>
</FormField> </FormField>
)} }
<FormField label="Account status"><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">Active</option><option value="inactive">Inactive</option></select></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>
<div className="admin-assignment-grid"> <div className="admin-assignment-grid">
<div><span className="form-label">System roles</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.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">Tenant memberships</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><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
</div> </div>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">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.</p>} {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">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</p> <p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="Global account details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>} {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>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}> <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>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>} {temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} /> <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);
} }

View File

@@ -3,14 +3,22 @@ import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin"; import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const DELTA_KEY = "access:tenant-settings";
const fallback: TenantSettingsItem = { const fallback: TenantSettingsItem = {
id: "", id: "",
slug: "", slug: "",
name: "", name: "",
default_locale: "en", 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: {} settings: {}
}; };
@@ -18,23 +26,42 @@ export default function TenantSettingsPanel({
settings, settings,
canWrite, canWrite,
onAuthRefresh onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) { }: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [draft, setDraft] = useState<TenantSettingsItem>(fallback); const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = 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() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
setSuccess(""); setSuccess("");
try { try {
setDraft(await fetchTenantSettings(settings)); 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) { } catch (err) {
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
} finally { } finally {
@@ -42,44 +69,98 @@ export default function TenantSettingsPanel({
} }
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); useEffect(() => {
resetDeltaWatermark(DELTA_KEY);
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
async function save() { async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
setSuccess(""); setSuccess("");
try { try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale }); const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
setDraft(saved); setDraft(saved);
setSuccess("Tenant general settings saved."); setSavedDraft(saved);
resetDeltaWatermark(DELTA_KEY);
setSuccess("i18n:govoplan-access.tenant_general_settings_saved.485e7681");
await onAuthRefresh(); await onAuthRefresh();
return true;
} catch (err) { } catch (err) {
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
function setEnabledLanguages(selected: string[]) {
const enabled = new Set(selected);
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 ( return (
<AdminPageLayout <AdminPageLayout
title="Tenant general settings" title="i18n:govoplan-access.tenant_general_settings.db1c3ba8"
description="Settings for the active tenant context." description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>} 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"> <div className="admin-settings-form">
<Card title="Locale"> <Card title="i18n:govoplan-access.locale.8970f0e6">
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults."> <FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /> <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> </FormField>
<AdminSelectionList
options={draft.system_enabled_language_codes.map((code) => {
const language = draft.available_languages.find((item) => item.code === code);
return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
})}
selected={draft.enabled_language_codes}
onChange={setEnabledLanguages}
/>
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
<dl className="detail-list"> <dl className="detail-list">
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div> <div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>Slug</dt><dd>{draft.slug || "-"}</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> </dl>
</Card> </Card>
</div> </div>
</AdminPageLayout> </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 } : {})
};
} }

View File

@@ -1,14 +1,15 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin"; import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type OverrideValue = "inherit" | "allow" | "deny"; type OverrideValue = "inherit" | "allow" | "deny";
type TenantDraft = { type TenantDraft = {
@@ -54,35 +55,46 @@ export default function TenantsPanel({
canUpdate, canUpdate,
canSuspend, canSuspend,
onAuthRefresh onAuthRefresh
}: {
settings: ApiSettings;
auth: AuthInfo;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
onAuthRefresh: () => Promise<void>;
}) { }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
const [tenants, setTenants] = useState<TenantAdminItem[]>([]); const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null); const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]); const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
const tenantsRef = useRef<TenantAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null); const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<TenantAdminItem | null>(null); const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
const [draft, setDraft] = useState<TenantDraft>(emptyDraft); const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null); const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([ const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
fetchTenants(settings), loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]), canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null) fetchSystemSettings(settings).catch(() => null)]
]); );
tenantsRef.current = nextTenants;
setTenants(nextTenants); setTenants(nextTenants);
setOwnerCandidates(nextOwnerCandidates); setOwnerCandidates(nextOwnerCandidates);
setSystemSettings(nextSystemSettings); setSystemSettings(nextSystemSettings);
@@ -93,16 +105,22 @@ export default function TenantsPanel({
} }
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); useEffect(() => {
tenantsRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() { function openCreate() {
setDraft({ ...emptyDraft, ownerAccountId: auth.user.account_id }); const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing("new"); setEditing("new");
setError(""); setError("");
} }
function openEdit(tenant: TenantAdminItem) { function openEdit(tenant: TenantAdminItem) {
setDraft({ const nextDraft = {
slug: tenant.slug, slug: tenant.slug,
name: tenant.name, name: tenant.name,
ownerAccountId: "", ownerAccountId: "",
@@ -112,12 +130,20 @@ export default function TenantsPanel({
customGroups: fromOverride(tenant.allow_custom_groups), customGroups: fromOverride(tenant.allow_custom_groups),
customRoles: fromOverride(tenant.allow_custom_roles), customRoles: fromOverride(tenant.allow_custom_roles),
apiKeys: fromOverride(tenant.allow_api_keys) apiKeys: fromOverride(tenant.allow_api_keys)
}); };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(tenant); setEditing(tenant);
setError(""); setError("");
} }
async function save() { function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -137,7 +163,7 @@ export default function TenantsPanel({
...governance ...governance
}); });
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId); const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
setSuccess(`Tenant ${created.name} created with ${selectedOwner?.display_name || selectedOwner?.email || "the selected account"} as Owner.`); 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(); await onAuthRefresh();
} else if (editing) { } else if (editing) {
const payload: Parameters<typeof updateTenant>[2] = {}; const payload: Parameters<typeof updateTenant>[2] = {};
@@ -151,13 +177,15 @@ export default function TenantsPanel({
} }
if (canSuspend) payload.is_active = draft.isActive; if (canSuspend) payload.is_active = draft.isActive;
await updateTenant(settings, editing.id, payload); await updateTenant(settings, editing.id, payload);
setSuccess(`Tenant ${draft.name} updated.`); setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_updated.25b2c855", { value0: draft.name }));
await onAuthRefresh(); await onAuthRefresh();
} }
setEditing(null); setEditing(null);
await load(); await load();
return true;
} catch (err) { } catch (err) {
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -169,7 +197,7 @@ export default function TenantsPanel({
setError(""); setError("");
try { try {
await updateTenant(settings, confirmSuspend.id, { is_active: false }); await updateTenant(settings, confirmSuspend.id, { is_active: false });
setSuccess(`${confirmSuspend.name} suspended.`); setSuccess(i18nMessage("i18n:govoplan-access.value_suspended.31731a28", { value0: confirmSuspend.name }));
setConfirmSuspend(null); setConfirmSuspend(null);
await onAuthRefresh(); await onAuthRefresh();
await load(); await load();
@@ -185,74 +213,82 @@ export default function TenantsPanel({
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false; const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false; const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
const systemDeniedGovernance = [ const systemDeniedGovernance = [
systemAllowsCustomGroups ? "" : "custom groups", systemAllowsCustomGroups ? "" : "i18n:govoplan-access.custom_groups.453a605c",
systemAllowsCustomRoles ? "" : "custom roles", systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
systemAllowsApiKeys ? "" : "API keys" systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
].filter(Boolean).join(", "); filter(Boolean).join(", ");
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [ const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
{ id: "name", header: "Tenant", 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: "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: "Users", 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: "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: "Groups", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 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: "Campaigns", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 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: "Files", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 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: "Locale", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale }, { 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: "Status", 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: "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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
<AdminIconButton label={`Suspend ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} /> { id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
</div> } ]} /> }],
], [activeTenantId, canSuspend, canUpdate]); [activeTenantId, canSuspend, canUpdate]);
return ( return (
<> <>
<AdminPageLayout <AdminPageLayout
title="Tenants" title="i18n:govoplan-access.tenants.1f7ae776"
description="Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended." description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>} 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="No tenants found." /></div> <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> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create tenant" : "Edit tenant"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" && !draft.ownerAccountId)}>{busy ? "Saving…" : "Save tenant"}</Button></>}> <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"> <div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <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="Slug"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: 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="Initial tenant owner"><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 ? `${candidate.display_name} (${candidate.email})` : candidate.email}</option>)}</select></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="Default locale"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></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="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Suspended</option></select></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="Description"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></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> </div>
<h3>System governance overrides</h3> <h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} /> <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="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} /> <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="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} /> <GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
</div> </div>
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p> <p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</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>
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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"> {viewing && <><dl className="admin-details-grid">
<div><dt>Tenant</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div> <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>Status</dt><dd>{viewing.is_active ? "Active" : "Suspended"}</dd></div><div><dt>Default locale</dt><dd>{viewing.default_locale}</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>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</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>Custom groups</dt><dd>{viewing.effective_governance.allow_custom_groups ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_groups)})</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>Custom roles</dt><dd>{viewing.effective_governance.allow_custom_roles ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_roles)})</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>API keys</dt><dd>{viewing.effective_governance.allow_api_keys ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_api_keys)})</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>Objects</dt><dd>{viewing.counts.users ?? 0} users, {viewing.counts.groups ?? 0} groups, {viewing.counts.campaigns ?? 0} campaigns, {viewing.counts.files ?? 0} files</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>}</>} </dl>{viewing.description && <p>{viewing.description}</p>}</>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(confirmSuspend)} title="Suspend tenant" message={`Suspend ${confirmSuspend?.name}? Existing data remains retained, but its members cannot use the tenant.`} confirmLabel="Suspend tenant" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} /> <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 }) { 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">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>; 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);
} }

View File

@@ -1,16 +1,18 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createUser, fetchGroups, fetchRoles, fetchUsers, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin"; 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 { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { PasswordField } from "@govoplan/core-webui"; import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { hasTenantWildcard } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { const emptyDraft = {
email: "", email: "",
@@ -22,51 +24,80 @@ const emptyDraft = {
roleIds: [] as string[] roleIds: [] as string[]
}; };
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: { 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>;
}) { }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [users, setUsers] = useState<UserAdminItem[]>([]); const [users, setUsers] = useState<UserAdminItem[]>([]);
const [groups, setGroups] = useState<GroupSummary[]>([]); const [groups, setGroups] = useState<GroupSummary[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]); 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 [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<UserAdminItem | 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 [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; password: string } | null>(null); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [nextUsers, nextGroups, nextRoles] = await Promise.all([fetchUsers(settings), fetchGroups(settings), fetchRoles(settings)]); 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); setUsers(nextUsers);
setGroups(nextGroups); setGroups(nextGroups);
setRoles(nextRoles.filter((role) => role.is_assignable)); setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setLoading(false); } {setLoading(false);}
} }
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]); useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
function openCreate() { function openCreate() {
setDraft(emptyDraft); setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new"); setEditing("new");
setError(""); setError("");
} }
function openEdit(user: UserAdminItem) { function openEdit(user: UserAdminItem) {
setDraft({ const nextDraft = {
email: user.email, email: user.email,
displayName: user.display_name || "", displayName: user.display_name || "",
password: "", password: "",
@@ -74,12 +105,20 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
isActive: user.is_active, isActive: user.is_active,
groupIds: user.groups.map((group) => group.id), groupIds: user.groups.map((group) => group.id),
roleIds: user.roles.map((role) => role.id) roleIds: user.roles.map((role) => role.id)
}); };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(user); setEditing(user);
setError(""); setError("");
} }
async function save() { function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -93,7 +132,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
group_ids: canManageGroups ? draft.groupIds : [], group_ids: canManageGroups ? draft.groupIds : [],
role_ids: canAssignRoles ? draft.roleIds : [] role_ids: canAssignRoles ? draft.roleIds : []
}); });
setSuccess(`Tenant membership created for ${response.user.email}.`); 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 }); if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
} else if (editing) { } else if (editing) {
const payload: Parameters<typeof updateUser>[2] = {}; const payload: Parameters<typeof updateUser>[2] = {};
@@ -102,13 +141,14 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
if (canManageGroups) payload.group_ids = draft.groupIds; if (canManageGroups) payload.group_ids = draft.groupIds;
if (canAssignRoles) payload.role_ids = draft.roleIds; if (canAssignRoles) payload.role_ids = draft.roleIds;
await updateUser(settings, editing.id, payload); await updateUser(settings, editing.id, payload);
setSuccess(`Access updated for ${editing.email}.`); setSuccess(i18nMessage("i18n:govoplan-access.access_updated_for_value.87f22245", { value0: editing.email }));
if (editing.id === auth.user.id) await onAuthRefresh(); if (editing.id === auth.user.id) await onAuthRefresh();
} }
setEditing(null); setEditing(null);
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } return true;
finally { setBusy(false); } } catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
} }
async function deactivate() { async function deactivate() {
@@ -117,73 +157,229 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
setError(""); setError("");
try { try {
await updateUser(settings, deactivating.id, { is_active: false }); await updateUser(settings, deactivating.id, { is_active: false });
setSuccess(`${deactivating.email} deactivated in this tenant.`); setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2", { value0: deactivating.email }));
if (deactivating.id === auth.user.id) await onAuthRefresh(); if (deactivating.id === auth.user.id) await onAuthRefresh();
setDeactivating(null); setDeactivating(null);
await load(); await load();
} catch (err) { setError(adminErrorMessage(err)); } } catch (err) {setError(adminErrorMessage(err));} finally
finally { setBusy(false); } {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>[]>(() => [ const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
{ id: "user", header: "User", 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: "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: "Groups", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) }, { 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: "Direct roles", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { 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: "Permissions", 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) ? "All" : String(row.effective_scopes.length) }, { 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: "Status", 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: "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: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) }, { 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: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} /> { id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
</div> } { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, onClick: () => setDeactivating(row) }
], [canAssignRoles, canManageGroups, canSuspend, canUpdate]); ]} /> }],
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
return ( return (
<> <>
<AdminPageLayout title="Tenant users" description="Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant user" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}> <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="No tenant users found." /></div> <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> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Add tenant user" : "Edit tenant user"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "Saving…" : "Save user"}</Button></>}> <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"> <div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField> <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="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: 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" && ( {editing === "new" &&
<FormField label="Initial password"> <FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField <PasswordField
value={draft.password} value={draft.password}
placeholder="Leave empty to generate" placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password" autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} onValueChange={(password) => setDraft({ ...draft, password })} />
/>
</FormField> </FormField>
)} }
<FormField label="Membership status"><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">Active</option><option value="inactive">Inactive</option></select></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> </div>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>} {editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">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.</p>} {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 className="admin-assignment-grid">
<div><span className="form-label">Groups</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="No groups exist yet." /></div> <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">Direct roles</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="No assignable roles exist." /></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> </div>
<p className="muted small-note">Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.</p> <p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="Tenant user details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}> <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"> {viewing && <dl className="admin-details-grid">
<div><dt>User</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>Email</dt><dd>{viewing.email}</dd></div> <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>Membership</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Global account</dt><dd>{viewing.account_is_active ? "Active" : "Inactive"}</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>Groups</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>Direct roles</dt><dd>{joinLabels(viewing.roles)}</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>Effective permissions</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "All tenant permissions" : viewing.effective_scopes.join(", ") || "None"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</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>} </dl>}
</Dialog> </Dialog>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}> <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>}>
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">Transmit it through a separate secure channel.</p></>} {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>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate tenant membership" message={`Deactivate ${deactivating?.email} in the active tenant? Historical ownership remains intact.`} confirmLabel="Deactivate user" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} /> <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";
}

View 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;
}

View File

@@ -0,0 +1,704 @@
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.available.7c62a142": "Available",
"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.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Map accepted function facts to tenant roles.",
"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.available.7c62a142": "Verfuegbar",
"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.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Akzeptierte Funktionsfakten Mandantenrollen zuordnen.",
"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."
}
};

View File

@@ -2,4 +2,6 @@ export { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/admin"; export * from "./api/admin";
export { default as AdminPage } from "./features/admin/AdminPage"; export { default as AdminPage } from "./features/admin/AdminPage";
export { ResourceAccessExplanation } from "@govoplan/core-webui";
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

View File

@@ -1,26 +1,51 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui"; import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
import { adminReadScopes } from "@govoplan/core-webui"; import { adminReadScopes } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
const AdminPage = lazy(() => import("./features/admin/AdminPage")); const AdminPage = lazy(() => import("./features/admin/AdminPage"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const accessAdminSurfaces = [
{ id: "access.admin.system-tenants", moduleId: "access", kind: "section" as const, label: "System tenants", order: 10 },
{ id: "access.admin.system-roles", moduleId: "access", kind: "section" as const, label: "System roles", order: 20 },
{ id: "access.admin.system-users", moduleId: "access", kind: "section" as const, label: "System users", order: 50 },
{ id: "access.admin.system-credentials", moduleId: "access", kind: "section" as const, label: "System credentials", order: 80 },
{ id: "access.admin.tenant-roles", moduleId: "access", kind: "section" as const, label: "Tenant roles", order: 10 },
{ id: "access.admin.tenant-function-mappings", moduleId: "access", kind: "section" as const, label: "Function mappings", order: 20 },
{ id: "access.admin.tenant-groups", moduleId: "access", kind: "section" as const, label: "Tenant groups", order: 30 },
{ id: "access.admin.tenant-users", moduleId: "access", kind: "section" as const, label: "Tenant users", order: 40 },
{ id: "access.admin.tenant-credentials", moduleId: "access", kind: "section" as const, label: "Tenant credentials", order: 70 },
{ id: "access.admin.tenant-api-keys", moduleId: "access", kind: "section" as const, label: "Tenant API keys", order: 80 },
{ id: "access.admin.tenant-settings", moduleId: "access", kind: "section" as const, label: "Tenant settings", order: 90 },
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "Group credentials", order: 30 },
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "User credentials", order: 30 },
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "Personal credentials", order: 30 }
];
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) { function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) { if (!onAuthChange) {
throw new Error("The access admin route requires the platform auth-change callback."); throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
} }
return createElement(AdminPage, { settings, auth, onAuthChange }); return createElement(AdminPage, { settings, auth, onAuthChange });
} }
export const accessModule: PlatformWebModule = { export const accessModule: PlatformWebModule = {
id: "access", id: "access",
label: "Access", label: "i18n:govoplan-access.access.2f81a22d",
version: "1.0.0", version: "1.0.0",
translations,
viewSurfaces: accessAdminSurfaces,
navItems: [ navItems: [
{ to: "/admin", label: "Admin", iconName: "admin", anyOf: adminReadScopes, order: 900 } { to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
],
routes: [ routes: [
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute } { path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }]
]
}; };
export default accessModule; export default accessModule;