From 04681f1d75a76403e8dcb2345d60d36c4eba5b69 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 12:51:16 +0200 Subject: [PATCH] feat: add access module boundary migrations --- README.md | 35 +- docs/ACCESS_MODULE_BOUNDARY.md | 157 ++ docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md | 177 ++ docs/OPENDESK_IDENTITY_BOUNDARY.md | 97 + package.json | 2 +- src/govoplan_access/__init__.py | 2 +- src/govoplan_access/auth/__init__.py | 21 + src/govoplan_access/backend/admin/service.py | 3 + .../backend/api/v1/admin_common.py | 5 +- .../backend/api/v1/admin_schemas.py | 380 +++ src/govoplan_access/backend/api/v1/auth.py | 158 +- src/govoplan_access/backend/api/v1/routes.py | 2188 ++++++++++++++++- .../backend/auth/dependencies.py | 33 +- .../backend/configuration_provider.py | 376 +++ src/govoplan_access/backend/db/models.py | 209 +- src/govoplan_access/backend/directory.py | 196 +- src/govoplan_access/backend/explanation.py | 256 ++ src/govoplan_access/backend/manifest.py | 229 +- .../backend/migrations/__init__.py | 1 + .../4a5b6c7d8e9f_access_semantic_directory.py | 396 +++ .../backend/migrations/versions/__init__.py | 1 + .../backend/security/sessions.py | 4 +- src/govoplan_access/backend/semantic.py | 156 ++ webui/package.json | 2 +- webui/src/api/admin.ts | 131 +- webui/src/features/admin/AdminAuditPanel.tsx | 140 +- webui/src/features/admin/AdminPage.tsx | 207 +- webui/src/features/admin/ApiKeysPanel.tsx | 169 +- .../features/admin/FileConnectorsPanel.tsx | 112 + webui/src/features/admin/GroupsPanel.tsx | 161 +- .../src/features/admin/MailProfilesPanel.tsx | 83 +- .../features/admin/RetentionPoliciesPanel.tsx | 107 +- webui/src/features/admin/RolesPanel.tsx | 126 +- webui/src/features/admin/SystemRolesPanel.tsx | 261 +- webui/src/features/admin/SystemUsersPanel.tsx | 196 +- .../features/admin/TenantSettingsPanel.tsx | 136 +- webui/src/features/admin/TenantsPanel.tsx | 178 +- webui/src/features/admin/UsersPanel.tsx | 192 +- webui/src/features/admin/utils/deltaRows.ts | 33 + webui/src/i18n/generatedTranslations.ts | 630 +++++ webui/src/module.ts | 21 +- 41 files changed, 7229 insertions(+), 738 deletions(-) create mode 100644 docs/ACCESS_MODULE_BOUNDARY.md create mode 100644 docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md create mode 100644 docs/OPENDESK_IDENTITY_BOUNDARY.md create mode 100644 src/govoplan_access/auth/__init__.py create mode 100644 src/govoplan_access/backend/configuration_provider.py create mode 100644 src/govoplan_access/backend/explanation.py create mode 100644 src/govoplan_access/backend/migrations/__init__.py create mode 100644 src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py create mode 100644 src/govoplan_access/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_access/backend/semantic.py create mode 100644 webui/src/features/admin/FileConnectorsPanel.tsx create mode 100644 webui/src/features/admin/utils/deltaRows.ts create mode 100644 webui/src/i18n/generatedTranslations.ts diff --git a/README.md b/README.md index b411bef..4f03f67 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ administration. The repository contains the extracted access seed implementation under `src/govoplan_access/backend`. Session, API-key, and password helper services, -interactive auth routes, FastAPI auth dependencies, and the legacy -administration router are owned here. Access-side admin service helpers remain +interactive auth routes, and administration routers are owned here. The public +FastAPI request dependency API is exported from `govoplan_access.auth`; modules +must not import the backend dependency module directly. Access-side admin +service helpers remain here for users, groups, roles, system accounts, sessions, API keys, tenant access enforcement, admin/audit lookup capabilities, tenant owner provisioning, and governance-template materialization into access-owned groups @@ -16,13 +18,16 @@ transitional administration WebUI route shell and access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic system administration panels are contributed by `@govoplan/admin-webui` through core's `admin.sections` UI capability. Live access ORM models are defined here -while retaining their historical table names; tenant records live in -`govoplan-tenancy`, governance templates in `govoplan-admin`, audit logs in -`govoplan-audit`, and system settings in `govoplan-core`. The staged -extraction path is documented in: +with `access_*` table names; tenant records live in `govoplan-tenancy`, +governance templates in `govoplan-admin`, audit logs in `govoplan-audit`, and +system settings in `govoplan-core`. The current access boundary is documented +in: -- `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md` +- `/mnt/DATA/git/govoplan-core/docs/ACCESS_RBAC_MODEL.md` - `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md` +- `docs/ACCESS_MODULE_BOUNDARY.md` +- `docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md` +- `docs/OPENDESK_IDENTITY_BOUNDARY.md` ## Initial Ownership @@ -33,13 +38,16 @@ This module will own: - API keys - legacy administration route contribution during the transition - tenant-local users +- identity-to-account projection for explainable access decisions +- organization-bound functions and function assignments +- explicit delegation and acting-in-place facts - groups and memberships - roles and role assignments - principal resolution - permission evaluation - access administration backend routes - access administration WebUI route contributions -- published FastAPI auth dependency API +- published FastAPI auth dependency API at `govoplan_access.auth` - access administration, tenant provisioning, and governance materializer capabilities - access-owned migrations @@ -50,6 +58,17 @@ contributed by `govoplan-admin`; access must not register those routes. `govoplan-access` depends on `govoplan-tenancy`; the registry loads tenancy before access for authenticated platform composition. +## 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 published `govoplan_access.auth` dependency API +instead of importing access ORM models or backend dependency internals. + +The detailed module boundary and serialization fields are documented in +[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md). + ## WebUI Package The repository root and `webui/` directory both expose the package diff --git a/docs/ACCESS_MODULE_BOUNDARY.md b/docs/ACCESS_MODULE_BOUNDARY.md new file mode 100644 index 0000000..63db79c --- /dev/null +++ b/docs/ACCESS_MODULE_BOUNDARY.md @@ -0,0 +1,157 @@ +# 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`, + `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 published +`govoplan_access.auth` request dependency API, not on access ORM models or +`govoplan_access.backend.*` implementation internals. + +## 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` + +`/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. + +## 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 +public `govoplan_access.auth` request dependency API, 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. diff --git a/docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md b/docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md new file mode 100644 index 0000000..f9ebc01 --- /dev/null +++ b/docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md @@ -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. diff --git a/docs/OPENDESK_IDENTITY_BOUNDARY.md b/docs/OPENDESK_IDENTITY_BOUNDARY.md new file mode 100644 index 0000000..aa12552 --- /dev/null +++ b/docs/OPENDESK_IDENTITY_BOUNDARY.md @@ -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. diff --git a/package.json b/package.json index 7d71660..e6132f2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ], "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/src/govoplan_access/__init__.py b/src/govoplan_access/__init__.py index 99ca0d5..8c62a8b 100644 --- a/src/govoplan_access/__init__.py +++ b/src/govoplan_access/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN access platform module.""" -__version__ = "0.1.4" +__version__ = "0.1.6" diff --git a/src/govoplan_access/auth/__init__.py b/src/govoplan_access/auth/__init__.py new file mode 100644 index 0000000..48e21a4 --- /dev/null +++ b/src/govoplan_access/auth/__init__.py @@ -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", +] diff --git a/src/govoplan_access/backend/admin/service.py b/src/govoplan_access/backend/admin/service.py index 3fb1a76..c35f324 100644 --- a/src/govoplan_access/backend/admin/service.py +++ b/src/govoplan_access/backend/admin/service.py @@ -20,6 +20,7 @@ from govoplan_access.backend.db.models import ( UserGroupMembership, UserRoleAssignment, ) +from govoplan_access.backend.semantic import ensure_identity_for_account from govoplan_access.backend.security.passwords import hash_password from govoplan_core.security.permissions import ( DEFAULT_SYSTEM_ROLES, @@ -98,6 +99,7 @@ def get_or_create_account( raise AdminConflictError( "The global account is disabled. A system administrator must reactivate it before adding a tenant membership." ) + ensure_identity_for_account(session, account) return account, False, None temporary_password = password or generate_temporary_password() @@ -112,6 +114,7 @@ def get_or_create_account( ) session.add(account) session.flush() + ensure_identity_for_account(session, account) return account, True, temporary_password diff --git a/src/govoplan_access/backend/api/v1/admin_common.py b/src/govoplan_access/backend/api/v1/admin_common.py index 0cf7fa6..d304f4f 100644 --- a/src/govoplan_access/backend/api/v1/admin_common.py +++ b/src/govoplan_access/backend/api/v1/admin_common.py @@ -25,6 +25,7 @@ from govoplan_access.backend.security.sessions import ( collect_user_groups, collect_user_scopes, ) +from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope from govoplan_access.backend.db.models import ( Account, @@ -44,7 +45,7 @@ def _http_admin_error(exc: Exception) -> HTTPException: if isinstance(exc, AdminConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, AdminValidationError): - return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_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)) @@ -150,6 +151,8 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non last_login_at=account.last_login_at, groups=[_group_summary(session, group, include_members=False) for group in groups], roles=[_role_summary(session, role) for role in roles], + function_assignment_ids=collect_function_assignment_ids(session, user), + function_delegation_ids=collect_function_delegation_ids(session, user), effective_scopes=collect_user_scopes(session, user, include_system=False), is_owner=user.id in effective_owner_ids, is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1, diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index 545fd24..29f351a 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem + RETENTION_DAY_KEYS = ( "raw_campaign_json_retention_days", "generated_eml_retention_days", @@ -128,6 +130,9 @@ class TenantSettingsItem(BaseModel): slug: str name: str default_locale: str = Field(default="en", min_length=1, max_length=20) + available_languages: list[dict[str, Any]] = Field(default_factory=list) + system_enabled_language_codes: list[str] = Field(default_factory=list) + enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) @@ -135,6 +140,7 @@ class TenantSettingsUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") default_locale: str = Field(min_length=1, max_length=20) + enabled_language_codes: list[str] | None = None class RoleSummary(BaseModel): @@ -153,6 +159,227 @@ class RoleSummary(BaseModel): 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(BaseModel): + identities: list[IdentityAdminItem] + + +class IdentityCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=255) + external_subject: str | None = Field(default=None, max_length=255) + source: str = Field(default="local", max_length=50) + account_ids: list[str] = Field(default_factory=list) + primary_account_id: str | None = None + is_active: bool = True + + +class IdentityUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=255) + external_subject: str | None = Field(default=None, max_length=255) + source: str | None = Field(default=None, max_length=50) + account_ids: list[str] | None = None + primary_account_id: str | None = None + is_active: bool | None = None + + +class OrganizationUnitItem(BaseModel): + id: str + tenant_id: str + parent_id: str | None = None + slug: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class OrganizationUnitListResponse(BaseModel): + organization_units: list[OrganizationUnitItem] + + +class OrganizationUnitCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + slug: str + name: str = Field(min_length=1, max_length=255) + parent_id: str | None = None + description: str | None = None + is_active: bool = True + + +class OrganizationUnitUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + slug: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=255) + parent_id: str | None = None + description: str | None = None + is_active: bool | None = None + + +class FunctionAdminItem(BaseModel): + id: str + tenant_id: str + organization_unit_id: str + slug: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + role_ids: list[str] = Field(default_factory=list) + delegable: bool = False + act_in_place_allowed: bool = False + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class FunctionListResponse(BaseModel): + functions: list[FunctionAdminItem] + + +class FunctionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + organization_unit_id: str + slug: str + name: str = Field(min_length=1, max_length=255) + description: str | None = None + role_ids: list[str] = Field(default_factory=list) + delegable: bool = False + act_in_place_allowed: bool = False + is_active: bool = True + + +class FunctionUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + organization_unit_id: str | None = None + slug: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + role_ids: list[str] | None = None + delegable: bool | None = None + act_in_place_allowed: bool | None = None + is_active: bool | None = None + + +class FunctionAssignmentAdminItem(BaseModel): + id: str + tenant_id: str + account_id: str + identity_id: str | None = None + function_id: str + organization_unit_id: str + applies_to_subunits: bool = False + source: str = "direct" + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class FunctionAssignmentListResponse(BaseModel): + assignments: list[FunctionAssignmentAdminItem] + + +class FunctionAssignmentCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + account_id: str + function_id: str + organization_unit_id: str | None = None + identity_id: str | None = None + applies_to_subunits: bool = False + source: str = Field(default="direct", max_length=50) + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + + +class FunctionAssignmentUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + identity_id: str | None = None + organization_unit_id: str | None = None + applies_to_subunits: bool | None = None + source: str | None = Field(default=None, max_length=50) + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + + +class FunctionDelegationAdminItem(BaseModel): + id: str + tenant_id: str + function_assignment_id: str + delegator_account_id: str + delegate_account_id: str + mode: Literal["delegate", "act_in_place"] = "delegate" + reason: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + revoked_at: datetime | None = None + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class FunctionDelegationListResponse(BaseModel): + delegations: list[FunctionDelegationAdminItem] + + +class FunctionDelegationCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + function_assignment_id: str + delegate_account_id: str + mode: Literal["delegate", "act_in_place"] = "delegate" + reason: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + + +class FunctionDelegationUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + reason: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + revoked: bool | None = None + + class GroupSummary(BaseModel): id: str slug: str = Field(min_length=1, max_length=100) @@ -180,6 +407,8 @@ class UserAdminItem(BaseModel): last_login_at: datetime | None = None groups: list[GroupSummary] = Field(default_factory=list) roles: list[RoleSummary] = Field(default_factory=list) + function_assignment_ids: list[str] = Field(default_factory=list) + function_delegation_ids: list[str] = Field(default_factory=list) effective_scopes: list[str] = Field(default_factory=list) is_owner: bool = False is_last_active_owner: bool = False @@ -191,6 +420,13 @@ class UserListResponse(BaseModel): users: list[UserAdminItem] +class UserListDeltaResponse(UserListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class UserCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -222,6 +458,13 @@ class GroupListResponse(BaseModel): groups: list[GroupSummary] +class GroupListDeltaResponse(GroupListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class GroupCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -247,6 +490,13 @@ class RoleListResponse(BaseModel): roles: list[RoleSummary] +class RoleListDeltaResponse(RoleListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class RoleCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -280,6 +530,13 @@ class SystemAccountListResponse(BaseModel): roles: list[RoleSummary] +class SystemAccountListDeltaResponse(SystemAccountListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class SystemAccountUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -396,12 +653,124 @@ class RetentionRunResponse(BaseModel): result: dict[str, Any] +class ConfigurationSafetyFieldItem(BaseModel): + key: str + label: str + owner_module: str + scope: Literal["system", "tenant", "user", "group", "campaign"] + storage: str + ui_managed: bool + risk: Literal["low", "medium", "high", "destructive"] + secret_handling: Literal["none", "reference_only", "env_only"] = "none" + required_scopes: list[str] = Field(default_factory=list) + dry_run_required: bool = False + validation_required: bool = True + policy_explanation_required: bool = False + audit_event: str | None = None + maintenance_required: bool = False + two_person_approval_required: bool = False + rollback_history_required: bool = False + notes: str | None = None + + +class ConfigurationSafetyCatalogResponse(BaseModel): + fields: list[ConfigurationSafetyFieldItem] + + +class ConfigurationChangeSafetyPlanRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + value: Any = None + dry_run: bool = False + maintenance_mode: bool = False + approval_count: int = Field(default=0, ge=0) + + +class ConfigurationChangeSafetyPlanResponse(BaseModel): + plan: dict[str, Any] + + +class ConfigurationChangeRequestCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + value: Any = None + dry_run: bool = False + target: dict[str, Any] = Field(default_factory=dict) + reason: str | None = Field(default=None, max_length=1000) + + +class ConfigurationChangeRequestApproveRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + reason: str | None = Field(default=None, max_length=1000) + + +class ConfigurationChangeRequestResponse(BaseModel): + request: dict[str, Any] + + +class ConfigurationControlSnapshotResponse(BaseModel): + requests: list[dict[str, Any]] = Field(default_factory=list) + history: list[dict[str, Any]] = Field(default_factory=list) + + +class ConfigurationControlDeltaResponse(ConfigurationControlSnapshotResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + +class ConfigurationPackageCatalogResponse(BaseModel): + validation: dict[str, Any] + + +class ConfigurationPackageRunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + package: dict[str, Any] + tenant_id: str | None = None + supplied_data: dict[str, Any] = Field(default_factory=dict) + change_request_id: str | None = None + + +class ConfigurationPackageDryRunResponse(BaseModel): + diagnostics: list[dict[str, Any]] = Field(default_factory=list) + required_data: list[dict[str, Any]] = Field(default_factory=list) + plan: list[dict[str, Any]] = Field(default_factory=list) + + +class ConfigurationPackageApplyResponse(BaseModel): + diagnostics: list[dict[str, Any]] = Field(default_factory=list) + created_refs: dict[str, str] = Field(default_factory=dict) + updated_refs: dict[str, str] = Field(default_factory=dict) + + +class ConfigurationPackageExportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str | None = None + scopes: list[str] = Field(default_factory=list) + module_ids: list[str] = Field(default_factory=list) + object_refs: list[str] = Field(default_factory=list) + + +class ConfigurationPackageExportResponse(BaseModel): + fragments: list[dict[str, Any]] = Field(default_factory=list) + data_requirements: list[dict[str, Any]] = Field(default_factory=list) + diagnostics: list[dict[str, Any]] = Field(default_factory=list) + + class SystemSettingsItem(BaseModel): default_locale: str = "en" allow_tenant_custom_groups: bool = True allow_tenant_custom_roles: bool = True allow_tenant_api_keys: bool = True privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) + available_languages: list[dict[str, Any]] = Field(default_factory=list) + enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) @@ -413,6 +782,8 @@ class SystemSettingsUpdateRequest(BaseModel): allow_tenant_custom_roles: bool allow_tenant_api_keys: bool privacy_retention_policy: PrivacyRetentionPolicyItem | None = None + available_languages: list[dict[str, Any]] | None = None + enabled_language_codes: list[str] | None = None class ApiKeyAdminItem(BaseModel): @@ -432,6 +803,13 @@ class ApiKeyListResponse(BaseModel): api_keys: list[ApiKeyAdminItem] +class ApiKeyListDeltaResponse(ApiKeyListResponse): + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class AdminApiKeyCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -463,3 +841,5 @@ class AuditAdminListResponse(BaseModel): page: int = 1 page_size: int = 100 pages: int = 1 + cursor: str | None = None + next_cursor: str | None = None diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 888090b..9d0513d 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -8,21 +8,36 @@ from govoplan_core.api.v1.schemas import ( LoginRequest, LoginResponse, MeResponse, + PrincipalContextInfo, ProfileUpdateRequest, RoleInfo, SwitchTenantRequest, TenantInfo, TenantMembershipInfo, UserInfo, + UserUiPreferences, ) +from govoplan_core.core.access import AuthMethod, PrincipalRef from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal +from govoplan_core.admin.settings import get_system_settings from govoplan_core.audit.logging import audit_from_principal 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_core.db.session import get_session +from govoplan_core.i18n import ( + i18n_settings, + normalize_enabled_language_codes, + normalize_language_code, + preferred_language_code, + system_enabled_language_codes, + system_i18n_payload, + tenant_enabled_language_codes, + user_enabled_language_codes, +) from govoplan_core.security.permissions import normalize_email, scopes_grant from govoplan_core.security.time import utc_now from govoplan_core.settings import settings +from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.sessions import ( collect_system_roles, @@ -68,17 +83,32 @@ def _clear_auth_cookies(response: Response) -> None: 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( id=tenant.id, slug=tenant.slug, name=tenant.name, is_active=tenant.is_active, default_locale=tenant.default_locale, + enabled_language_codes=enabled_language_codes or [], ) -def _user_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( id=user.id, account_id=account.id, @@ -87,6 +117,9 @@ def _user_info(user: User, account: Account) -> UserInfo: tenant_display_name=user.display_name, is_tenant_admin=user.is_tenant_admin, password_reset_required=account.password_reset_required, + preferred_language=preferred_language, + enabled_language_codes=enabled_language_codes or [], + ui_preferences=_user_ui_preferences(user.settings), ) @@ -124,6 +157,25 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember return memberships +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 _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]: account = ( session.query(Account) @@ -157,13 +209,22 @@ def _me_response( user: User, tenant: Tenant, effective_scopes: list[str] | None = None, + auth_method: AuthMethod = "session", + api_key_id: str | None = None, + session_id: str | None = None, + service_account_id: str | None = None, include_system: bool = True, include_all_memberships: bool = True, ) -> MeResponse: tenant_roles = collect_user_roles(session, user) system_roles = collect_system_roles(session, account) if include_system else [] groups = collect_user_groups(session, user) - active_tenant = _tenant_info(tenant) + scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system) + 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) if include_all_memberships else [ TenantMembershipInfo( id=tenant.id, @@ -175,13 +236,35 @@ def _me_response( ) ] return MeResponse( - user=_user_info(user, account), + user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled), tenant=active_tenant, active_tenant=active_tenant, 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"), 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_for_account(session, account.id), + 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(collect_function_assignment_ids(session, user)), + delegation_ids=frozenset(collect_function_delegation_ids(session, user)), + 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, ) @@ -209,7 +292,15 @@ def login(payload: LoginRequest, request: Request, response: Response, session: return LoginResponse( access_token=created.token, expires_at=created.model.expires_at, - **me_payload.model_dump(), + **_me_response( + session, + account=account, + user=user, + tenant=tenant, + effective_scopes=me_payload.scopes, + auth_method="session", + session_id=created.model.id, + ).model_dump(), ) @@ -224,6 +315,10 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = user=principal.user, tenant=tenant, effective_scopes=principal.scopes, + auth_method=principal.auth_method, + api_key_id=principal.api_key_id, + session_id=principal.session_id, + service_account_id=principal.principal.service_account_id, include_system=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None, ) @@ -244,6 +339,46 @@ def update_profile( 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 session.add(principal.user) + next_settings = dict(principal.user.settings or {}) + settings_changed = False + if {"preferred_language", "enabled_language_codes"}.intersection(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") + 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(tenant.settings, system_enabled, default_locale=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: + principal.user.settings = next_settings + session.add(principal.user) audit_from_principal( session, @@ -262,6 +397,8 @@ def update_profile( account=principal.account, user=principal.user, tenant=tenant, + auth_method=principal.auth_method, + session_id=principal.session_id, include_system=True, include_all_memberships=True, ) @@ -283,7 +420,14 @@ def switch_tenant( if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") session.commit() - return _me_response(session, account=principal.account, user=membership, tenant=tenant) + return _me_response( + session, + account=principal.account, + user=membership, + tenant=tenant, + auth_method="session", + session_id=principal.session_id, + ) @router.post("/logout") diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index 3e3962f..309ec77 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -1,6 +1,10 @@ from __future__ import annotations +from collections.abc import Iterable +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_access.backend.admin.governance import ( @@ -52,20 +56,59 @@ from govoplan_access.backend.api.v1.admin_schemas import ( AdminApiKeyCreateRequest, AdminApiKeyCreateResponse, ApiKeyAdminItem, + ApiKeyListDeltaResponse, ApiKeyListResponse, GroupCreateRequest, + GroupListDeltaResponse, GroupListResponse, GroupSummary, GroupUpdateRequest, + FunctionAdminItem, + FunctionAssignmentAdminItem, + FunctionAssignmentCreateRequest, + FunctionAssignmentListResponse, + FunctionAssignmentUpdateRequest, + FunctionCreateRequest, + FunctionDelegationAdminItem, + FunctionDelegationCreateRequest, + FunctionDelegationListResponse, + FunctionDelegationUpdateRequest, + FunctionListResponse, + FunctionUpdateRequest, + IdentityAdminItem, + IdentityCreateRequest, + IdentityListResponse, + IdentityUpdateRequest, + OrganizationUnitCreateRequest, + OrganizationUnitItem, + OrganizationUnitListResponse, + OrganizationUnitUpdateRequest, + ConfigurationChangeSafetyPlanRequest, + ConfigurationChangeSafetyPlanResponse, + ConfigurationChangeRequestApproveRequest, + ConfigurationChangeRequestCreateRequest, + ConfigurationChangeRequestResponse, + ConfigurationControlDeltaResponse, + ConfigurationControlSnapshotResponse, + ConfigurationPackageApplyResponse, + ConfigurationPackageCatalogResponse, + ConfigurationPackageDryRunResponse, + ConfigurationPackageExportRequest, + ConfigurationPackageExportResponse, + ConfigurationPackageRunRequest, + ConfigurationSafetyCatalogResponse, + ConfigurationSafetyFieldItem, PermissionCatalogResponse, PermissionItem, RoleCreateRequest, + RoleListDeltaResponse, RoleListResponse, RoleSummary, RoleUpdateRequest, SystemAccountCreateRequest, SystemAccountCreateResponse, SystemAccountItem, + SystemAccountListDeltaResponse, SystemAccountListResponse, SystemAccountMembershipsUpdateRequest, SystemAccountRolesUpdateRequest, @@ -73,6 +116,7 @@ from govoplan_access.backend.api.v1.admin_schemas import ( UserCreateRequest, UserCreateResponse, UserAdminItem, + UserListDeltaResponse, UserListResponse, UserUpdateRequest, ) @@ -80,13 +124,467 @@ from govoplan_access.backend.security.api_keys import create_api_key from govoplan_access.backend.security.sessions import collect_user_scopes from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope from govoplan_core.audit.logging import audit_event, audit_from_principal -from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, Tenant, User +from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider +from govoplan_access.backend.runtime import get_registry +from govoplan_core.core.configuration_packages import ( + CONFIGURATION_PROVIDER_CAPABILITY, + ConfigurationExportSelection, + ConfigurationPreflightContext, + apply_configuration_package, + dry_run_configuration_package, + export_configuration_package, + validate_configuration_package_catalog, +) +from govoplan_core.core.configuration_control import ( + CONFIGURATION_CHANGE_RECORD_RESOURCE, + CONFIGURATION_CHANGE_REQUEST_RESOURCE, + CONFIGURATION_CHANGES_COLLECTION, + CONFIGURATION_CONTROL_MODULE_ID, + ConfigurationControlError, + approve_configuration_change_request, + configuration_control_snapshot, + create_configuration_change_request, + ensure_configuration_change_allowed, + record_configuration_change_applied, +) +from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change +from govoplan_core.api.v1.schemas import DeltaDeletedItem +from govoplan_core.core.change_sequence import ( + decode_sequence_watermark, + encode_sequence_watermark, + max_sequence_id, + record_change, + sequence_entries_since, + sequence_watermark_is_expired, +) +from govoplan_access.backend.db.models import ( + Account, + ApiKey, + Function, + FunctionAssignment, + FunctionDelegation, + FunctionRoleAssignment, + Group, + GroupRoleAssignment, + Identity, + IdentityAccountLink, + OrganizationUnit, + Role, + SystemRoleAssignment, + Tenant, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_access.backend.semantic import identity_id_for_account from govoplan_core.db.session import get_session from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant from govoplan_core.security.time import utc_now router = APIRouter(prefix="/admin", tags=["admin"]) +ACCESS_MODULE_ID = "access" +ACCESS_USERS_COLLECTION = "access.admin.users" +ACCESS_GROUPS_COLLECTION = "access.admin.groups" +ACCESS_ROLES_COLLECTION = "access.admin.roles" +ACCESS_IDENTITIES_COLLECTION = "access.admin.identities" +ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units" +ACCESS_FUNCTIONS_COLLECTION = "access.admin.functions" +ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION = "access.admin.function_assignments" +ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations" +ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles" +ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts" +ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys" + + +def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + tenant_id=tenant_id, + module_id=ACCESS_MODULE_ID, + collections=collections, + ) + ) + + +def _access_delta_entries( + session: Session, + *, + tenant_id: str | None, + collections: tuple[str, ...], + since: str, + limit: int, +): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=tenant_id, + module_id=ACCESS_MODULE_ID, + collections=collections, + ): + return None, False + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=tenant_id, + module_id=ACCESS_MODULE_ID, + collections=collections, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _access_delta_response_watermark( + session: Session, + *, + tenant_id: str | None, + collections: tuple[str, ...], + entries, + has_more: bool, +) -> str: + return ( + encode_sequence_watermark(entries[-1].id) + if has_more and entries + else _access_delta_watermark(session, tenant_id, collections) + ) + + +def _delta_deleted_item(entry) -> DeltaDeletedItem: + return DeltaDeletedItem( + id=entry.resource_id, + resource_type=entry.resource_type, + revision=encode_sequence_watermark(entry.id), + deleted_at=entry.created_at if entry.operation == "deleted" else None, + ) + + +def _changed_ids(entries, resource_type: str) -> list[str]: + return list(dict.fromkeys(entry.resource_id for entry in entries if entry.resource_type == resource_type)) + + +def _record_access_change( + session: Session, + *, + collection: str, + resource_type: str, + resource_id: str | None, + operation: str, + tenant_id: str | None, + principal: ApiPrincipal, + payload: dict | None = None, +) -> None: + if not resource_id: + return + record_change( + session, + module_id=ACCESS_MODULE_ID, + collection=collection, + resource_type=resource_type, + resource_id=resource_id, + operation=operation, + tenant_id=tenant_id, + actor_type="user", + actor_id=principal.user.id, + payload=payload or {}, + ) + + +def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None: + if not any(has_scope(principal, scope) for scope in scopes): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}") + + +def _identity_item(session: Session, identity: Identity) -> IdentityAdminItem: + rows = ( + session.query(IdentityAccountLink, Account) + .join(Account, Account.id == IdentityAccountLink.account_id) + .filter(IdentityAccountLink.identity_id == identity.id) + .order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) + .all() + ) + return IdentityAdminItem( + id=identity.id, + display_name=identity.display_name, + external_subject=identity.external_subject, + source=identity.source, + is_active=identity.is_active, + accounts=[ + { + "id": link.id, + "account_id": account.id, + "email": account.email, + "display_name": account.display_name, + "is_primary": link.is_primary, + "source": link.source, + } + for link, account in rows + ], + created_at=identity.created_at, + updated_at=identity.updated_at, + ) + + +def _set_identity_accounts( + session: Session, + *, + identity: Identity, + account_ids: list[str], + primary_account_id: str | None, + source: str, +) -> None: + requested = list(dict.fromkeys(account_ids)) + if primary_account_id and primary_account_id not in requested: + requested.insert(0, primary_account_id) + if not requested: + session.query(IdentityAccountLink).filter(IdentityAccountLink.identity_id == identity.id).delete(synchronize_session=False) + session.flush() + return + accounts = session.query(Account).filter(Account.id.in_(requested)).all() + if len(accounts) != len(requested): + raise AdminValidationError("One or more account IDs are unknown.") + conflicting = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.account_id.in_(requested), IdentityAccountLink.identity_id != identity.id) + .first() + ) + if conflicting is not None: + raise AdminConflictError("One or more accounts are already linked to another identity.") + primary = primary_account_id or requested[0] + session.query(IdentityAccountLink).filter(IdentityAccountLink.identity_id == identity.id).delete(synchronize_session=False) + for account_id in requested: + session.add( + IdentityAccountLink( + identity_id=identity.id, + account_id=account_id, + is_primary=account_id == primary, + source=source, + ) + ) + session.flush() + + +def _organization_unit_item(item: OrganizationUnit) -> OrganizationUnitItem: + return OrganizationUnitItem( + id=item.id, + tenant_id=item.tenant_id, + parent_id=item.parent_id, + slug=item.slug, + name=item.name, + description=item.description, + is_active=item.is_active, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _validate_parent_organization_unit( + session: Session, + *, + tenant_id: str, + parent_id: str | None, + current_id: str | None = None, +) -> None: + if parent_id is None: + return + if parent_id == current_id: + raise AdminValidationError("An organization unit cannot be its own parent.") + parent = session.query(OrganizationUnit).filter(OrganizationUnit.id == parent_id, OrganizationUnit.tenant_id == tenant_id).one_or_none() + if parent is None: + raise AdminValidationError("Parent organization unit does not belong to the tenant.") + + +def _function_item(session: Session, item: Function) -> FunctionAdminItem: + role_ids = [ + row[0] + for row in session.query(FunctionRoleAssignment.role_id) + .filter(FunctionRoleAssignment.function_id == item.id) + .order_by(FunctionRoleAssignment.created_at.asc()) + .all() + ] + return FunctionAdminItem( + id=item.id, + tenant_id=item.tenant_id, + organization_unit_id=item.organization_unit_id, + slug=item.slug, + name=item.name, + description=item.description, + role_ids=role_ids, + delegable=item.delegable, + act_in_place_allowed=item.act_in_place_allowed, + is_active=item.is_active, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _set_function_roles(session: Session, *, function: Function, role_ids: list[str]) -> None: + requested = sorted(set(role_ids)) + roles = ( + session.query(Role) + .filter(Role.tenant_id == function.tenant_id, Role.id.in_(requested), Role.is_assignable.is_(True)) + .all() + if requested else [] + ) + if len(roles) != len(requested): + raise AdminValidationError("One or more selected roles are invalid or not assignable.") + session.query(FunctionRoleAssignment).filter(FunctionRoleAssignment.function_id == function.id).delete(synchronize_session=False) + for role in roles: + session.add(FunctionRoleAssignment(tenant_id=function.tenant_id, function_id=function.id, role_id=role.id)) + session.flush() + + +def _function_assignment_item(item: FunctionAssignment) -> FunctionAssignmentAdminItem: + return FunctionAssignmentAdminItem( + 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, + 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, + is_active=item.is_active, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _validate_assignment_window(valid_from, valid_until) -> None: + if valid_from is not None and valid_until is not None and valid_until <= valid_from: + raise AdminValidationError("valid_until must be after valid_from.") + + +def _function_delegation_item(item: FunctionDelegation) -> FunctionDelegationAdminItem: + return FunctionDelegationAdminItem( + id=item.id, + tenant_id=item.tenant_id, + function_assignment_id=item.function_assignment_id, + delegator_account_id=item.delegator_account_id, + delegate_account_id=item.delegate_account_id, + mode=item.mode, # type: ignore[arg-type] + reason=item.reason, + valid_from=item.valid_from, + valid_until=item.valid_until, + revoked_at=item.revoked_at, + is_active=item.is_active, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _record_many_access_changes( + session: Session, + *, + collection: str, + resource_type: str, + resource_ids: Iterable[str], + operation: str, + tenant_id: str | None, + principal: ApiPrincipal, + payload: dict | None = None, +) -> None: + for resource_id in sorted(set(resource_ids)): + _record_access_change( + session, + collection=collection, + resource_type=resource_type, + resource_id=resource_id, + operation=operation, + tenant_id=tenant_id, + principal=principal, + payload=payload, + ) + + +def _current_user_group_ids(session: Session, user: User) -> set[str]: + return { + row[0] + for row in session.query(UserGroupMembership.group_id) + .filter(UserGroupMembership.tenant_id == user.tenant_id, UserGroupMembership.user_id == user.id) + .all() + } + + +def _current_user_role_ids(session: Session, user: User) -> set[str]: + return { + row[0] + for row in session.query(UserRoleAssignment.role_id) + .filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id) + .all() + } + + +def _current_group_member_ids(session: Session, group: Group) -> set[str]: + return { + row[0] + for row in session.query(UserGroupMembership.user_id) + .filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) + .all() + } + + +def _current_group_role_ids(session: Session, group: Group) -> set[str]: + return { + row[0] + for row in session.query(GroupRoleAssignment.role_id) + .filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id) + .all() + } + + +def _tenant_user_ids_for_role(session: Session, role_id: str, tenant_id: str) -> set[str]: + direct = { + row[0] + for row in session.query(UserRoleAssignment.user_id) + .filter(UserRoleAssignment.tenant_id == tenant_id, UserRoleAssignment.role_id == role_id) + .all() + } + via_groups = { + row[0] + for row in session.query(UserGroupMembership.user_id) + .join(GroupRoleAssignment, GroupRoleAssignment.group_id == UserGroupMembership.group_id) + .filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.role_id == role_id) + .all() + } + return direct | via_groups + + +def _tenant_group_ids_for_role(session: Session, role_id: str, tenant_id: str) -> set[str]: + return { + row[0] + for row in session.query(GroupRoleAssignment.group_id) + .filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.role_id == role_id) + .all() + } + + +def _system_account_ids_for_role(session: Session, role_id: str) -> set[str]: + return { + row[0] + for row in session.query(SystemRoleAssignment.account_id) + .filter(SystemRoleAssignment.role_id == role_id) + .all() + } + + +def _current_system_role_ids(session: Session, account: Account) -> set[str]: + return { + row[0] + for row in session.query(SystemRoleAssignment.role_id) + .filter(SystemRoleAssignment.account_id == account.id) + .all() + } + @router.get("/permissions", response_model=PermissionCatalogResponse) def permission_catalog( @@ -106,6 +604,1040 @@ def permission_catalog( return PermissionCatalogResponse(permissions=items) +@router.get("/configuration-safety", response_model=ConfigurationSafetyCatalogResponse) +def configuration_safety_catalog_endpoint( + include_env_only: bool = False, + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + del principal + return ConfigurationSafetyCatalogResponse(fields=[ConfigurationSafetyFieldItem(**item.to_dict()) for item in configuration_safety_catalog(include_env_only=include_env_only)]) + + +@router.post("/configuration-safety/plan", response_model=ConfigurationChangeSafetyPlanResponse) +def configuration_change_safety_plan( + payload: ConfigurationChangeSafetyPlanRequest, + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + plan = plan_configuration_change( + payload.key, + actor_scopes=tuple(principal.scopes), + value=payload.value, + dry_run=payload.dry_run, + maintenance_mode=payload.maintenance_mode, + approval_count=payload.approval_count, + include_env_only=True, + ) + return ConfigurationChangeSafetyPlanResponse(plan=plan.to_dict()) + + +@router.get("/configuration-packages/catalog", response_model=ConfigurationPackageCatalogResponse) +def configuration_package_catalog_validation( + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "system:settings:read", "system:governance:read")), +): + del principal + return ConfigurationPackageCatalogResponse(validation=validate_configuration_package_catalog()) + + +@router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse) +def configuration_package_dry_run_endpoint( + payload: ConfigurationPackageRunRequest, + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) + return ConfigurationPackageDryRunResponse( + diagnostics=[item.to_dict() for item in result.diagnostics], + required_data=[item.to_dict() for item in result.required_data], + plan=[item.to_dict() for item in result.plan], + ) + + +@router.post("/configuration-packages/apply", response_model=ConfigurationPackageApplyResponse) +def configuration_package_apply_endpoint( + payload: ConfigurationPackageRunRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), +): + try: + approval = ensure_configuration_change_allowed( + session, + key="configuration_packages.apply", + value=payload.package, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"tenant_id": payload.tenant_id or principal.tenant_id}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) + record_configuration_change_applied( + session, + key="configuration_packages.apply", + before_value={}, + after_value=payload.package, + actor_user_id=principal.user.id, + approval=approval, + target={"tenant_id": payload.tenant_id or principal.tenant_id}, + audit_event="configuration_package.applied", + ) + audit_from_principal( + session, + principal, + action="configuration_package.applied", + scope="system", + object_type="configuration_package", + object_id=approval.request.get("id") if approval.request else "direct", + details={"tenant_id": payload.tenant_id or principal.tenant_id}, + ) + session.commit() + return ConfigurationPackageApplyResponse( + diagnostics=[item.to_dict() for item in result.diagnostics], + created_refs=dict(result.created_refs), + updated_refs=dict(result.updated_refs), + ) + + +@router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse) +def configuration_package_export_endpoint( + payload: ConfigurationPackageExportRequest, + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + selection = ConfigurationExportSelection( + tenant_id=payload.tenant_id, + scopes=tuple(payload.scopes), + module_ids=tuple(payload.module_ids), + object_refs=tuple(payload.object_refs), + ) + result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id)) + return ConfigurationPackageExportResponse( + fragments=[item.to_dict() for item in result.fragments], + data_requirements=[item.to_dict() for item in result.data_requirements], + diagnostics=[item.to_dict() for item in result.diagnostics], + ) + + +def _configuration_providers() -> tuple[object, ...]: + registry = get_registry() + if registry is not None and hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY): + capability = registry.capability(ACCESS_CONFIGURATION_CAPABILITY) + if capability is not None: + return (capability,) + return (SqlAccessConfigurationProvider(),) + + +def _configuration_context(principal: ApiPrincipal, *, tenant_id: str | None = None, supplied_data: dict[str, Any] | None = None) -> ConfigurationPreflightContext: + registry = get_registry() + installed_modules: dict[str, str] = {"access": "0.1.6"} + capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY} + if registry is not None and hasattr(registry, "manifests"): + manifests = registry.manifests() + installed_modules = {manifest.id: manifest.version for manifest in manifests} + if hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY): + capabilities.add(ACCESS_CONFIGURATION_CAPABILITY) + return ConfigurationPreflightContext( + tenant_id=tenant_id or principal.tenant_id, + operator_user_id=principal.user.id, + supplied_data=supplied_data or {}, + installed_modules=installed_modules, + capabilities=frozenset(capabilities), + ) + + +def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": exc.code, "message": str(exc), "plan": exc.plan}, + ) + + +def _configuration_delta_watermark(session: Session) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + module_id=CONFIGURATION_CONTROL_MODULE_ID, + collections=(CONFIGURATION_CHANGES_COLLECTION,), + ) + ) + + +def _configuration_delta_entries(session: Session, *, since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + module_id=CONFIGURATION_CONTROL_MODULE_ID, + collections=(CONFIGURATION_CHANGES_COLLECTION,), + ): + return None, False + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + module_id=CONFIGURATION_CONTROL_MODULE_ID, + collections=(CONFIGURATION_CHANGES_COLLECTION,), + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _configuration_delta_response_watermark(session: Session, *, entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _configuration_delta_watermark(session) + + +def _full_configuration_delta_response(session: Session) -> ConfigurationControlDeltaResponse: + snapshot = configuration_control_snapshot(session) + return ConfigurationControlDeltaResponse( + requests=snapshot["requests"], + history=snapshot["history"], + deleted=[], + watermark=_configuration_delta_watermark(session), + has_more=False, + full=True, + ) + + +@router.get("/configuration-changes", response_model=ConfigurationControlSnapshotResponse) +def list_configuration_changes( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + del principal + return ConfigurationControlSnapshotResponse(**configuration_control_snapshot(session)) + + +@router.get("/configuration-changes/delta", response_model=ConfigurationControlDeltaResponse) +def list_configuration_changes_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), +): + del principal + if since is None: + return _full_configuration_delta_response(session) + entries, has_more = _configuration_delta_entries(session, since=since, limit=limit) + if entries is None: + return _full_configuration_delta_response(session) + request_ids = { + entry.resource_id + for entry in entries + if entry.resource_type == CONFIGURATION_CHANGE_REQUEST_RESOURCE + } + record_ids = { + entry.resource_id + for entry in entries + if entry.resource_type == CONFIGURATION_CHANGE_RECORD_RESOURCE + } + snapshot = configuration_control_snapshot(session) + return ConfigurationControlDeltaResponse( + requests=[item for item in snapshot["requests"] if item.get("id") in request_ids], + history=[item for item in snapshot["history"] if item.get("id") in record_ids], + deleted=[], + watermark=_configuration_delta_response_watermark(session, entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.post("/configuration-change-requests", response_model=ConfigurationChangeRequestResponse, status_code=status.HTTP_201_CREATED) +def create_configuration_change_request_endpoint( + payload: ConfigurationChangeRequestCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), +): + try: + request = create_configuration_change_request( + session, + key=payload.key, + value=payload.value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + dry_run=payload.dry_run, + target=payload.target, + reason=payload.reason, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + audit_from_principal( + session, + principal, + action="configuration_change.requested", + scope="system", + object_type="configuration_change_request", + object_id=str(request["id"]), + details={"key": payload.key, "target": payload.target, "dry_run": payload.dry_run}, + ) + session.commit() + return ConfigurationChangeRequestResponse(request=request) + + +@router.post("/configuration-change-requests/{request_id}/approve", response_model=ConfigurationChangeRequestResponse) +def approve_configuration_change_request_endpoint( + request_id: str, + payload: ConfigurationChangeRequestApproveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), +): + try: + request = approve_configuration_change_request( + session, + request_id=request_id, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + reason=payload.reason, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + audit_from_principal( + session, + principal, + action="configuration_change.approved", + scope="system", + object_type="configuration_change_request", + object_id=request_id, + details={"key": request.get("key")}, + ) + session.commit() + return ConfigurationChangeRequestResponse(request=request) + + +@router.get("/identities", response_model=IdentityListResponse) +def list_identities( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")), +): + del principal + identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all() + return IdentityListResponse(identities=[_identity_item(session, item) for item in identities]) + + +@router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED) +def create_identity( + payload: IdentityCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), +): + identity = Identity( + display_name=payload.display_name.strip() if payload.display_name else None, + external_subject=payload.external_subject.strip() if payload.external_subject else None, + source=payload.source.strip() or "local", + is_active=payload.is_active, + ) + session.add(identity) + session.flush() + try: + _set_identity_accounts( + session, + identity=identity, + account_ids=payload.account_ids, + primary_account_id=payload.primary_account_id, + source=identity.source, + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_IDENTITIES_COLLECTION, + resource_type="access_identity", + resource_id=identity.id, + operation="created", + tenant_id=None, + principal=principal, + ) + session.commit() + return _identity_item(session, identity) + + +@router.patch("/identities/{identity_id}", response_model=IdentityAdminItem) +def update_identity( + identity_id: str, + payload: IdentityUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), +): + identity = session.get(Identity, identity_id) + if identity is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") + if payload.display_name is not None: + identity.display_name = payload.display_name.strip() or None + if payload.external_subject is not None: + identity.external_subject = payload.external_subject.strip() or None + if payload.source is not None: + identity.source = payload.source.strip() or "local" + if payload.is_active is not None: + identity.is_active = payload.is_active + session.add(identity) + session.flush() + if payload.account_ids is not None or payload.primary_account_id is not None: + existing = [ + row[0] + for row in session.query(IdentityAccountLink.account_id) + .filter(IdentityAccountLink.identity_id == identity.id) + .all() + ] + try: + _set_identity_accounts( + session, + identity=identity, + account_ids=payload.account_ids if payload.account_ids is not None else existing, + primary_account_id=payload.primary_account_id, + source=identity.source, + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_IDENTITIES_COLLECTION, + resource_type="access_identity", + resource_id=identity.id, + operation="updated", + tenant_id=None, + principal=principal, + ) + session.commit() + return _identity_item(session, identity) + + +@router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT) +def deactivate_identity( + identity_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), +): + identity = session.get(Identity, identity_id) + if identity is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") + identity.is_active = False + session.add(identity) + _record_access_change( + session, + collection=ACCESS_IDENTITIES_COLLECTION, + resource_type="access_identity", + resource_id=identity.id, + operation="deleted", + tenant_id=None, + principal=principal, + ) + session.commit() + return None + + +@router.get("/organization-units", response_model=OrganizationUnitListResponse) +def list_organization_units( + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + items = ( + session.query(OrganizationUnit) + .filter(OrganizationUnit.tenant_id == tenant.id) + .order_by(OrganizationUnit.name.asc()) + .all() + ) + return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items]) + + +@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) +def create_organization_unit( + payload: OrganizationUnitCreateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + slug = slugify(payload.slug) + if not slug: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") + if session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant.id, OrganizationUnit.slug == slug).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An organization unit with this slug already exists.") + try: + _validate_parent_organization_unit(session, tenant_id=tenant.id, parent_id=payload.parent_id) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + item = OrganizationUnit( + tenant_id=tenant.id, + parent_id=payload.parent_id, + slug=slug, + name=payload.name.strip(), + description=payload.description.strip() if payload.description else None, + is_active=payload.is_active, + ) + session.add(item) + session.flush() + _record_access_change( + session, + collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, + resource_type="access_organization_unit", + resource_id=item.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _organization_unit_item(item) + + +@router.patch("/organization-units/{organization_unit_id}", response_model=OrganizationUnitItem) +def update_organization_unit( + organization_unit_id: str, + payload: OrganizationUnitUpdateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + item = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization unit not found") + if payload.slug is not None: + slug = slugify(payload.slug) + if not slug: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") + if session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant.id, OrganizationUnit.slug == slug, OrganizationUnit.id != item.id).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An organization unit with this slug already exists.") + item.slug = slug + if payload.parent_id is not None: + try: + _validate_parent_organization_unit(session, tenant_id=tenant.id, parent_id=payload.parent_id, current_id=item.id) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + item.parent_id = payload.parent_id + if payload.name is not None: + item.name = payload.name.strip() + if payload.description is not None: + item.description = payload.description.strip() or None + if payload.is_active is not None: + item.is_active = payload.is_active + session.add(item) + _record_access_change( + session, + collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, + resource_type="access_organization_unit", + resource_id=item.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _organization_unit_item(item) + + +@router.delete("/organization-units/{organization_unit_id}", status_code=status.HTTP_204_NO_CONTENT) +def deactivate_organization_unit( + organization_unit_id: str, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + item = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization unit not found") + item.is_active = False + session.add(item) + _record_access_change( + session, + collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, + resource_type="access_organization_unit", + resource_id=item.id, + operation="deleted", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return None + + +@router.get("/functions", response_model=FunctionListResponse) +def list_functions( + tenant_id: str | None = None, + organization_unit_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + query = session.query(Function).filter(Function.tenant_id == tenant.id) + if organization_unit_id: + query = query.filter(Function.organization_unit_id == organization_unit_id) + functions = query.order_by(Function.name.asc()).all() + return FunctionListResponse(functions=[_function_item(session, item) for item in functions]) + + +@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED) +def create_function( + payload: FunctionCreateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if unit is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") + slug = slugify(payload.slug) + if not slug: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") + if session.query(Function).filter(Function.tenant_id == tenant.id, Function.organization_unit_id == unit.id, Function.slug == slug).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A function with this slug already exists in the organization unit.") + function = Function( + tenant_id=tenant.id, + organization_unit_id=unit.id, + slug=slug, + name=payload.name.strip(), + description=payload.description.strip() if payload.description else None, + delegable=payload.delegable, + act_in_place_allowed=payload.act_in_place_allowed, + is_active=payload.is_active, + ) + session.add(function) + session.flush() + try: + _set_function_roles(session, function=function, role_ids=payload.role_ids) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_FUNCTIONS_COLLECTION, + resource_type="access_function", + resource_id=function.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_item(session, function) + + +@router.patch("/functions/{function_id}", response_model=FunctionAdminItem) +def update_function( + function_id: str, + payload: FunctionUpdateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + function = session.query(Function).filter(Function.id == function_id, Function.tenant_id == tenant.id).one_or_none() + if function is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") + if payload.organization_unit_id is not None: + unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if unit is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") + function.organization_unit_id = unit.id + if payload.slug is not None: + slug = slugify(payload.slug) + if not slug: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") + if session.query(Function).filter(Function.tenant_id == tenant.id, Function.organization_unit_id == function.organization_unit_id, Function.slug == slug, Function.id != function.id).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A function with this slug already exists in the organization unit.") + function.slug = slug + if payload.name is not None: + function.name = payload.name.strip() + if payload.description is not None: + function.description = payload.description.strip() or None + if payload.delegable is not None: + function.delegable = payload.delegable + if payload.act_in_place_allowed is not None: + function.act_in_place_allowed = payload.act_in_place_allowed + if payload.is_active is not None: + function.is_active = payload.is_active + session.add(function) + session.flush() + if payload.role_ids is not None: + try: + _set_function_roles(session, function=function, role_ids=payload.role_ids) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_FUNCTIONS_COLLECTION, + resource_type="access_function", + resource_id=function.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_item(session, function) + + +@router.delete("/functions/{function_id}", status_code=status.HTTP_204_NO_CONTENT) +def deactivate_function( + function_id: str, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + function = session.query(Function).filter(Function.id == function_id, Function.tenant_id == tenant.id).one_or_none() + if function is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") + function.is_active = False + session.add(function) + _record_access_change( + session, + collection=ACCESS_FUNCTIONS_COLLECTION, + resource_type="access_function", + resource_id=function.id, + operation="deleted", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return None + + +@router.get("/function-assignments", response_model=FunctionAssignmentListResponse) +def list_function_assignments( + tenant_id: str | None = None, + account_id: str | None = None, + function_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + query = session.query(FunctionAssignment).filter(FunctionAssignment.tenant_id == tenant.id) + if account_id: + query = query.filter(FunctionAssignment.account_id == account_id) + if function_id: + query = query.filter(FunctionAssignment.function_id == function_id) + assignments = query.order_by(FunctionAssignment.created_at.desc()).all() + return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments]) + + +@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED) +def create_function_assignment( + payload: FunctionAssignmentCreateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + account = session.get(Account, payload.account_id) + if account is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Account not found") + function = session.query(Function).filter(Function.id == payload.function_id, Function.tenant_id == tenant.id).one_or_none() + if function is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function not found") + organization_unit_id = payload.organization_unit_id or function.organization_unit_id + unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if unit is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") + identity_id = payload.identity_id or identity_id_for_account(session, account.id) + if identity_id is not None and session.get(Identity, identity_id) is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Identity not found") + try: + _validate_assignment_window(payload.valid_from, payload.valid_until) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + assignment = FunctionAssignment( + tenant_id=tenant.id, + account_id=account.id, + identity_id=identity_id, + function_id=function.id, + organization_unit_id=unit.id, + applies_to_subunits=payload.applies_to_subunits, + source=payload.source.strip() or "direct", + delegated_from_assignment_id=payload.delegated_from_assignment_id, + acting_for_account_id=payload.acting_for_account_id, + valid_from=payload.valid_from, + valid_until=payload.valid_until, + is_active=payload.is_active, + ) + session.add(assignment) + try: + session.flush() + except IntegrityError as exc: + raise _http_admin_error(AdminConflictError("This function assignment already exists for the account and scope.")) from exc + _record_access_change( + session, + collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, + resource_type="access_function_assignment", + resource_id=assignment.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_assignment_item(assignment) + + +@router.patch("/function-assignments/{assignment_id}", response_model=FunctionAssignmentAdminItem) +def update_function_assignment( + assignment_id: str, + payload: FunctionAssignmentUpdateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() + if assignment is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function assignment not found") + if payload.identity_id is not None: + if session.get(Identity, payload.identity_id) is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Identity not found") + assignment.identity_id = payload.identity_id + if payload.organization_unit_id is not None: + unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() + if unit is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") + assignment.organization_unit_id = unit.id + if payload.applies_to_subunits is not None: + assignment.applies_to_subunits = payload.applies_to_subunits + if payload.source is not None: + assignment.source = payload.source.strip() or "direct" + if payload.delegated_from_assignment_id is not None: + assignment.delegated_from_assignment_id = payload.delegated_from_assignment_id + if payload.acting_for_account_id is not None: + assignment.acting_for_account_id = payload.acting_for_account_id + if payload.valid_from is not None: + assignment.valid_from = payload.valid_from + if payload.valid_until is not None: + assignment.valid_until = payload.valid_until + if payload.is_active is not None: + assignment.is_active = payload.is_active + try: + _validate_assignment_window(assignment.valid_from, assignment.valid_until) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + session.add(assignment) + _record_access_change( + session, + collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, + resource_type="access_function_assignment", + resource_id=assignment.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_assignment_item(assignment) + + +@router.delete("/function-assignments/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT) +def deactivate_function_assignment( + assignment_id: str, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() + if assignment is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function assignment not found") + assignment.is_active = False + session.add(assignment) + _record_access_change( + session, + collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, + resource_type="access_function_assignment", + resource_id=assignment.id, + operation="deleted", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return None + + +@router.get("/function-delegations", response_model=FunctionDelegationListResponse) +def list_function_delegations( + tenant_id: str | None = None, + account_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id) + if account_id: + query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id)) + delegations = query.order_by(FunctionDelegation.created_at.desc()).all() + return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations]) + + +@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED) +def create_function_delegation( + payload: FunctionDelegationCreateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == payload.function_assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() + if assignment is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function assignment not found") + function = session.query(Function).filter(Function.id == assignment.function_id, Function.tenant_id == tenant.id).one_or_none() + if function is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function not found") + if payload.mode == "delegate" and not function.delegable: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This function cannot be delegated.") + if payload.mode == "act_in_place" and not function.act_in_place_allowed: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This function does not permit acting in place.") + delegate = session.get(Account, payload.delegate_account_id) + if delegate is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Delegate account not found") + if delegate.id == assignment.account_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Delegate account must differ from delegator account.") + try: + _validate_assignment_window(payload.valid_from, payload.valid_until) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + delegation = FunctionDelegation( + tenant_id=tenant.id, + function_assignment_id=assignment.id, + delegator_account_id=assignment.account_id, + delegate_account_id=delegate.id, + mode=payload.mode, + reason=payload.reason.strip() if payload.reason else None, + valid_from=payload.valid_from, + valid_until=payload.valid_until, + is_active=payload.is_active, + ) + session.add(delegation) + try: + session.flush() + except IntegrityError as exc: + raise _http_admin_error(AdminConflictError("This function delegation already exists.")) from exc + _record_access_change( + session, + collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, + resource_type="access_function_delegation", + resource_id=delegation.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_delegation_item(delegation) + + +@router.patch("/function-delegations/{delegation_id}", response_model=FunctionDelegationAdminItem) +def update_function_delegation( + delegation_id: str, + payload: FunctionDelegationUpdateRequest, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + delegation = session.query(FunctionDelegation).filter(FunctionDelegation.id == delegation_id, FunctionDelegation.tenant_id == tenant.id).one_or_none() + if delegation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function delegation not found") + if payload.reason is not None: + delegation.reason = payload.reason.strip() or None + if payload.valid_from is not None: + delegation.valid_from = payload.valid_from + if payload.valid_until is not None: + delegation.valid_until = payload.valid_until + if payload.is_active is not None: + delegation.is_active = payload.is_active + if payload.revoked is True: + delegation.revoked_at = utc_now() + delegation.is_active = False + try: + _validate_assignment_window(delegation.valid_from, delegation.valid_until) + except AdminValidationError as exc: + raise _http_admin_error(exc) from exc + session.add(delegation) + _record_access_change( + session, + collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, + resource_type="access_function_delegation", + resource_id=delegation.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return _function_delegation_item(delegation) + + +@router.delete("/function-delegations/{delegation_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_function_delegation( + delegation_id: str, + tenant_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + delegation = session.query(FunctionDelegation).filter(FunctionDelegation.id == delegation_id, FunctionDelegation.tenant_id == tenant.id).one_or_none() + if delegation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function delegation not found") + delegation.revoked_at = utc_now() + delegation.is_active = False + session.add(delegation) + _record_access_change( + session, + collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, + resource_type="access_function_delegation", + resource_id=delegation.id, + operation="deleted", + tenant_id=tenant.id, + principal=principal, + ) + session.commit() + return None + + +def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse: + users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() + owner_ids = tenant_owner_user_ids(session, tenant.id) + return UserListDeltaResponse( + users=[_user_item(session, user, owner_ids=owner_ids) for user in users], + deleted=[], + watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), + has_more=False, + full=True, + ) + + +def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_users_delta_response(session, tenant) + changed_ids = _changed_ids(entries, "access_user") + visible = { + user.id: user + for user in ( + session.query(User).filter(User.tenant_id == tenant.id, User.id.in_(changed_ids)).all() + if changed_ids else [] + ) + } + owner_ids = tenant_owner_user_ids(session, tenant.id) + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if entry.resource_type == "access_user" and entry.resource_id not in visible + ] + return UserListDeltaResponse( + users=[_user_item(session, user, owner_ids=owner_ids) for user in visible.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/users/delta", response_model=UserListDeltaResponse) +def list_users_delta( + tenant_id: str | None = Query(default=None), + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:users:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + if since is None: + return _full_users_delta_response(session, tenant) + return _users_delta_response(session, tenant, since=since, limit=limit) + + @router.get("/users", response_model=UserListResponse) def list_users( tenant_id: str | None = Query(default=None), @@ -147,6 +1679,43 @@ def create_user( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_id=result.user.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + if result.account_created: + _record_access_change( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_id=result.account.id, + operation="created", + tenant_id=None, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_ids=payload.group_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=payload.role_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -189,6 +1758,8 @@ def update_user( user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set() + previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set() try: if payload.display_name is not None: user.display_name = payload.display_name.strip() or None @@ -203,6 +1774,35 @@ def update_user( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_id=user.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + if payload.group_ids is not None: + _record_many_access_changes( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_ids=previous_group_ids | set(payload.group_ids), + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + if payload.role_ids is not None: + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=previous_role_ids | set(payload.role_ids), + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -216,6 +1816,57 @@ def update_user( return _user_item(session, user) +def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: + groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() + return GroupListDeltaResponse( + groups=[_group_summary(session, group) for group in groups], + deleted=[], + watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)), + has_more=False, + full=True, + ) + + +def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_groups_delta_response(session, tenant) + changed_ids = _changed_ids(entries, "access_group") + visible = { + group.id: group + for group in ( + session.query(Group).filter(Group.tenant_id == tenant.id, Group.id.in_(changed_ids)).all() + if changed_ids else [] + ) + } + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if entry.resource_type == "access_group" and entry.resource_id not in visible + ] + return GroupListDeltaResponse( + groups=[_group_summary(session, group) for group in visible.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/groups/delta", response_model=GroupListDeltaResponse) +def list_groups_delta( + tenant_id: str | None = Query(default=None), + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + if since is None: + return _full_groups_delta_response(session, tenant) + return _groups_delta_response(session, tenant, since=since, limit=limit) + + @router.get("/groups", response_model=GroupListResponse) def list_groups( tenant_id: str | None = Query(default=None), @@ -263,6 +1914,33 @@ def create_group( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_id=group.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_ids=payload.member_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=payload.role_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -294,6 +1972,8 @@ def update_group( group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none() if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set() + previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set() try: ensure_group_mutation_allowed(group, requested_active=payload.is_active) if group.system_template_id is None and payload.name is not None: @@ -311,6 +1991,35 @@ def update_group( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_id=group.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + if payload.member_ids is not None: + _record_many_access_changes( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_ids=previous_member_ids | set(payload.member_ids), + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + if payload.role_ids is not None: + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=previous_role_ids | set(payload.role_ids), + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -324,6 +2033,59 @@ def update_group( return _group_summary(session, group) +def _full_roles_delta_response(session: Session, tenant: Tenant) -> RoleListDeltaResponse: + ensure_default_roles(session, tenant) + session.commit() + roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() + return RoleListDeltaResponse( + roles=[_role_summary(session, role) for role in roles], + deleted=[], + watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)), + has_more=False, + full=True, + ) + + +def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_roles_delta_response(session, tenant) + changed_ids = _changed_ids(entries, "access_role") + visible = { + role.id: role + for role in ( + session.query(Role).filter(Role.tenant_id == tenant.id, Role.id.in_(changed_ids)).all() + if changed_ids else [] + ) + } + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if entry.resource_type == "access_role" and entry.resource_id not in visible + ] + return RoleListDeltaResponse( + roles=[_role_summary(session, role) for role in visible.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/roles/delta", response_model=RoleListDeltaResponse) +def list_roles_delta( + tenant_id: str | None = Query(default=None), + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + if since is None: + return _full_roles_delta_response(session, tenant) + return _roles_delta_response(session, tenant, since=since, limit=limit) + + @router.get("/roles", response_model=RoleListResponse) def list_roles( tenant_id: str | None = Query(default=None), @@ -358,6 +2120,15 @@ def create_role( ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_id=role.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -383,6 +2154,8 @@ def update_role( role = session.query(Role).filter(Role.id == role_id, Role.tenant_id == tenant.id).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + affected_user_ids = _tenant_user_ids_for_role(session, role.id, tenant.id) + affected_group_ids = _tenant_group_ids_for_role(session, role.id, tenant.id) try: ensure_role_mutation_allowed(role, requested_assignable=payload.is_assignable) if payload.permissions is not None: @@ -398,6 +2171,33 @@ def update_role( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_id=role.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_ids=affected_user_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_ids=affected_group_ids, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) audit_event( session, tenant_id=tenant.id, @@ -430,6 +2230,16 @@ def delete_role( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_id=role_id, + operation="deleted", + tenant_id=tenant.id, + principal=principal, + payload={"slug": role_slug}, + ) audit_event( session, tenant_id=tenant.id, @@ -443,6 +2253,58 @@ def delete_role( return None +def _full_system_roles_delta_response(session: Session) -> RoleListDeltaResponse: + ensure_default_roles(session, None) + session.commit() + roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() + return RoleListDeltaResponse( + roles=[_role_summary(session, role) for role in roles], + deleted=[], + watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)), + has_more=False, + full=True, + ) + + +def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_system_roles_delta_response(session) + changed_ids = _changed_ids(entries, "access_system_role") + visible = { + role.id: role + for role in ( + session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(changed_ids)).all() + if changed_ids else [] + ) + } + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if entry.resource_type == "access_system_role" and entry.resource_id not in visible + ] + return RoleListDeltaResponse( + roles=[_role_summary(session, role) for role in visible.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/system/roles/delta", response_model=RoleListDeltaResponse) +def list_system_roles_delta( + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), +): + del principal + if since is None: + return _full_system_roles_delta_response(session) + return _system_roles_delta_response(session, since=since, limit=limit) + + @router.get("/system/roles", response_model=RoleListResponse) def list_system_roles( session: Session = Depends(get_session), @@ -471,6 +2333,15 @@ def create_system_role_endpoint( ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_id=role.id, + operation="created", + tenant_id=None, + principal=principal, + ) audit_from_principal( session, principal, action="system_role.created", scope="system", object_type="role", object_id=role.id, details={"slug": role.slug, "permissions": role.permissions}, @@ -489,6 +2360,7 @@ def update_system_role_endpoint( role = session.query(Role).filter(Role.id == role_id, Role.tenant_id.is_(None)).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System role not found") + affected_account_ids = _system_account_ids_for_role(session, role.id) try: assert_can_delegate_system_permissions(principal.scopes, payload.permissions) update_system_role( @@ -502,6 +2374,24 @@ def update_system_role_endpoint( assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_id=role.id, + operation="updated", + tenant_id=None, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_ids=affected_account_ids, + operation="updated", + tenant_id=None, + principal=principal, + ) audit_from_principal( session, principal, action="system_role.updated", scope="system", object_type="role", object_id=role.id, details=payload.model_dump(), @@ -525,6 +2415,16 @@ def delete_system_role_endpoint( assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_id=role_id, + operation="deleted", + tenant_id=None, + principal=principal, + payload={"slug": role_slug}, + ) audit_from_principal( session, principal, action="system_role.deleted", scope="system", object_type="role", object_id=role_id, details={"slug": role_slug}, @@ -533,6 +2433,75 @@ def delete_system_role_endpoint( return None +_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION) + + +def _full_system_accounts_delta_response(session: Session) -> SystemAccountListDeltaResponse: + ensure_default_roles(session, None) + session.commit() + system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() + accounts = session.query(Account).order_by(Account.email.asc()).all() + return SystemAccountListDeltaResponse( + accounts=[_system_account_item(session, account) for account in accounts], + roles=[_role_summary(session, role) for role in system_roles], + deleted=[], + watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS), + has_more=False, + full=True, + ) + + +def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit) + if entries is None: + return _full_system_accounts_delta_response(session) + account_ids = _changed_ids(entries, "access_system_account") + role_ids = _changed_ids(entries, "access_system_role") + visible_accounts = { + account.id: account + for account in ( + session.query(Account).filter(Account.id.in_(account_ids)).all() + if account_ids else [] + ) + } + visible_roles = { + role.id: role + for role in ( + session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(role_ids)).all() + if role_ids else [] + ) + } + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if ( + (entry.resource_type == "access_system_account" and entry.resource_id not in visible_accounts) + or (entry.resource_type == "access_system_role" and entry.resource_id not in visible_roles) + ) + ] + return SystemAccountListDeltaResponse( + accounts=[_system_account_item(session, account) for account in visible_accounts.values()], + roles=[_role_summary(session, role) for role in visible_roles.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/system/accounts/delta", response_model=SystemAccountListDeltaResponse) +def list_system_accounts_delta( + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), +): + del principal + if since is None: + return _full_system_accounts_delta_response(session) + return _system_accounts_delta_response(session, since=since, limit=limit) + + @router.get("/system/accounts", response_model=SystemAccountListResponse) def list_system_accounts( session: Session = Depends(get_session), @@ -564,6 +2533,7 @@ def update_system_account( account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set() if "display_name" in payload.model_fields_set: account.display_name = payload.display_name.strip() if payload.display_name else None if payload.is_active is not None: @@ -590,6 +2560,25 @@ def update_system_account( assert_tenant_owner_exists(session, tenant_id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_id=account.id, + operation="updated", + tenant_id=None, + principal=principal, + ) + if payload.role_ids is not None: + _record_many_access_changes( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_ids=previous_role_ids | set(payload.role_ids), + operation="updated", + tenant_id=None, + principal=principal, + ) audit_from_principal( session, principal, @@ -613,6 +2602,7 @@ def update_system_account_roles( account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + previous_role_ids = _current_system_role_ids(session, account) try: _require_system_role_assignment(principal) assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids) @@ -620,6 +2610,24 @@ def update_system_account_roles( assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_id=account.id, + operation="updated", + tenant_id=None, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_ids=previous_role_ids | set(payload.role_ids), + operation="updated", + tenant_id=None, + principal=principal, + ) audit_from_principal( session, principal, @@ -660,6 +2668,54 @@ def create_system_account( assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_id=account.id, + operation="created", + tenant_id=None, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_SYSTEM_ROLES_COLLECTION, + resource_type="access_system_role", + resource_ids=payload.role_ids, + operation="updated", + tenant_id=None, + principal=principal, + ) + for membership in payload.memberships: + user = session.query(User).filter(User.account_id == account.id, User.tenant_id == membership.tenant_id).one_or_none() + if user: + _record_access_change( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_id=user.id, + operation="created", + tenant_id=membership.tenant_id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_ids=membership.group_ids, + operation="updated", + tenant_id=membership.tenant_id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=membership.role_ids, + operation="updated", + tenant_id=membership.tenant_id, + principal=principal, + ) audit_from_principal( session, principal, action="system_account.created", scope="system", object_type="account", object_id=account.id, details={"email": account.email, "tenant_ids": [item.tenant_id for item in payload.memberships], "role_ids": payload.role_ids}, @@ -678,6 +2734,8 @@ def update_system_account_memberships( account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + previous_users = {user.tenant_id: user for user in session.query(User).filter(User.account_id == account.id).all()} + requested_tenant_ids = {item.tenant_id for item in payload.memberships} try: _require_permission(principal, "system:access:assign") _set_system_memberships(session, account, [item.model_dump() for item in payload.memberships]) @@ -685,6 +2743,56 @@ def update_system_account_memberships( assert_tenant_owner_exists(session, tenant_id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + _record_access_change( + session, + collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, + resource_type="access_system_account", + resource_id=account.id, + operation="updated", + tenant_id=None, + principal=principal, + ) + for membership in payload.memberships: + user = session.query(User).filter(User.account_id == account.id, User.tenant_id == membership.tenant_id).one_or_none() + if user: + _record_access_change( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_id=user.id, + operation="created" if membership.tenant_id not in previous_users else "updated", + tenant_id=membership.tenant_id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_GROUPS_COLLECTION, + resource_type="access_group", + resource_ids=membership.group_ids, + operation="updated", + tenant_id=membership.tenant_id, + principal=principal, + ) + _record_many_access_changes( + session, + collection=ACCESS_ROLES_COLLECTION, + resource_type="access_role", + resource_ids=membership.role_ids, + operation="updated", + tenant_id=membership.tenant_id, + principal=principal, + ) + for tenant_id, user in previous_users.items(): + if tenant_id not in requested_tenant_ids: + _record_access_change( + session, + collection=ACCESS_USERS_COLLECTION, + resource_type="access_user", + resource_id=user.id, + operation="updated", + tenant_id=tenant_id, + principal=principal, + ) audit_from_principal( session, principal, action="system_memberships.updated", scope="system", object_type="account", object_id=account.id, details={"tenant_ids": [item.tenant_id for item in payload.memberships]}, @@ -693,6 +2801,64 @@ def update_system_account_memberships( return _system_account_item(session, account) +def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool) -> ApiKeyListDeltaResponse: + query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) + if not include_revoked: + query = query.filter(ApiKey.revoked_at.is_(None)) + keys = query.order_by(ApiKey.created_at.desc()).all() + return ApiKeyListDeltaResponse( + api_keys=[_api_key_item(session, item) for item in keys], + deleted=[], + watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)), + has_more=False, + full=True, + ) + + +def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse: + entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) + changed_ids = _changed_ids(entries, "access_api_key") + query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) + if not include_revoked: + query = query.filter(ApiKey.revoked_at.is_(None)) + visible = { + item.id: item + for item in ( + query.filter(ApiKey.id.in_(changed_ids)).all() + if changed_ids else [] + ) + } + deleted = [ + _delta_deleted_item(entry) + for entry in entries + if entry.resource_type == "access_api_key" and entry.resource_id not in visible + ] + return ApiKeyListDeltaResponse( + api_keys=[_api_key_item(session, item) for item in visible.values()], + deleted=deleted, + watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + +@router.get("/api-keys/delta", response_model=ApiKeyListDeltaResponse) +def list_api_keys_delta( + tenant_id: str | None = Query(default=None), + include_revoked: bool = Query(default=False), + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), +): + tenant = _resolve_tenant(session, principal, tenant_id) + if since is None: + return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) + return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit) + + @router.get("/api-keys", response_model=ApiKeyListResponse) def list_api_keys( tenant_id: str | None = Query(default=None), @@ -729,7 +2895,7 @@ def create_tenant_api_key( invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)] if invalid: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"API key scopes exceed the user's tenant permissions: {', '.join(invalid)}", ) created = create_api_key( @@ -748,6 +2914,15 @@ def create_tenant_api_key( object_id=created.model.id, details={"name": created.model.name, "prefix": created.model.prefix, "scopes": created.model.scopes, "owner_user_id": user.id}, ) + _record_access_change( + session, + collection=ACCESS_API_KEYS_COLLECTION, + resource_type="access_api_key", + resource_id=created.model.id, + operation="created", + tenant_id=tenant.id, + principal=principal, + ) session.commit() return AdminApiKeyCreateResponse(**_api_key_item(session, created.model).model_dump(), secret=created.secret) @@ -775,5 +2950,14 @@ def revoke_api_key( object_id=item.id, details={"name": item.name, "prefix": item.prefix}, ) + _record_access_change( + session, + collection=ACCESS_API_KEYS_COLLECTION, + resource_type="access_api_key", + resource_id=item.id, + operation="updated", + tenant_id=tenant.id, + principal=principal, + ) session.commit() return _api_key_item(session, item) diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index c12fe7a..81e0bc1 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -17,8 +17,15 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.db.session import get_database, get_session from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User +from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.security.api_keys import authenticate_api_key -from govoplan_access.backend.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf +from govoplan_access.backend.security.sessions import ( + authenticate_session_token, + collect_user_groups, + collect_user_roles, + collect_user_scopes, + verify_auth_session_csrf, +) from govoplan_tenancy.backend.db.models import Tenant from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.permissions import intersect_api_key_scopes @@ -63,6 +70,26 @@ class ApiPrincipal: def group_ids(self) -> frozenset[str]: return self.principal.group_ids + @property + def role_ids(self) -> frozenset[str]: + return self.principal.role_ids + + @property + def function_assignment_ids(self) -> frozenset[str]: + return self.principal.function_assignment_ids + + @property + def delegation_ids(self) -> frozenset[str]: + return self.principal.delegation_ids + + @property + def identity_id(self) -> str | None: + return self.principal.identity_id + + @property + def acting_for_account_id(self) -> str | None: + return self.principal.acting_for_account_id + @property def auth_method(self) -> str: return self.principal.auth_method @@ -128,8 +155,12 @@ def _build_principal_ref( account_id=account.id, membership_id=user.id, tenant_id=tenant_id, + identity_id=identity_id_for_account(session, account.id), scopes=frozenset(scopes), group_ids=_principal_group_ids(session, user), + role_ids=frozenset(role.id for role in collect_user_roles(session, user)), + function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), + delegation_ids=frozenset(collect_function_delegation_ids(session, user)), auth_method=auth_method, # type: ignore[arg-type] api_key_id=api_key.id if api_key else None, session_id=auth_session.id if auth_session else None, diff --git a/src/govoplan_access/backend/configuration_provider.py b/src/govoplan_access/backend/configuration_provider.py new file mode 100644 index 0000000..93bdcf8 --- /dev/null +++ b/src/govoplan_access/backend/configuration_provider.py @@ -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, + ) diff --git a/src/govoplan_access/backend/db/models.py b/src/govoplan_access/backend/db/models.py index f86eb63..5fe53a6 100644 --- a/src/govoplan_access/backend/db/models.py +++ b/src/govoplan_access/backend/db/models.py @@ -18,7 +18,7 @@ def new_uuid() -> str: class Account(AccessBase, TimestampMixin): """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) email: Mapped[str] = mapped_column(String(320), nullable=False) @@ -31,22 +31,70 @@ class Account(AccessBase, TimestampMixin): last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) memberships: Mapped[list[User]] = relationship(back_populates="account") + identity_links: Mapped[list[IdentityAccountLink]] = relationship( + back_populates="account", cascade="all, delete-orphan" + ) auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan") system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship( back_populates="account", cascade="all, delete-orphan" ) +class 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): - __tablename__ = "users" + __tablename__ = "access_users" __table_args__ = ( UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"), UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) - account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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) display_name: Mapped[str | None] = mapped_column(String(255)) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) @@ -64,11 +112,11 @@ class User(AccessBase, TimestampMixin): class Group(AccessBase, TimestampMixin): - __tablename__ = "groups" + __tablename__ = "access_groups" __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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) @@ -80,7 +128,7 @@ class Group(AccessBase, TimestampMixin): class Role(AccessBase, TimestampMixin): - __tablename__ = "roles" + __tablename__ = "access_roles" __table_args__ = ( UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"), Index( @@ -93,7 +141,7 @@ class Role(AccessBase, TimestampMixin): ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -104,54 +152,148 @@ class Role(AccessBase, TimestampMixin): 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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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(ForeignKey("tenancy_tenants.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) + 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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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 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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + function_assignment_id: Mapped[str] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="CASCADE"), nullable=False, index=True) + delegator_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + delegate_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + mode: Mapped[str] = mapped_column(String(30), default="delegate", nullable=False) + reason: Mapped[str | None] = mapped_column(Text) + valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + class SystemRoleAssignment(AccessBase, TimestampMixin): - __tablename__ = "system_role_assignments" + __tablename__ = "access_system_role_assignments" __table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) - role_id: Mapped[str] = mapped_column(ForeignKey("roles.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("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) account: Mapped[Account] = relationship(back_populates="system_role_assignments") role: Mapped[Role] = relationship() class UserGroupMembership(AccessBase, TimestampMixin): - __tablename__ = "user_group_memberships" + __tablename__ = "access_user_group_memberships" __table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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("access_groups.id", ondelete="CASCADE"), nullable=False, index=True) 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"),) 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) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) 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"),) 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) - group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) - role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) class ApiKey(AccessBase, TimestampMixin): - __tablename__ = "api_keys" + __tablename__ = "access_api_keys" 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) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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) prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True) key_hash: Mapped[str] = mapped_column(String(128), nullable=False) @@ -164,12 +306,12 @@ class ApiKey(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) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.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("access_accounts.id", ondelete="CASCADE"), nullable=False, 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) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) @@ -186,8 +328,15 @@ __all__ = [ "Account", "ApiKey", "AuthSession", + "Function", + "FunctionAssignment", + "FunctionDelegation", + "FunctionRoleAssignment", "Group", "GroupRoleAssignment", + "Identity", + "IdentityAccountLink", + "OrganizationUnit", "Role", "SystemRoleAssignment", "Tenant", diff --git a/src/govoplan_access/backend/directory.py b/src/govoplan_access/backend/directory.py index 7be6fd4..0979cf5 100644 --- a/src/govoplan_access/backend/directory.py +++ b/src/govoplan_access/backend/directory.py @@ -2,9 +2,30 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef -from govoplan_access.backend.db.models import Account, Group, User +from govoplan_core.core.access import ( + AccessSemanticDirectory, + AccessSubjectRef, + AccountRef, + FunctionAssignmentRef, + FunctionRef, + GroupRef, + IdentityRef, + OrganizationUnitRef, + UserRef, +) +from govoplan_access.backend.db.models import ( + Account, + Function, + FunctionAssignment, + FunctionRoleAssignment, + Group, + Identity, + IdentityAccountLink, + OrganizationUnit, + User, +) from govoplan_core.db.session import get_database +from govoplan_access.backend.semantic import active_function_assignments_for_account def _status(active: bool) -> str: @@ -40,7 +61,60 @@ 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 _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 _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 _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] + ) + + +class SqlAccessDirectory(AccessSemanticDirectory): def get_account(self, account_id: str) -> AccountRef | None: with get_database().session() as session: account = session.get(Account, account_id) @@ -113,6 +187,9 @@ class SqlAccessDirectory(AccessDirectory): return tuple(_group_ref(group) for group in groups) def display_label(self, subject: AccessSubjectRef) -> str | None: + if subject.kind == "identity": + identity = self.get_identity(subject.id) + return identity.display_name if identity else subject.label if subject.kind == "account": account = self.get_account(subject.id) return account.display_name or account.email if account else subject.label @@ -122,4 +199,117 @@ class SqlAccessDirectory(AccessDirectory): if subject.kind == "group": group = self.get_group(subject.id) return group.name if group else subject.label + if subject.kind == "organization_unit": + item = self.get_organization_unit(subject.id) + return item.name if item else subject.label + if subject.kind == "function": + item = self.get_function(subject.id) + return item.name if item else subject.label return subject.label or subject.id + + def get_identity(self, identity_id: str) -> IdentityRef | None: + 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, ...]: + 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: + 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, ...]: + 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: + 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, ...]: + 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) + return tuple(_function_assignment_ref(item) for item in items) diff --git a/src/govoplan_access/backend/explanation.py b/src/govoplan_access/backend/explanation.py new file mode 100644 index 0000000..4d2ce9f --- /dev/null +++ b/src/govoplan_access/backend/explanation.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import ( + Account, + 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 +from govoplan_core.db.session import get_database +from govoplan_core.security.permissions import scopes_grant + + +class SqlAccessExplanationService(AccessExplanationService): + 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)) + + def explain_resource_provenance( + self, + principal: PrincipalRef, + *, + resource_type: str, + resource_id: str, + action: str, + ) -> tuple[AccessDecisionProvenance, ...]: + del resource_type, resource_id + return self.explain_scope_provenance(principal, action) + + +def _scope_provenance(session: Session, principal: PrincipalRef, required_scope: str) -> 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) + 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", + ) + ) + _append_direct_role_provenance(session, items, user, required_scope) + _append_group_role_provenance(session, items, user, required_scope) + _append_function_role_provenance(session, items, user, required_scope) + if account is not None: + _append_system_role_provenance(session, items, account, required_scope) + items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope)) + return items + + +def _append_direct_role_provenance( + session: Session, + items: list[AccessDecisionProvenance], + user: User, + required_scope: str, +) -> None: + roles = ( + session.query(Role) + .join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id) + .filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id) + .all() + ) + for role in roles: + if scopes_grant(role.permissions or [], required_scope): + items.append( + AccessDecisionProvenance( + kind="role", + id=role.id, + label=role.name, + tenant_id=user.tenant_id, + source="direct_user_role", + ) + ) + + +def _append_group_role_provenance( + session: Session, + items: list[AccessDecisionProvenance], + user: User, + required_scope: str, +) -> None: + 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() + ) + for group, role in rows: + if scopes_grant(role.permissions or [], required_scope): + items.append( + AccessDecisionProvenance( + kind="group", + id=group.id, + label=group.name, + tenant_id=user.tenant_id, + source="group_membership", + ) + ) + items.append( + AccessDecisionProvenance( + kind="role", + id=role.id, + label=role.name, + tenant_id=user.tenant_id, + source="group_role", + details={"group_id": group.id}, + ) + ) + + +def _append_function_role_provenance( + session: Session, + items: list[AccessDecisionProvenance], + user: User, + required_scope: str, +) -> None: + direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id) + for assignment in direct_assignments: + _append_function_assignment_provenance(session, items, assignment, required_scope, 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 + items.append( + AccessDecisionProvenance( + kind="delegation", + id=delegation.id, + label=delegation.mode, + tenant_id=delegation.tenant_id, + source="function_delegation", + details={ + "function_assignment_id": delegation.function_assignment_id, + "delegator_account_id": delegation.delegator_account_id, + "delegate_account_id": delegation.delegate_account_id, + }, + ) + ) + _append_function_assignment_provenance(session, items, assignment, required_scope, source="delegated_function") + + +def _append_function_assignment_provenance( + session: Session, + items: list[AccessDecisionProvenance], + assignment: FunctionAssignment, + required_scope: str, + *, + source: str, +) -> None: + 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() + ) + matching = [role for role in roles if scopes_grant(role.permissions or [], required_scope)] + if not matching: + return + items.append( + AccessDecisionProvenance( + kind="organization_unit", + id=assignment.organization_unit_id, + tenant_id=assignment.tenant_id, + source=source, + details={"applies_to_subunits": assignment.applies_to_subunits}, + ) + ) + items.append( + AccessDecisionProvenance( + kind="function", + id=assignment.id, + label=function.name, + tenant_id=assignment.tenant_id, + source=source, + details={"function_id": function.id, "organization_unit_id": assignment.organization_unit_id}, + ) + ) + for role in matching: + items.append( + AccessDecisionProvenance( + kind="role", + id=role.id, + label=role.name, + tenant_id=assignment.tenant_id, + source=source, + details={"function_assignment_id": assignment.id, "function_id": function.id}, + ) + ) + + +def _append_system_role_provenance( + session: Session, + items: list[AccessDecisionProvenance], + account: Account, + required_scope: str, +) -> None: + roles = ( + session.query(Role) + .join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id) + .filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None)) + .all() + ) + for role in roles: + if scopes_grant(role.permissions or [], required_scope): + items.append( + AccessDecisionProvenance( + kind="role", + id=role.id, + label=role.name, + source="system_role", + ) + ) diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 1967077..29132e0 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -1,17 +1,34 @@ from __future__ import annotations +from pathlib import Path + from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_core.core.access import ( CAPABILITY_ACCESS_ADMINISTRATION, CAPABILITY_ACCESS_DIRECTORY, + CAPABILITY_ACCESS_EXPLANATION, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_ACCESS_TENANT_PROVISIONER, + CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, ) 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, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: @@ -44,6 +61,10 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"), _permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"), _permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"), + _permission("access:function:read", "View functions", "Inspect organization-bound functions and assignments.", "Tenant access", "tenant"), + _permission("access:function:write", "Manage functions", "Create and update organization units, functions, and role mappings.", "Tenant access", "tenant"), + _permission("access:function:assign", "Assign functions", "Assign organization-bound functions to accounts.", "Tenant access", "tenant"), + _permission("access:function:delegate", "Delegate functions", "Create and revoke permitted function delegations.", "Tenant access", "tenant"), _permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"), _permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"), _permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"), @@ -103,6 +124,10 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( "access:group:manage_members", "access:role:read", "access:role:assign", + "access:function:read", + "access:function:write", + "access:function:assign", + "access:function:delegate", "access:api_key:read", "access:api_key:create", "access:api_key:revoke", @@ -128,6 +153,169 @@ ADMIN_READ_SCOPES = ( "access:tenant:read", "access:account:read", "access:governance:read", + "access:function:read", +) + +ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( + DocumentationTopic( + id="access.workflow.grant-user-access", + title="Grant a person access", + summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.", + body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change.", + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin"), + order=30, + conditions=( + DocumentationCondition( + required_modules=("access",), + any_scopes=( + "admin:users:read", + "admin:groups:read", + "admin:roles:read", + "access:membership:read", + "access:group:read", + "access:role:read", + ), + ), + ), + links=( + DocumentationLink(label="Access administration", href="/admin", kind="runtime"), + DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"), + DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"), + DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"), + ), + configuration_keys=("access_governance",), + metadata={ + "kind": "workflow", + "outcome": "A person can sign in to the tenant and receives the intended access through groups and roles.", + "prerequisites": [ + "You can open Admin.", + "You may read users, groups, and roles.", + "Write or assignment actions require matching management permissions.", + ], + "steps": [ + "Open Admin and go to Users.", + "Find the existing person or create a membership with their email address and display name.", + "Review current groups and direct roles before changing anything.", + "Add the person to the smallest group that grants the needed shared access.", + "Assign direct roles only when a group does not match the case.", + "Save and review any blocker message before asking a system or tenant owner for help.", + ], + "result": "The membership has the intended effective permissions and no broader roles than necessary.", + "verification": "Open the user again and compare groups, direct roles, and effective permissions with the request.", + "related_field_ids": [ + "access.user.email", + "access.user.display_name", + "access.user.groups", + "access.user.roles", + ], + "related_topic_ids": [ + "access.reference.admin-access-fields", + "docs.pattern.field-help", + ], + }, + ), + DocumentationTopic( + id="access.reference.admin-access-fields", + title="Access administration fields", + summary="The access administration screens show tenant memberships, groups, roles, and API keys. Admin docs map the visible fields to API payloads and permission scopes.", + body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions.", + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator"), + order=31, + conditions=( + DocumentationCondition( + required_modules=("access",), + any_scopes=( + "admin:users:read", + "admin:groups:read", + "admin:roles:read", + "admin:api_keys:read", + "access:membership:read", + "access:group:read", + "access:role:read", + "access:api_key:read", + ), + ), + ), + links=( + DocumentationLink(label="Access administration", href="/admin", kind="runtime"), + DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"), + DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"), + DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"), + DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"), + ), + configuration_keys=("access_governance",), + metadata={ + "kind": "reference", + "route": "/admin", + "screen": "Admin", + "section": "Users, groups, roles, and API keys", + "fields": [ + { + "field_id": "access.user.email", + "label": "Email", + "user_description": "The address used to identify the person when they sign in.", + "admin_description": "Stored on the account and membership payloads. It must be normalized and unique for the relevant login account.", + "api_path": "/api/v1/admin/users", + "api_field": "email", + "permission_scope": "access:membership:create", + "validation": "Must be a valid email address.", + "provenance": "Tenant membership creation or account lookup.", + }, + { + "field_id": "access.user.display_name", + "label": "Display name", + "user_description": "The readable name shown in user lists and review screens.", + "admin_description": "Maps to display_name on user and account responses where available.", + "api_path": "/api/v1/admin/users", + "api_field": "display_name", + "permission_scope": "access:membership:update", + "validation": "Human-readable text; keep it recognizable for administrators.", + "provenance": "Tenant membership profile.", + }, + { + "field_id": "access.user.groups", + "label": "Groups", + "user_description": "Shared access bundles that can add roles for many people at once.", + "admin_description": "Maps to group_ids when updating a user or group membership.", + "api_path": "/api/v1/admin/users/{user_id}", + "api_field": "group_ids", + "permission_scope": "access:group:manage_members", + "validation": "Groups must belong to the same tenant.", + "provenance": "User-group membership rows.", + }, + { + "field_id": "access.user.roles", + "label": "Roles", + "user_description": "Direct access grants assigned to one person or inherited from groups.", + "admin_description": "Maps to role_ids on user and group role update requests.", + "api_path": "/api/v1/admin/users/{user_id}", + "api_field": "role_ids", + "permission_scope": "access:role:assign", + "validation": "Roles must be assignable and cannot exceed the actor's delegation limit.", + "provenance": "Direct user roles plus group role inheritance.", + }, + { + "field_id": "access.api_key.scopes", + "label": "Scopes", + "user_description": "The actions an API key may perform.", + "admin_description": "Maps to scopes when creating an API key and is intersected with the owner's current permissions.", + "api_path": "/api/v1/admin/api-keys", + "api_field": "scopes", + "permission_scope": "access:api_key:create", + "validation": "Use the narrowest scopes possible.", + "provenance": "API key grant plus owner delegation.", + }, + ], + "related_topic_ids": [ + "access.workflow.grant-user-access", + "docs.pattern.field-help", + ], + }, + ), ) @@ -152,6 +340,20 @@ def _access_directory(context: ModuleContext) -> object: return SqlAccessDirectory() +def _access_semantic_directory(context: ModuleContext) -> object: + del context + from govoplan_access.backend.directory import SqlAccessDirectory + + return SqlAccessDirectory() + + +def _access_explanation_service(context: ModuleContext) -> object: + del context + from govoplan_access.backend.explanation import SqlAccessExplanationService + + return SqlAccessExplanationService() + + def _tenant_provisioner(context: ModuleContext) -> object: del context from govoplan_access.backend.tenancy.provisioning import LegacyTenantAccessProvisioner @@ -173,6 +375,13 @@ def _governance_materializer(context: ModuleContext) -> object: return SqlAccessGovernanceMaterializer() +def _configuration_provider(context: ModuleContext) -> object: + del context + from govoplan_access.backend.configuration_provider import SqlAccessConfigurationProvider + + return SqlAccessConfigurationProvider() + + def _route_factory(context: ModuleContext): from fastapi import APIRouter @@ -194,16 +403,28 @@ manifest = ModuleManifest( name="Access", version="0.1.6", dependencies=("tenancy",), + optional_dependencies=("identity", "organizations"), permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, 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=( persistent_table_uninstall_guard( access_models.Account, + access_models.Identity, + access_models.IdentityAccountLink, access_models.User, access_models.Group, access_models.Role, + access_models.OrganizationUnit, + access_models.Function, + access_models.FunctionRoleAssignment, + access_models.FunctionAssignment, + access_models.FunctionDelegation, access_models.SystemRoleAssignment, access_models.UserGroupMembership, access_models.UserRoleAssignment, @@ -224,10 +445,14 @@ manifest = ModuleManifest( CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_ACCESS_DIRECTORY: _access_directory, + CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory, + CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service, CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner, CAPABILITY_ACCESS_ADMINISTRATION: _access_administration, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer, + ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, }, + documentation=ACCESS_DOCUMENTATION, ) diff --git a/src/govoplan_access/backend/migrations/__init__.py b/src/govoplan_access/backend/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_access/backend/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py b/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py new file mode 100644 index 0000000..768b8f3 --- /dev/null +++ b/src/govoplan_access/backend/migrations/versions/4a5b6c7d8e9f_access_semantic_directory.py @@ -0,0 +1,396 @@ +"""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 _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() + + 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"], + ["tenancy_tenants.id"], + name=op.f("fk_access_organization_units_tenant_id_tenancy_tenants"), + 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"], + ["tenancy_tenants.id"], + name=op.f("fk_access_functions_tenant_id_tenancy_tenants"), + 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"], + ["tenancy_tenants.id"], + name=op.f("fk_access_function_role_assignments_tenant_id_tenancy_tenants"), + 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_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"], + ["tenancy_tenants.id"], + name=op.f("fk_access_function_assignments_tenant_id_tenancy_tenants"), + 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"], + ["tenancy_tenants.id"], + name=op.f("fk_access_function_delegations_tenant_id_tenancy_tenants"), + 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_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) diff --git a/src/govoplan_access/backend/migrations/versions/__init__.py b/src/govoplan_access/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_access/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ + diff --git a/src/govoplan_access/backend/security/sessions.py b/src/govoplan_access/backend/security/sessions.py index e4c738d..e58a424 100644 --- a/src/govoplan_access/backend/security/sessions.py +++ b/src/govoplan_access/backend/security/sessions.py @@ -18,6 +18,7 @@ from govoplan_access.backend.db.models import ( UserGroupMembership, UserRoleAssignment, ) +from govoplan_access.backend.semantic import collect_function_roles from govoplan_core.security.permissions import expand_scopes from govoplan_core.security.time import ensure_aware_utc, utc_now @@ -169,6 +170,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]: ) for role in group_roles: roles_by_id[role.id] = role + for role in collect_function_roles(session, user): + roles_by_id[role.id] = role return list(roles_by_id.values()) @@ -218,4 +221,3 @@ def collect_tenant_memberships(session: Session, account: Account) -> list[tuple .order_by(Tenant.name.asc()) .all() ) - diff --git a/src/govoplan_access/backend/semantic.py b/src/govoplan_access/backend/semantic.py new file mode 100644 index 0000000..ff47466 --- /dev/null +++ b/src/govoplan_access/backend/semantic.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import ( + Account, + Function, + FunctionAssignment, + FunctionDelegation, + FunctionRoleAssignment, + Identity, + IdentityAccountLink, + Role, + User, +) +from govoplan_core.security.time import utc_now + + +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) -> str | None: + 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() + ) diff --git a/webui/package.json b/webui/package.json index 3973cbb..819f16e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index af40293..08802f3 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,4 +1,4 @@ -import type { ApiSettings } from "@govoplan/core-webui"; +import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import { apiFetch } from "@govoplan/core-webui"; export type PermissionItem = { @@ -171,17 +171,35 @@ export type SystemSettingsItem = { allow_tenant_custom_roles: boolean; allow_tenant_api_keys: boolean; privacy_retention_policy: PrivacyRetentionPolicy; + available_languages?: LanguagePackage[]; + enabled_language_codes?: string[]; settings: Record; }; +export type LanguagePackage = { + code: string; + label: string; + native_label?: string | null; +}; + export type TenantSettingsItem = { id: string; slug: string; name: string; default_locale: string; + available_languages: LanguagePackage[]; + system_enabled_language_codes: string[]; + enabled_language_codes: string[]; settings: Record; }; +export type TenantSettingsDeltaSections = Partial<{ + identity: Pick; + locale: Pick; + languages: Pick; + settings: Pick["settings"]; +}>; + export type RetentionRunResponse = { result: { dry_run: boolean; @@ -236,6 +254,42 @@ export type AuditAdminItem = { created_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 TenantSettingsDeltaResponse = { + item?: TenantSettingsItem | null; + sections: TenantSettingsDeltaSections; + changed_sections: string[]; +} & DeltaResponseFields; +export type AuditAdminDeltaResponse = { + items: AuditAdminItem[]; + total: number; + page: number; + page_size: number; + pages: number; + cursor?: string | null; + next_cursor?: string | null; +} & DeltaResponseFields; + +function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { + const params = new URLSearchParams(); + if (options.since) params.set("since", options.since); + if (options.limit) params.set("limit", String(options.limit)); + return params.toString() ? `?${params.toString()}` : ""; +} + export function fetchAdminOverview(settings: ApiSettings): Promise { @@ -252,6 +306,11 @@ export async function fetchTenants(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`); +} + export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); return response.accounts; @@ -289,7 +348,12 @@ export function fetchTenantSettings(settings: ApiSettings): Promise { +export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + 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 { return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) }); } @@ -298,6 +362,11 @@ export async function fetchUsers(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/users/delta${suffix}`); +} + export function createUser(settings: ApiSettings, payload: { email: string; display_name?: string | null; @@ -324,6 +393,11 @@ export async function fetchGroups(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/groups/delta${suffix}`); +} + export function createGroup(settings: ApiSettings, payload: { slug: string; name: string; @@ -350,6 +424,11 @@ export async function fetchRoles(settings: ApiSettings): Promise return response.roles; } +export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/roles/delta${suffix}`); +} + export function createRole(settings: ApiSettings, payload: { slug: string; name: string; @@ -377,6 +456,11 @@ export async function fetchSystemRoles(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/system/roles/delta${suffix}`); +} + export function createSystemRole(settings: ApiSettings, payload: { slug: string; name: string; @@ -403,6 +487,11 @@ export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ acco return apiFetch(settings, "/api/v1/admin/system/accounts"); } +export function fetchSystemAccountsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/system/accounts/delta${suffix}`); +} + export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: { display_name?: string | null; is_active?: boolean; @@ -429,6 +518,15 @@ export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false return response.api_keys; } +export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise { + const params = new URLSearchParams(); + if (includeRevoked) params.set("include_revoked", "true"); + if (options.since) params.set("since", options.since); + if (options.limit) params.set("limit", String(options.limit)); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`); +} + export function createApiKey(settings: ApiSettings, payload: { name: string; user_id?: string | null; @@ -450,12 +548,13 @@ export type AuditQueryOptions = { offset?: number; page?: number; pageSize?: number; + cursor?: string | null; sortBy?: "time" | "actor" | "action" | "object" | "tenant"; sortDirection?: "asc" | "desc"; filters?: Partial>; }; -export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> { +export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number; cursor?: string | null; next_cursor?: string | null }> { const params = new URLSearchParams(); if (options.tenantId) params.set("tenant_id", options.tenantId); if (options.allTenants) params.set("all_tenants", "true"); @@ -464,6 +563,7 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery 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.cursor) params.set("cursor", options.cursor); 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 ?? {})) { @@ -473,6 +573,24 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery return apiFetch(settings, `/api/v1/admin/audit${suffix}`); } +export async function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise { + 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.pageSize) params.set("page_size", String(options.pageSize)); + if (options.cursor) params.set("cursor", options.cursor); + if (options.sortBy) params.set("sort_by", options.sortBy); + if (options.sortDirection) params.set("sort_direction", options.sortDirection); + if (options.since) params.set("since", options.since); + 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/delta${suffix}`); +} + export function createSystemAccount(settings: ApiSettings, payload: { email: string; @@ -502,6 +620,8 @@ export type SystemSettingsUpdatePayload = { allow_tenant_custom_roles: boolean; allow_tenant_api_keys: boolean; privacy_retention_policy?: PrivacyRetentionPolicy | null; + available_languages?: LanguagePackage[] | null; + enabled_language_codes?: string[] | null; }; export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { @@ -532,6 +652,11 @@ export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "gr return response.templates; } +export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/system/governance-templates/delta${suffix}`); +} + export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); } diff --git a/webui/src/features/admin/AdminAuditPanel.tsx b/webui/src/features/admin/AdminAuditPanel.tsx index 6891a6d..c27c078 100644 --- a/webui/src/features/admin/AdminAuditPanel.tsx +++ b/webui/src/features/admin/AdminAuditPanel.tsx @@ -1,11 +1,13 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Search } from "lucide-react"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; -import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin"; +import { fetchAdminAudit, fetchAdminAuditDelta, 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"; +import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui"; + +type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant"; const DEFAULT_QUERY: DataGridQueryState = { sort: { columnId: "time", direction: "desc" }, @@ -16,12 +18,16 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false -}: { - settings: ApiSettings; - auth: AuthInfo; - systemMode?: boolean; -}) { + + + + +}: {settings: ApiSettings;auth: AuthInfo;systemMode?: boolean;}) { const [items, setItems] = useState([]); + const itemsRef = useRef([]); + const pageItemsRef = useRef>({}); + const pageCursorsRef = useRef>({ 1: null }); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); @@ -37,27 +43,60 @@ export default function AdminAuditPanel({ setError(""); try { const sortColumn = query.sort?.columnId; - const response = await fetchAdminAudit(settings, { + const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) ? + sortColumn as AuditSortBy : + "time"; + const sortDirection = query.sort?.direction ?? "desc"; + const filters = query.filters; + const pageCursor = page === 1 ? null : pageCursorsRef.current[page]; + const deltaMode = page === 1 || pageCursor !== undefined; + const requestOptions = { 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); + cursor: pageCursor, + sortBy, + sortDirection, + filters + }; + const deltaKey = `access:audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`; + const response = deltaMode + ? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) }) + : await fetchAdminAudit(settings, requestOptions); + const baseItems = pageItemsRef.current[deltaKey] ?? []; + const nextItems = "full" in response && !response.full + ? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize) + : response.items; + pageItemsRef.current[deltaKey] = nextItems; + itemsRef.current = nextItems; + setItems(nextItems); setTotal(response.total); - if (response.page !== page) setPage(response.page); + if (!deltaMode && response.page !== page) setPage(response.page); + if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null; + if (response.next_cursor !== undefined) { + if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor; + else delete pageCursorsRef.current[page + 1]; + } + if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) { + pageCursorsRef.current = { 1: null }; + } + if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark); + if (!deltaMode) resetDeltaWatermark(deltaKey); } catch (err) { setError(adminErrorMessage(err)); } finally { setLoading(false); } - }, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]); + }, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]); - useEffect(() => { void load(); }, [load]); + useEffect(() => { + itemsRef.current = []; + pageItemsRef.current = {}; + pageCursorsRef.current = { 1: null }; + resetDeltaWatermark(); + }, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, pageSize, query, resetDeltaWatermark]); + + useEffect(() => {void load();}, [load]); const handleQueryChange = useCallback((next: DataGridQueryState) => { setQuery((current) => { @@ -68,13 +107,13 @@ export default function AdminAuditPanel({ }, []); const columns = useMemo[]>(() => [ - { 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) =>
} onClick={() => setSelected(row)} />
} - ], [systemMode]); + { id: "time", header: "i18n:govoplan-access.time.6c82e6dd", 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: "i18n:govoplan-access.actor.cbd19b5c", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "i18n:govoplan-access.system.bc0792d8" }, + { id: "action", header: "i18n:govoplan-access.action.97c89a4d", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action }, + { id: "object", header: "i18n:govoplan-access.object.2883f191", 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: "i18n:govoplan-access.tenant_context.b401a2ad", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []), + { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 70, sticky: "end", resizable: false, align: "right", render: (row) =>
} onClick={() => setSelected(row)} />
}], + [systemMode]); const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1; const lastShown = Math.min(total, page * pageSize); @@ -82,21 +121,21 @@ export default function AdminAuditPanel({ return ( <> setReloadToken((value) => value + 1)} disabled={loading}>Reload} - > + actions={}> +
row.id} - emptyText="No administrative audit records found." + emptyText="i18n:govoplan-access.no_administrative_audit_records_found.8d128767" className="admin-audit-grid" initialSort={{ columnId: "time", direction: "desc" }} pagination={{ @@ -107,15 +146,36 @@ export default function AdminAuditPanel({ pageSizeOptions: [10, 25, 50, 100, 250], disabled: loading, onPageChange: setPage, - onPageSizeChange: (next) => { setPageSize(next); setPage(1); } + onPageSizeChange: (next) => {setPageSize(next);setPage(1);} }} - onQueryChange={handleQueryChange} - /> + onQueryChange={handleQueryChange} /> +
- setSelected(null)} className="admin-dialog admin-dialog-wide" footer={}> - {selected && <>
Scope
{selected.scope}
Action
{selected.action}
Actor
{selected.actor_email || "System"}
Object
{selected.object_type || "—"} {selected.object_id || ""}
Tenant context
{selected.tenant_id || "—"}
Time
{formatDateTime(selected.created_at)}
{JSON.stringify(selected.details, null, 2)}
} + setSelected(null)} className="admin-dialog admin-dialog-wide" footer={}> + {selected && <>
i18n:govoplan-access.scope.4651a34e
{selected.scope}
i18n:govoplan-access.action.97c89a4d
{selected.action}
i18n:govoplan-access.actor.cbd19b5c
{selected.actor_email || "i18n:govoplan-access.system.bc0792d8"}
i18n:govoplan-access.object.2883f191
{selected.object_type || "—"} {selected.object_id || ""}
i18n:govoplan-access.tenant_context.b401a2ad
{selected.tenant_id || "—"}
i18n:govoplan-access.time.6c82e6dd
{formatDateTime(selected.created_at)}
{JSON.stringify(selected.details, null, 2)}
}
- - ); + ); + +} + +function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number { + return (left, right) => { + const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy)); + const directed = sortDirection === "asc" ? primary : -primary; + return directed || right.id.localeCompare(left.id); + }; +} + +function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number { + if (sortBy === "time") return new Date(item.created_at).getTime(); + if (sortBy === "actor") return item.actor_email || "System"; + if (sortBy === "action") return item.action; + if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`; + return item.tenant_id || ""; +} + +function compareAuditValues(left: string | number, right: string | number): number { + if (typeof left === "number" && typeof right === "number") return left - right; + return String(left).localeCompare(String(right)); } diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index 078063e..d8e7a19 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -1,6 +1,13 @@ import { useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; -import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui"; +import type { + AdminSectionContribution, + AdminSectionsUiCapability, + ApiSettings, + AuthInfo, + FilesConnectorsUiCapability, + MailProfilesUiCapability +} from "@govoplan/core-webui"; import { fetchMe } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; @@ -14,6 +21,7 @@ import GroupsPanel from "./GroupsPanel"; import RolesPanel from "./RolesPanel"; import ApiKeysPanel from "./ApiKeysPanel"; import AdminAuditPanel from "./AdminAuditPanel"; +import FileConnectorsPanel from "./FileConnectorsPanel"; import MailProfilesPanel from "./MailProfilesPanel"; import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui"; @@ -21,6 +29,38 @@ import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCap type AdminSection = string; type OrderedAdminNavItem = { id: AdminSection; label: string; order: number }; +const handledAdminSectionIds = new Set([ + "overview", + "system-settings", + "system-configuration-changes", + "system-configuration-packages", + "system-modules", + "system-audit", + "system-tenants", + "system-roles", + "system-role-templates", + "system-groups", + "system-users", + "system-file-connectors", + "system-mail-servers", + "system-retention", + "tenant-settings", + "tenant-roles", + "tenant-groups", + "tenant-users", + "tenant-file-connectors", + "tenant-mail-servers", + "tenant-api-keys", + "tenant-retention", + "tenant-audit", + "tenant-group-file-connectors", + "tenant-group-mail-servers", + "tenant-group-retention", + "tenant-user-file-connectors", + "tenant-user-mail-servers", + "tenant-user-retention" +]); + export default function AdminPage({ settings, auth, @@ -31,16 +71,19 @@ export default function AdminPage({ onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; }) { const mailProfilesUi = usePlatformUiCapability("mail.profiles"); + const fileConnectorsUi = usePlatformUiCapability("files.connectors"); const adminSectionCapabilities = usePlatformUiCapabilities("admin.sections"); const mailProfilesAvailable = Boolean(mailProfilesUi); + const fileConnectorsAvailable = Boolean(fileConnectorsUi); const auditAvailable = usePlatformModuleInstalled("audit"); const policyAvailable = usePlatformModuleInstalled("policy"); - const tenancyAvailable = usePlatformModuleInstalled("tenancy"); - const contributedSections = useMemo(() => ( - adminSectionCapabilities - .flatMap((capability) => capability.sections) - .sort((left, right) => (left.order ?? 100) - (right.order ?? 100)) - ), [adminSectionCapabilities]); + const contributedSections = useMemo( + () => + adminSectionCapabilities + .flatMap((capability) => capability.sections) + .sort((left, right) => (left.order ?? 100) - (right.order ?? 100)), + [adminSectionCapabilities] + ); const contributionById = useMemo(() => { const mapped = new Map(); for (const section of contributedSections) { @@ -58,7 +101,7 @@ export default function AdminPage({ if (policyAvailable) sections.add("system-retention"); if (mailProfilesAvailable) sections.add("system-mail-servers"); } - if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); + if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit"); @@ -71,6 +114,10 @@ export default function AdminPage({ if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers"); } + if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) { + if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors"); + if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors"); + } if (policyAvailable && hasScope(auth, "admin:policies:read")) { sections.add("tenant-retention"); if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention"); @@ -79,7 +126,7 @@ export default function AdminPage({ if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit"); return sections; - }, [auth, auditAvailable, contributedSections, mailProfilesAvailable, policyAvailable, tenancyAvailable]); + }, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]); const [searchParams, setSearchParams] = useSearchParams(); const requestedSection = searchParams.get("section") as AdminSection | null; const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); @@ -105,50 +152,85 @@ export default function AdminPage({ }, [settings.accessToken, settings.apiBaseUrl]); if (!hasAnyScope(auth, adminReadScopes)) { - return

Your current roles do not grant administrative access.

; + return ( +
+ +

i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69

+
+
+ ); } - const rootItems = contributedNavItems(contributedSections, available, "ROOT"); const adminSubnav: ModuleSubnavGroup[] = [ - { items: asSubnavItems(rootItems) }, { - title: "SYSTEM", - items: asSubnavItems(sortNavItems([ - ...contributedNavItems(contributedSections, available, "SYSTEM"), - visibleNavItem(available, "system-tenants", "Tenants", 20), - visibleNavItem(available, "system-roles", "System roles", 30), - visibleNavItem(available, "system-users", "Users", 60), - visibleNavItem(available, "system-mail-servers", "Mail servers", 70), - visibleNavItem(available, "system-retention", "Retention", 80), - visibleNavItem(available, "system-audit", "Audit", 90) - ])) + title: "ADMINISTRATION", + items: asSubnavItems( + sortNavItems([ + ...contributedNavItems(contributedSections, available, "ROOT"), + visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10), + visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20), + visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30), + visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40), + visibleNavItem(available, "system-audit", "i18n:govoplan-access.audit.fa1703dd", 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-retention", "i18n:govoplan-access.retention.c7199d9e", 80), + ...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds), + ...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds) + ]) + ) }, { title: "TENANT", - items: [ - ...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []), - ...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []), - ...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []), - ...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []), - ...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []), - ...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []), - ...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []), - ...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : []) - ] + items: asSubnavItems( + sortNavItems([ + visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10), + visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20), + visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30), + visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40), + visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50), + visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60), + visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70), + visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80), + visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 90), + ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) + ]) + ) }, { title: "GROUP", - items: [ - ...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []), - ...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []), - ] + items: asSubnavItems( + sortNavItems([ + visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), + visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), + visibleNavItem(available, "tenant-group-retention", "i18n:govoplan-access.retention.c7199d9e", 30), + ...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds) + ]) + ) }, { title: "USER", - items: [ - ...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []), - ...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []), - ] + items: asSubnavItems( + sortNavItems([ + visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), + visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), + visibleNavItem(available, "tenant-user-retention", "i18n:govoplan-access.retention.c7199d9e", 30), + ...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds) + ]) + ) } ].filter((group) => group.items.length > 0); const contributedSection = contributionById.get(active); @@ -161,22 +243,24 @@ export default function AdminPage({
{contributedSection && contributedSection.render(contributionContext)} {!contributedSection && active === "system-retention" && } - {!contributedSection && active === "system-mail-servers" && } - {!contributedSection && active === "system-tenants" && } - {!contributedSection && active === "system-users" && } - {!contributedSection && active === "system-roles" && } + {!contributedSection && active === "system-mail-servers" && ( + + )} + {!contributedSection && active === "system-tenants" && ( + + )} + {!contributedSection && active === "system-users" && ( + + )} + {!contributedSection && active === "system-roles" && } {!contributedSection && active === "system-audit" && } {!contributedSection && active === "tenant-users" && } {!contributedSection && active === "tenant-groups" && } @@ -185,6 +269,8 @@ export default function AdminPage({ {!contributedSection && active === "tenant-mail-servers" && } {!contributedSection && active === "tenant-user-mail-servers" && } {!contributedSection && active === "tenant-group-mail-servers" && } + {!contributedSection && active === "tenant-user-file-connectors" && } + {!contributedSection && active === "tenant-group-file-connectors" && } {!contributedSection && active === "tenant-retention" && } {!contributedSection && active === "tenant-user-retention" && } {!contributedSection && active === "tenant-group-retention" && } @@ -202,9 +288,14 @@ function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribut return true; } -function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet, group: string): OrderedAdminNavItem[] { +function contributedNavItems( + sections: AdminSectionContribution[], + available: ReadonlySet, + group: string, + excludedIds: ReadonlySet = new Set() +): OrderedAdminNavItem[] { 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 })); } diff --git a/webui/src/features/admin/ApiKeysPanel.tsx b/webui/src/features/admin/ApiKeysPanel.tsx index e029494..af17111 100644 --- a/webui/src/features/admin/ApiKeysPanel.tsx +++ b/webui/src/features/admin/ApiKeysPanel.tsx @@ -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 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; +import { DateTimeField } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; -import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; -import { scopeGrants } from "@govoplan/core-webui"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui"; +import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { loadDeltaRows } from "./utils/deltaRows"; -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([]); const [users, setUsers] = useState([]); const [permissions, setPermissions] = useState([]); + const keysRef = useRef([]); + const usersRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [showRevoked, setShowRevoked] = useState(false); const [creating, setCreating] = useState(false); const [viewing, setViewing] = useState(null); - const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" }); - const [secret, setSecret] = useState<{ name: string; value: string } | null>(null); + const [draft, setDraft] = useState(() => defaultDraft(auth.user.id)); + const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id))); + const [secret, setSecret] = useState<{name: string;value: string;} | null>(null); const [revoking, setRevoking] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const dirty = creating && draftKey(draft) !== savedDraftKey; + + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: closeCreate + }); async function load() { setLoading(true); setError(""); try { - const [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); setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active)); setPermissions(nextPermissions.filter((permission) => permission.level === "tenant")); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setLoading(false); } + } catch (err) {setError(adminErrorMessage(err));} finally + {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 allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))); const columns = useMemo[]>(() => [ - { id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) =>
{row.name}
{row.prefix}…
}, - { id: "owner", header: "Owner", 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: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => }, - { 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: "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: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
- } onClick={() => setViewing(row)} /> - } disabled /> - } variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} /> -
} - ], [canRevoke]); + { 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) =>
{row.name}
{row.prefix}…
}, + { id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email }, + { id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) }, + { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => }, + { id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) }, + { id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" }, + { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
+ } onClick={() => setViewing(row)} /> + } disabled /> + } variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} /> +
}], + [canRevoke]); function openCreate() { 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); setError(""); } - async function save() { + function closeCreate() { + setCreating(false); + const nextDraft = defaultDraft(auth.user.id); + setDraft(nextDraft); + setSavedDraftKey(draftKey(nextDraft)); + } + + async function save(): Promise { setBusy(true); setError(""); try { const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null }); setSecret({ name: created.name, value: created.secret }); setCreating(false); - setSuccess(`API key ${created.name} created.`); + setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name })); await load(); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setBusy(false); } + return true; + } catch (err) {setError(adminErrorMessage(err));return false;} finally + {setBusy(false);} } async function revoke() { @@ -83,42 +142,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { setError(""); try { 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); await load(); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setBusy(false); } + } catch (err) {setError(adminErrorMessage(err));} finally + {setBusy(false);} } return ( <> - } variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} />}> -
row.id} emptyText="No API keys found." />
+ } variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} />}> +
row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" />
- !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<>}> + !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<>}>
- setDraft({ ...draft, name: event.target.value })} /> - - setDraft({ ...draft, expiresAt: event.target.value })} /> + setDraft({ ...draft, name: event.target.value })} /> + + setDraft({ ...draft, expiresAt: value })} />
-
Allowed scopes ({ 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." />
+
i18n:govoplan-access.allowed_scopes.d94515ff ({ 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" />
- setViewing(null)} className="admin-dialog admin-dialog-wide" footer={}> + setViewing(null)} className="admin-dialog admin-dialog-wide" footer={}> {viewing && <>
-
Name
{viewing.name}
Prefix
{viewing.prefix}…
-
Owner
{viewing.user_email}
Status
{viewing.revoked_at ? "Revoked" : "Active"}
-
Created
{formatDateTime(viewing.created_at)}
Last used
{formatDateTime(viewing.last_used_at)}
-
Expires
{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}
Revoked
{formatDateTime(viewing.revoked_at)}
-

Scopes

{viewing.scopes.map((scope) => {scope})}
} +
i18n:govoplan-access.name.709a2322
{viewing.name}
i18n:govoplan-access.prefix.90eceb01
{viewing.prefix}…
+
i18n:govoplan-access.owner.89ff3122
{viewing.user_email}
i18n:govoplan-access.status.bae7d5be
{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}
+
i18n:govoplan-access.created.accf40c8
{formatDateTime(viewing.created_at)}
i18n:govoplan-access.last_used.f1109d3d
{formatDateTime(viewing.last_used_at)}
+
i18n:govoplan-access.expires.a99be3da
{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}
i18n:govoplan-access.revoked.85f17ac0
{formatDateTime(viewing.revoked_at)}
+

i18n:govoplan-access.scopes.c23540e5

{viewing.scopes.map((scope) => {scope})}
}
- setSecret(null)} className="admin-dialog" footer={}> - {secret && <>

The secret for {secret.name} is shown once.

{secret.value}

Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.

} + setSecret(null)} className="admin-dialog" footer={}> + {secret && <>

i18n:govoplan-access.the_secret_for.c60737ef {secret.name} i18n:govoplan-access.is_shown_once.af2b1235

{secret.value}

i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588

}
- setRevoking(null)} onConfirm={() => void revoke()} /> - - ); + setRevoking(null)} onConfirm={() => void revoke()} /> + ); + +} + +function draftKey(draft: ReturnType): 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); } diff --git a/webui/src/features/admin/FileConnectorsPanel.tsx b/webui/src/features/admin/FileConnectorsPanel.tsx new file mode 100644 index 0000000..59a43db --- /dev/null +++ b/webui/src/features/admin/FileConnectorsPanel.tsx @@ -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; + canWrite: boolean; +}; + +const copy: Record = { + 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("files.connectors"); + const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null; + const [targets, setTargets] = useState([]); + const usersRef = useRef([]); + const groupsRef = useRef([]); + 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 ( + + +

i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153

+
+
); + + } + + return ( + + + + ); + +} + +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); +} diff --git a/webui/src/features/admin/GroupsPanel.tsx b/webui/src/features/admin/GroupsPanel.tsx index e17d10e..eb6c057 100644 --- a/webui/src/features/admin/GroupsPanel.tsx +++ b/webui/src/features/admin/GroupsPanel.tsx @@ -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 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; -import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { loadDeltaRows } from "./utils/deltaRows"; const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] }; -export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: { - settings: ApiSettings; - auth: AuthInfo; - canDefine: boolean; - canManageMembers: boolean; - canAssignRoles: boolean; - onAuthRefresh: () => Promise; -}) { +export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh + + + + + + +}: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise;}) { const [groups, setGroups] = useState([]); const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); + const groupsRef = useRef([]); + const usersRef = useRef([]); + const rolesRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); const [deactivating, setDeactivating] = useState(null); const [draft, setDraft] = useState(emptyDraft); + const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const dirty = editing !== null && draftKey(draft) !== savedDraftKey; + + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: closeEditor + }); async function load() { setLoading(true); setError(""); try { - const [nextGroups, nextUsers, nextRoles] = await Promise.all([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); setUsers(nextUsers); setRoles(nextRoles.filter((role) => role.is_assignable)); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setLoading(false); } + } catch (err) {setError(adminErrorMessage(err));} finally + {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) { - 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); setError(""); } - async function save() { + function closeEditor() { + setEditing(null); + setDraft(emptyDraft); + setSavedDraftKey(draftKey(emptyDraft)); + } + + async function save(): Promise { setBusy(true); setError(""); try { if (editing === "new") { await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] }); - setSuccess(`Group ${draft.name} created.`); + setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name })); } else if (editing) { const managed = Boolean(editing.system_template_id); await updateGroup(settings, editing.id, { @@ -68,13 +102,14 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember ...(canManageMembers ? { member_ids: draft.memberIds } : {}), ...(canAssignRoles ? { role_ids: draft.roleIds } : {}) }); - setSuccess(`Group ${draft.name} updated.`); + setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name })); } setEditing(null); await onAuthRefresh(); await load(); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setBusy(false); } + return true; + } catch (err) {setError(adminErrorMessage(err));return false;} finally + {setBusy(false);} } async function deactivate() { @@ -83,57 +118,75 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember setError(""); try { 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); if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh(); await load(); - } catch (err) { setError(adminErrorMessage(err)); } - finally { setBusy(false); } + } catch (err) {setError(adminErrorMessage(err));} finally + {setBusy(false);} } const columns = useMemo[]>(() => [ - { 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) =>
{row.name}
{row.slug} {row.system_template_id && System{row.system_required ? " · required" : ""}}
}, - { id: "members", header: "Members", 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: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, - { id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
- } onClick={() => setViewing(row)} /> - } onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} /> - } variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} /> -
} - ], [canAssignRoles, canDefine, canManageMembers]); + { 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) =>
{row.name}
{row.slug} {row.system_template_id && i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}}
}, + { id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count }, + { id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, + { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, + { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
+ } onClick={() => setViewing(row)} /> + } onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} /> + } variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} /> +
}], + [canAssignRoles, canDefine, canManageMembers]); return ( <> - } variant="primary" onClick={openCreate} disabled={!canDefine} />}> -
row.id} emptyText="No groups found." />
+ } variant="primary" onClick={openCreate} disabled={!canDefine} />}> +
row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" />
- !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}> - {editing !== "new" && editing?.system_template_id &&

This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.

} + !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}> + {editing !== "new" && editing?.system_template_id &&

i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e

}
- setDraft({ ...draft, name: event.target.value })} /> - setDraft({ ...draft, slug: event.target.value })} /> - -