Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cb72ccb0 | ||
|
|
7eb67b9fa7 | ||
|
|
8f8072b4ae | ||
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 | ||
|
|
206873b62a | ||
|
|
2d1b1e356e | ||
|
|
fa0c85e03a | ||
|
|
94b604a3af | ||
|
|
d3daf42bd9 | ||
|
|
6052dde760 | ||
|
|
566b3b83ad | ||
|
|
e4bae0121d | ||
|
|
5c6f446cd5 | ||
|
|
2be1dbc132 | ||
|
|
38fc22c06b | ||
|
|
3661fdd370 | ||
|
|
3966c7f33c | ||
|
|
9ba69286f7 | ||
|
|
55d87a7812 | ||
|
|
a861338b9a | ||
|
|
0b9e3751c2 | ||
|
|
e04671034f | ||
|
|
44799b15e5 | ||
|
|
367ffc4564 | ||
|
|
a7ed28f6a3 |
@@ -63,8 +63,12 @@ This module will own:
|
|||||||
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
||||||
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
||||||
- access administration, tenant provisioning, and governance materializer
|
- access administration, tenant provisioning, and governance materializer
|
||||||
capabilities
|
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
||||||
|
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
||||||
- access-owned migrations
|
- access-owned migrations
|
||||||
|
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
|
||||||
|
credentials and authorization projections while preserving shared global
|
||||||
|
accounts and identities
|
||||||
|
|
||||||
The governance-template routes under `/admin/system/governance-templates` are
|
The governance-template routes under `/admin/system/governance-templates` are
|
||||||
contributed by `govoplan-admin`; access must not register those routes.
|
contributed by `govoplan-admin`; access must not register those routes.
|
||||||
@@ -142,6 +146,10 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
|
|
||||||
## Login Throttling
|
## Login Throttling
|
||||||
|
|
||||||
|
Optional PostgreSQL password-recovery race checks and their disposable-database
|
||||||
|
setup are documented in English and German in
|
||||||
|
[docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md](docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md).
|
||||||
|
|
||||||
Interactive password login is throttled by normalized global login identity and by
|
Interactive password login is throttled by normalized global login identity and by
|
||||||
the directly connected client address. The deployment defaults are 10 identity
|
the directly connected client address. The deployment defaults are 10 identity
|
||||||
failures and 100 client failures in a 15-minute window. Counters use
|
failures and 100 client failures in a 15-minute window. Counters use
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ contracts.
|
|||||||
- tenant owner provisioning and default access bootstrap
|
- tenant owner provisioning and default access bootstrap
|
||||||
- materializing governance templates into access-owned groups and roles
|
- materializing governance templates into access-owned groups and roles
|
||||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||||
|
- the `tenancy.erasure_provider.access` contribution, which previews and
|
||||||
|
idempotently removes only target-tenant credentials and authorization rows
|
||||||
|
while retaining global accounts and identities shared with other tenants
|
||||||
|
|
||||||
The active access tables use the `access_*` namespace while the model classes
|
The active access tables use the `access_*` namespace while the model classes
|
||||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||||
@@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the
|
|||||||
core-owned `core_scopes` table as the scope table, but it must not import
|
core-owned `core_scopes` table as the scope table, but it must not import
|
||||||
`govoplan_tenancy` or require the tenancy package to start.
|
`govoplan_tenancy` or require the tenancy package to start.
|
||||||
|
|
||||||
|
## Tenant-Erasure Boundary
|
||||||
|
|
||||||
|
Access implements the Core tenant-erasure provider contract without importing
|
||||||
|
Tenancy. Its preview counts every Access table with a tenant boundary. The
|
||||||
|
first destructive step removes target-tenant sessions and API keys; the second
|
||||||
|
removes service accounts, memberships, groups, tenant roles, organization
|
||||||
|
units, functions, assignments, and delegations in dependency-safe order.
|
||||||
|
Both steps are database-transactional and idempotent, so reconciliation can
|
||||||
|
repeat them after an interrupted response.
|
||||||
|
|
||||||
|
Global accounts, system-role assignments, identities, and identity-account
|
||||||
|
links are intentionally retained: they are installation-wide facts and may be
|
||||||
|
used by another tenant. Provider previews and receipts contain counts and
|
||||||
|
stable references only, never password hashes, session tokens, API-key hashes,
|
||||||
|
email addresses, or other credential material.
|
||||||
|
|
||||||
## Core-Only Startup Contract
|
## Core-Only Startup Contract
|
||||||
|
|
||||||
A core-only installation must be able to start far enough to expose process
|
A core-only installation must be able to start far enough to expose process
|
||||||
@@ -146,8 +165,11 @@ The backend foundation exposes these administration routes:
|
|||||||
- `/api/v1/admin/function-assignments`
|
- `/api/v1/admin/function-assignments`
|
||||||
- `/api/v1/admin/function-delegations`
|
- `/api/v1/admin/function-delegations`
|
||||||
|
|
||||||
Dedicated WebUI management panels and explicit acting-in-place context
|
Dedicated WebUI management panels remain follow-up work on top of these routes.
|
||||||
selection are still follow-up work on top of these routes.
|
Interactive acting-in-place selection is available through
|
||||||
|
`/api/v1/auth/acting-contexts` and `/api/v1/auth/switch-acting-context`; it
|
||||||
|
persists the exact selected assignment and represented account on the session,
|
||||||
|
audits each switch, and fails closed when the assignment is no longer effective.
|
||||||
|
|
||||||
## Removed Compatibility Paths
|
## Removed Compatibility Paths
|
||||||
|
|
||||||
@@ -177,7 +199,12 @@ admin routers as base routers.
|
|||||||
|
|
||||||
Governance-template metadata CRUD is not access-owned. It is contributed by
|
Governance-template metadata CRUD is not access-owned. It is contributed by
|
||||||
`govoplan-admin`; access only materializes those templates into access-owned
|
`govoplan-admin`; access only materializes those templates into access-owned
|
||||||
groups and roles through the `access.governanceMaterializer` capability.
|
groups and roles. The compatibility `access.governanceMaterializer` capability
|
||||||
|
remains available for single-assignment callers. New Admin orchestration uses
|
||||||
|
`access.governanceProjection.v1`: a bounded request of stable template and
|
||||||
|
assignment DTOs that bulk-loads managed rows and assignment blockers, applies
|
||||||
|
idempotent create/update/remove reconciliation, and returns one provenance-rich
|
||||||
|
outcome per assignment. Admin never imports Access ORM models.
|
||||||
|
|
||||||
The configuration-package Admin routes remain in Access as a compatibility
|
The configuration-package Admin routes remain in Access as a compatibility
|
||||||
surface. Their preflight context is assembled from the active Core registry,
|
surface. Their preflight context is assembled from the active Core registry,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Authentication cache boundary hardening
|
||||||
|
|
||||||
|
The authentication cache is an optimization, never an additional authentication
|
||||||
|
method or permission source. The September 2026 review found and reproduced
|
||||||
|
three violations of that boundary in isolated SQLite tests.
|
||||||
|
|
||||||
|
| Finding | Consequence | Resolution |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Service-account credentials passed through ordinary principal-summary refresh | A narrowed service-account ceiling could be replaced by backing membership roles; the service-account identifier and authentication method were lost. | Dedicated service-account resolution retains its provenance and checks the current ceiling/lifecycle on every request. It does not enter the interactive principal-summary cache. |
|
||||||
|
| A warmed API-key summary accepted the same secret through a session cookie | The warm path accepted a cookie-authenticated mutation without the CSRF rule applied to real browser sessions; the cold path rejected the credential. | API keys require an explicit Bearer or X-API-Key header on both paths. Session cookies still require matching CSRF cookie/header/hash for mutations. |
|
||||||
|
| Tenant-key intersection excluded only the historical `system:` spelling | Module-native system permissions and retained module wildcards could survive the tenant-only intersection. | Resolve wildcard grants to concrete registered tenant permissions and exclude system permissions by catalogue and compatibility aliases. |
|
||||||
|
|
||||||
|
No existing secrets, sessions, assignments, or database schema are changed.
|
||||||
|
Normal header-authenticated API keys and concrete tenant aliases remain
|
||||||
|
compatible. Clients relying on API keys in browser cookies, implicit unknown
|
||||||
|
wildcards, or accidental instance-level rights must correct their authentication
|
||||||
|
method or permission configuration; these are not preserved as compatibility
|
||||||
|
exceptions. Stored grants are not rewritten. Service-account access continues
|
||||||
|
to narrow immediately when its ceiling is reduced or its lifecycle blocks use.
|
||||||
|
|
||||||
|
Regression coverage is in `tests/test_auth_cache_security.py` and
|
||||||
|
`tests/test_permission_catalog_contract.py`; the tests exercise the full
|
||||||
|
credential resolver with principal caching enabled, not only the lower-level
|
||||||
|
API-key lookup. They also verify that valid session CSRF and concrete legacy
|
||||||
|
tenant aliases still work.
|
||||||
|
|
||||||
|
## Remaining coordinated password-change workflow
|
||||||
|
|
||||||
|
`Account.password_reset_required` is currently advisory metadata, not an
|
||||||
|
enforced sign-in restriction. The administrator UI states this limitation,
|
||||||
|
but the authentication-fields documentation previously claimed mandatory
|
||||||
|
replacement; its English and German text now reflects the implementation.
|
||||||
|
A generated password is disclosed once but is not a single-use login secret.
|
||||||
|
|
||||||
|
A follow-up must deliver the password-change endpoint, current-password
|
||||||
|
verification and replacement policy, CSRF and attempt limits, session
|
||||||
|
revocation/rotation and cache invalidation, a restricted reset-required
|
||||||
|
principal, and the corresponding accessible UI/recovery path together.
|
||||||
|
Enabling only a rejection gate would lock affected accounts out without any
|
||||||
|
supported way to finish the change. This review does not enable such a gate or
|
||||||
|
change existing passwords.
|
||||||
|
|
||||||
|
## Betriebshinweise
|
||||||
|
|
||||||
|
API-Schlüssel werden ausschließlich über `Authorization: Bearer` oder
|
||||||
|
`X-API-Key` gesendet, nicht über das Sitzungscookie. Ändernde Cookie-Anfragen
|
||||||
|
benötigen weiterhin einen passenden CSRF-Header samt Cookie und serverseitigem
|
||||||
|
Prüfwert. Dienstkonten behalten ihre eigene Herkunft und werden bei jeder
|
||||||
|
Anfrage gegen den aktuellen Berechtigungsrahmen und Lebenszyklus geprüft.
|
||||||
|
Mitgliedschaftsrollen oder zwischengespeicherte interaktive Rechte dürfen diesen
|
||||||
|
Rahmen nicht ersetzen.
|
||||||
|
|
||||||
|
Mandantenschlüssel erhalten keine instanzweiten Rechte, auch nicht unter
|
||||||
|
modulbezogenen Berechtigungsnamen. Platzhalter werden in konkrete registrierte
|
||||||
|
Mandantenrechte aufgelöst. Bestehende konkrete Mandantenrechte und ihre
|
||||||
|
Kompatibilitätsnamen bleiben erhalten; gespeicherte Geheimnisse und
|
||||||
|
Rollenzuweisungen werden nicht geändert.
|
||||||
|
|
||||||
|
Das Kennzeichen `password_reset_required` erzwingt derzeit keinen
|
||||||
|
Passwortwechsel. Ein einmal angezeigtes Anfangspasswort bleibt zur Anmeldung
|
||||||
|
verwendbar. Die Nachfolgeumsetzung muss Passwortänderung, eng begrenzten
|
||||||
|
Zwischenzugriff, Sitzungswechsel beziehungsweise Widerruf und eine bedienbare
|
||||||
|
Wiederherstellung gemeinsam liefern; eine alleinige Zugriffssperre würde
|
||||||
|
betroffene Konten ohne durchführbaren Passwortwechsel aussperren.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# External function mapping schema repair
|
||||||
|
|
||||||
|
Access owns `access_external_function_role_assignments`. Some older databases
|
||||||
|
record the Access baseline (`4a5b6c7d8e9f`) without this table. The mapping list
|
||||||
|
and `/api/v1/admin/external-function-role-mappings/delta` then fail with an
|
||||||
|
undefined-table error. This is a schema/history mismatch, not a reason to change
|
||||||
|
user permissions or recreate tenant data.
|
||||||
|
|
||||||
|
Forward repair revision `d8f1b4e7a0c3` follows Access `c7e0a3d6f9b2` on the
|
||||||
|
release track and `b6d9f2a5c8e1` on the disposable-development track. The latter
|
||||||
|
also requires Core's existing scope-table rename `4f2a9c8e7b6d`; this is a Core
|
||||||
|
contract and does not require the optional Tenancy or Organizations modules.
|
||||||
|
|
||||||
|
Before applying deployment migrations, back up and verify the database backup.
|
||||||
|
Use the configured migration track and ordinary deployment migration workflow,
|
||||||
|
including its deployment-wide advisory lock. Inspect the pending revision plan
|
||||||
|
before any targeted repair. Never replay or stamp the baseline, initialize dev
|
||||||
|
data, reset the database, or switch migration tracks to bypass the error.
|
||||||
|
|
||||||
|
The development launcher can run pending migrations when its file watcher
|
||||||
|
reloads the backend. Prepare and test a migration outside the watched source
|
||||||
|
tree, and complete the backup/preflight before placing a new migration file in
|
||||||
|
that tree. Do not assume that waiting to invoke a migration command prevents a
|
||||||
|
running development instance from applying it automatically.
|
||||||
|
|
||||||
|
The repair:
|
||||||
|
|
||||||
|
- Creates the absent mapping table only, with its baseline columns, role/scope
|
||||||
|
cascade foreign keys, primary key, tenant/source/function/role uniqueness,
|
||||||
|
and four lookup indexes.
|
||||||
|
- Does nothing if the table already exists. It does not alter partial tables;
|
||||||
|
any other schema mismatch needs separate inspection.
|
||||||
|
- Never invents mappings or changes roles, memberships, permissions, or other
|
||||||
|
application records. An empty list means no mappings have been configured.
|
||||||
|
- Keeps the table and any stored mappings on downgrade, because the table
|
||||||
|
belongs to the baseline and removing it would delete authorization policy.
|
||||||
|
|
||||||
|
After migration, verify both mapping list endpoints return success for an
|
||||||
|
authorized user in the active tenant, and check the table's constraints and
|
||||||
|
indexes. Existing read scopes and tenant isolation remain enforced. A missing
|
||||||
|
table cannot reveal whether historical mappings were once removed: this repair
|
||||||
|
does not reconstruct lost policy; investigate backups if mappings were expected.
|
||||||
|
|
||||||
|
Regression coverage in `tests/test_external_function_mapping_migration.py`
|
||||||
|
recreates the observed missing-table failure in isolated databases on both
|
||||||
|
tracks. It checks the HTTP list/delta responses, repeated upgrades, no-op
|
||||||
|
upgrades with existing mappings, downgrade/re-upgrade preservation, unchanged
|
||||||
|
parent rows/permissions, constraints, denied unprivileged reads, and tenant
|
||||||
|
isolation.
|
||||||
|
|
||||||
|
## Deutsch
|
||||||
|
|
||||||
|
Bei älteren Datenbanken kann die Access-Basismigration als angewendet vermerkt
|
||||||
|
sein, obwohl `access_external_function_role_assignments` fehlt. Die Liste der
|
||||||
|
Funktions-Rollenzuordnungen und ihre Delta-API melden dann einen internen Fehler.
|
||||||
|
Dies ist ein Widerspruch zwischen Schema und Migrationsstand, kein Anlass zur
|
||||||
|
Erweiterung von Berechtigungen oder zum Neuerstellen von Mandantendaten.
|
||||||
|
|
||||||
|
Vor der regulären, vorwärtsgerichteten Migration `d8f1b4e7a0c3` eine überprüfte
|
||||||
|
Datenbanksicherung erstellen. Den konfigurierten Migrationstrack und den
|
||||||
|
regulären Bereitstellungsablauf mit installationsweiter Migrationssperre nutzen;
|
||||||
|
bei einer gezielten Reparatur zuvor die ausstehenden Revisionen prüfen.
|
||||||
|
Basismigrationen nicht erneut ausführen oder lediglich als angewendet markieren,
|
||||||
|
keine Entwicklungsdaten initialisieren und die Datenbank nicht zurücksetzen.
|
||||||
|
Der Entwicklungsstarter kann ausstehende Migrationen bereits beim automatischen
|
||||||
|
Neuladen des Backends anwenden. Neue Migrationsdateien deshalb außerhalb des
|
||||||
|
überwachten Quellbaums vorbereiten und testen; Sicherung und Vorprüfung vor dem
|
||||||
|
Kopieren in den überwachten Quellbaum abschließen. Das Warten mit einem manuellen
|
||||||
|
Migrationsaufruf verhindert die automatische Anwendung nicht.
|
||||||
|
|
||||||
|
Die Reparatur erstellt nur die fehlende Tabelle einschließlich Fremdschlüsseln,
|
||||||
|
Eindeutigkeitsbedingung und Indizes. Vorhandene Tabellen und Datensätze bleiben
|
||||||
|
unverändert; auch ein Downgrade entfernt keine Zuordnungsdaten. Teilweise
|
||||||
|
vorhandene Tabellen werden nicht umgebaut und erfordern eine gesonderte Prüfung.
|
||||||
|
Es entstehen keine automatischen Zuordnungen oder neuen Rechte. Anschließend
|
||||||
|
beide Listenendpunkte im aktiven Mandanten mit einer berechtigten Person prüfen.
|
||||||
|
Eine leere Liste bedeutet, dass keine Zuordnungen konfiguriert sind. Falls früher
|
||||||
|
Zuordnungen erwartet wurden, Sicherungen prüfen: Verlorene Berechtigungsregeln
|
||||||
|
lassen sich aus einer fehlenden Tabelle nicht rekonstruieren.
|
||||||
@@ -161,6 +161,10 @@ Implemented backend foundation:
|
|||||||
delegation identifiers when those facts exist.
|
delegation identifiers when those facts exist.
|
||||||
- The access manifest registers `access.semanticDirectory` and
|
- The access manifest registers `access.semanticDirectory` and
|
||||||
`access.explanation` capabilities.
|
`access.explanation` capabilities.
|
||||||
|
- Interactive sessions can list `/api/v1/auth/acting-contexts` and explicitly
|
||||||
|
select or clear one with `/api/v1/auth/switch-acting-context`. Every switch is
|
||||||
|
audited. API keys cannot select an acting context, and a stale, expired,
|
||||||
|
revoked, or account-mismatched assignment fails closed.
|
||||||
|
|
||||||
Remaining rollout:
|
Remaining rollout:
|
||||||
|
|
||||||
@@ -169,9 +173,6 @@ Remaining rollout:
|
|||||||
projection until migration is complete.
|
projection until migration is complete.
|
||||||
2. Add dedicated WebUI management panels for identities, organization units,
|
2. Add dedicated WebUI management panels for identities, organization units,
|
||||||
functions, assignments, and delegations.
|
functions, assignments, and delegations.
|
||||||
3. Add explicit acting-in-place context selection; `act_in_place` delegation
|
3. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
||||||
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
|
organization, and access explanation capabilities rather than local access
|
||||||
assumptions.
|
assumptions.
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ through declared capabilities or metadata.
|
|||||||
|
|
||||||
## Interaction evidence
|
## Interaction evidence
|
||||||
|
|
||||||
- `AdminPageLayout`, `TreeSubnav`, `DataGrid`, `Dialog`, `ConfirmDialog`,
|
- `WorkspaceLayout`, headerless `PageLayout`, `AdminPageLayout`, `TreeSubnav`,
|
||||||
|
`DataGrid`, `Dialog`, `ConfirmDialog`,
|
||||||
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
||||||
`DocumentationHelpLink` come from Core.
|
`DocumentationHelpLink` come from Core.
|
||||||
|
- The administration tree and its contributed panels now share Core-owned pane
|
||||||
|
sizing, scrolling, content inset, responsive collapse, region labels, and
|
||||||
|
contextual-help identity; Access no longer carries a raw workspace or page
|
||||||
|
frame exception.
|
||||||
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard
|
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard
|
||||||
ordering, responsive grid overflow, and alert semantics therefore inherit
|
ordering, responsive grid overflow, and alert semantics therefore inherit
|
||||||
the tested Core behavior.
|
the tested Core behavior.
|
||||||
@@ -47,7 +52,12 @@ through declared capabilities or metadata.
|
|||||||
|
|
||||||
- `access.workflow.grant-user-access` covers the user, group, and role path.
|
- `access.workflow.grant-user-access` covers the user, group, and role path.
|
||||||
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and
|
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and
|
||||||
reusable credentials.
|
their backing administration fields.
|
||||||
|
- `access.workflow.manage-api-keys` owns exact help for accountable ownership,
|
||||||
|
bounded scopes and expiry, one-time secret custody, and immediate revocation.
|
||||||
|
- `access.workflow.manage-service-account-credentials` owns exact help for the
|
||||||
|
account ceiling, activation state, credential rotation/revocation, one-time
|
||||||
|
secret custody, concurrency, and retirement consequences.
|
||||||
- `access.reference.external-function-role-mappings` explains the
|
- `access.reference.external-function-role-mappings` explains the
|
||||||
Organizations, IDM, and Access responsibility split.
|
Organizations, IDM, and Access responsibility split.
|
||||||
- Files and Mail blockers link to documentation supplied by the owning module.
|
- Files and Mail blockers link to documentation supplied by the owning module.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Password recovery: PostgreSQL concurrency verification
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
`tests/test_password_recovery_postgres.py` contains four optional real-transaction
|
||||||
|
regressions: simultaneous redemption of one recovery code, simultaneous changes
|
||||||
|
using one old password/session, competing recovery-code issuance, and issuer
|
||||||
|
password revocation during redemption. They reuse the synthetic Access HTTP
|
||||||
|
fixture with real PostgreSQL account locks and compare-and-swap updates.
|
||||||
|
|
||||||
|
Provision a **disposable test database**, preferably in a temporary PostgreSQL
|
||||||
|
cluster with only a private Unix socket. Never use a production or shared
|
||||||
|
development database. The supplied role needs permission to create and drop
|
||||||
|
schemas. The tests create a random `access_password_race_*` schema per case and
|
||||||
|
drop only that schema afterward, including on test failure. Audit emission is
|
||||||
|
mocked as in the ordinary fixture; these are not audit-sink integration tests.
|
||||||
|
|
||||||
|
From the Access checkout, using the workspace Python environment with the current
|
||||||
|
Core/Access sources, pytest, and psycopg installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOVOPLAN_ACCESS_TEST_POSTGRES_URL='postgresql+psycopg://test_role@/disposable_test?host=/absolute/private/socket&port=55439' \
|
||||||
|
../govoplan/.venv/bin/python -m pytest -q tests/test_password_recovery_postgres.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the synthetic URL with the explicitly provisioned test fixture. Without
|
||||||
|
this variable all four tests are skipped; there is no fallback to application
|
||||||
|
database settings. The tests do not provision or stop PostgreSQL: the fixture
|
||||||
|
owner must stop its temporary cluster and verify cleanup after the run. Existing
|
||||||
|
portable password-recovery tests continue to run without PostgreSQL.
|
||||||
|
|
||||||
|
## Deutsch
|
||||||
|
|
||||||
|
`tests/test_password_recovery_postgres.py` enthält vier optionale Regressionstests
|
||||||
|
mit echten Transaktionen: gleichzeitige Einlösung desselben Wiederherstellungscodes,
|
||||||
|
gleichzeitige Änderungen mit demselben alten Passwort und derselben Sitzung,
|
||||||
|
konkurrierende Code-Ausstellung sowie Passwortwechsel des ausstellenden
|
||||||
|
Administrators während einer Einlösung. Die synthetische Access-HTTP-Testumgebung
|
||||||
|
nutzt dabei echte PostgreSQL-Kontosperren und bedingte Datenbankaktualisierungen.
|
||||||
|
|
||||||
|
Nur eine **wegwerfbare Testdatenbank** verwenden, möglichst in einem temporären
|
||||||
|
PostgreSQL-Cluster mit privatem Unix-Socket. Produktionsdatenbanken und gemeinsam
|
||||||
|
genutzte Entwicklungsdatenbanken sind ausgeschlossen. Die Testrolle benötigt
|
||||||
|
Berechtigungen zum Erstellen und Löschen von Schemas. Jeder Test erstellt ein
|
||||||
|
zufälliges Schema `access_password_race_*` und entfernt ausschließlich dieses
|
||||||
|
Schema auch bei Fehlern. Die Audit-Ausgabe bleibt wie im bestehenden Testaufbau
|
||||||
|
simuliert; ein externer Audit-Dienst wird damit nicht geprüft.
|
||||||
|
|
||||||
|
Der obige Aufruf gilt aus dem Access-Checkout mit der aktuellen
|
||||||
|
Workspace-Python-Umgebung. Die Beispiel-URL muss durch die ausdrücklich
|
||||||
|
bereitgestellte Testumgebung ersetzt werden. Ohne
|
||||||
|
`GOVOPLAN_ACCESS_TEST_POSTGRES_URL` werden alle vier Tests übersprungen; es gibt
|
||||||
|
keinen Rückgriff auf die Datenbankeinstellungen der Anwendung. Die Tests starten
|
||||||
|
und stoppen PostgreSQL nicht selbst: Der Betreiber der Testumgebung muss den
|
||||||
|
temporären Cluster anschließend stoppen und die Bereinigung prüfen. Die
|
||||||
|
bestehenden portablen Tests laufen weiterhin ohne PostgreSQL.
|
||||||
@@ -39,3 +39,14 @@ deactivates the principal and revokes all active credentials.
|
|||||||
Credential list responses never contain a secret. Create and rotate responses
|
Credential list responses never contain a secret. Create and rotate responses
|
||||||
contain it once. Audit records include identifiers, prefixes, scopes, and the
|
contain it once. Audit records include identifiers, prefixes, scopes, and the
|
||||||
new service-account revision, but never the secret or its hash.
|
new service-account revision, but never the secret or its hash.
|
||||||
|
|
||||||
|
## Contextual help
|
||||||
|
|
||||||
|
F1 on the service-account page, its editors, scope controls, one-time secret,
|
||||||
|
rotation and revocation actions, activation state, or retirement confirmation
|
||||||
|
resolves to the Access-owned `access.workflow.manage-service-account-credentials`
|
||||||
|
topic. The German reference content distinguishes reversible deactivation from
|
||||||
|
retirement, explains immediate client impact, and states that secrets cannot be
|
||||||
|
recovered. Tenant API-key controls resolve separately to
|
||||||
|
`access.workflow.manage-api-keys`, because their effective authorization also
|
||||||
|
depends on the accountable human owner's current permissions.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Session And Device Management
|
||||||
|
|
||||||
|
Authenticated users can inspect their active browser sessions under **Settings
|
||||||
|
> Sessions and devices**. Each row exposes only a stable session identifier,
|
||||||
|
current-session marker, bounded user-agent label, creation time, last activity,
|
||||||
|
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
|
||||||
|
addresses, and unrelated request metadata are never returned.
|
||||||
|
|
||||||
|
Users may revoke one other session or all other active sessions. The current
|
||||||
|
session is deliberately protected by these operations; use normal logout to end
|
||||||
|
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||||
|
next request, including when a principal summary was previously cached.
|
||||||
|
|
||||||
|
The shared WebUI clears reusable API response data on explicit authentication,
|
||||||
|
account, tenant, and permission transitions, changed session/CSRF cookies, and
|
||||||
|
authentication-expiry responses. Late reads cannot repopulate caches after those
|
||||||
|
transitions or after a write finishes. `no-store` responses are not retained;
|
||||||
|
`no-cache` responses require server revalidation, with ETags retained only where
|
||||||
|
storage is allowed. Reload bypasses older cached responses. These safeguards do
|
||||||
|
not erase content already displayed by a page: reload that page to reflect
|
||||||
|
remote changes. The server remains authoritative for every permission check.
|
||||||
|
|
||||||
|
Successful interactive sign-in, including re-login, and local sign-out clear
|
||||||
|
the saved automation API key. It must not shadow the newly established cookie
|
||||||
|
session with a different principal. Explicitly applying an API key in connection
|
||||||
|
settings still selects that credential's identity and triggers a new shell
|
||||||
|
authentication check. Ordinary profile updates in API-key mode retain the key.
|
||||||
|
|
||||||
|
Tenant administrators may list sessions only for a membership in their governed
|
||||||
|
tenant and may revoke only a session belonging to that membership and tenant.
|
||||||
|
The mutation requires both the central membership-update permission and an
|
||||||
|
interactive-session password re-authorization. API-key administration and
|
||||||
|
cross-tenant session disclosure fail closed.
|
||||||
|
|
||||||
|
Audit events retain the actor, target session or account, action, and revoked
|
||||||
|
count where applicable. They do not copy client labels, network addresses, or
|
||||||
|
credentials. Expired and revoked sessions are retained according to Access data
|
||||||
|
retention and are omitted from the active-session list.
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.15"
|
version = "0.1.25"
|
||||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.15",
|
"govoplan-core>=0.1.45",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN access platform module."""
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
__version__ = "0.1.15"
|
__version__ = "0.1.25"
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ def _system_account_response_item(
|
|||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name,
|
display_name=account.display_name,
|
||||||
is_active=account.is_active,
|
is_active=account.is_active,
|
||||||
|
local_password=account.auth_provider == "local",
|
||||||
memberships=[
|
memberships=[
|
||||||
_system_membership_item(
|
_system_membership_item(
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +37,43 @@ class AdminOverviewResponse(BaseModel):
|
|||||||
capabilities: list[str] = Field(default_factory=list)
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: Literal["active", "expired", "revoked"]
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
client: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionListResponse(BaseModel):
|
||||||
|
sessions: list[AdminSessionItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionRevocationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
current_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionRevocationResponse(BaseModel):
|
||||||
|
session: AdminSessionItem
|
||||||
|
revoked: bool
|
||||||
|
|
||||||
|
|
||||||
class TenantAdminItem(BaseModel):
|
class TenantAdminItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
slug: str = Field(min_length=1, max_length=100)
|
slug: str = Field(min_length=1, max_length=100)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -81,7 +113,7 @@ class TenantCreateRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
owner_account_id: str | None = None
|
owner_account_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -105,7 +137,11 @@ class TenantSettingsItem(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
@@ -505,6 +541,21 @@ class ResourceAccessExplanationResponse(BaseModel):
|
|||||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectsResponse(BaseModel):
|
||||||
|
mode: Literal["current_user", "cross_user"]
|
||||||
|
can_select_other_users: bool
|
||||||
|
reason: str
|
||||||
|
source: str
|
||||||
|
required_scope: str | None = None
|
||||||
|
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(PagedListResponse):
|
class UserListResponse(PagedListResponse):
|
||||||
users: list[UserAdminItem]
|
users: list[UserAdminItem]
|
||||||
|
|
||||||
@@ -609,6 +660,7 @@ class SystemAccountItem(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
local_password: bool = False
|
||||||
memberships: list[dict[str, Any]] = Field(default_factory=list)
|
memberships: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
roles: list[RoleSummary] = Field(default_factory=list)
|
roles: list[RoleSummary] = Field(default_factory=list)
|
||||||
last_login_at: datetime | None = None
|
last_login_at: datetime | None = None
|
||||||
@@ -799,6 +851,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
|
|||||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
|
rollback: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationPackageExportRequest(BaseModel):
|
class ConfigurationPackageExportRequest(BaseModel):
|
||||||
@@ -814,10 +867,11 @@ class ConfigurationPackageExportResponse(BaseModel):
|
|||||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
data_requirements: 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)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsItem(BaseModel):
|
class SystemSettingsItem(BaseModel):
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
allow_tenant_custom_groups: bool = True
|
allow_tenant_custom_groups: bool = True
|
||||||
allow_tenant_custom_roles: bool = True
|
allow_tenant_custom_roles: bool = True
|
||||||
allow_tenant_api_keys: bool = True
|
allow_tenant_api_keys: bool = True
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from pydantic import BaseModel, Field, SecretStr
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import (
|
from govoplan_core.api.v1.schemas import (
|
||||||
@@ -15,6 +19,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
AuthShellResponse,
|
AuthShellResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthSessionUserInfo,
|
AuthSessionUserInfo,
|
||||||
|
EffectiveAppearanceInfo,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -30,6 +35,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
UserUiPreferences,
|
UserUiPreferences,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||||
|
from govoplan_core.core.appearance import resolve_effective_appearance
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
@@ -38,7 +44,7 @@ from govoplan_core.core.idm import (
|
|||||||
IdmDirectory,
|
IdmDirectory,
|
||||||
OrganizationFunctionAssignmentRef,
|
OrganizationFunctionAssignmentRef,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, require_scope
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||||
@@ -55,12 +61,19 @@ from govoplan_core.i18n import (
|
|||||||
user_enabled_language_codes,
|
user_enabled_language_codes,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
|
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
|
||||||
|
from govoplan_access.backend.security.password_change import (
|
||||||
|
MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, RECOVERY_MINUTES,
|
||||||
|
enforce_password_change, issue_recovery, local_password_account, locked_local_account,
|
||||||
|
password_change_required, recovery_issuer_authorized, replace_password,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
|
from govoplan_access.backend.db.models import PasswordRecovery
|
||||||
from govoplan_access.backend.security.login_throttle import (
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
LoginThrottle,
|
LoginThrottle,
|
||||||
LoginThrottleDecision,
|
LoginThrottleDecision,
|
||||||
@@ -78,8 +91,42 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
create_auth_session,
|
create_auth_session,
|
||||||
verify_auth_session_csrf,
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
SessionSummary,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
revoke_other_account_sessions,
|
||||||
|
session_summary,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
class AuthRoute(APIRoute):
|
||||||
|
def get_route_handler(self):
|
||||||
|
handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def without_secret_validation_inputs(request: Request):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except RequestValidationError as exc:
|
||||||
|
# FastAPI's default validation response echoes rejected input,
|
||||||
|
# including a password/code whose length validation failed.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
key: error[key]
|
||||||
|
for key in ("type", "loc", "msg")
|
||||||
|
if key in error
|
||||||
|
}
|
||||||
|
for error in exc.errors()
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return without_secret_validation_inputs
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"], route_class=AuthRoute)
|
||||||
|
|
||||||
|
|
||||||
class ActingContextInfo(BaseModel):
|
class ActingContextInfo(BaseModel):
|
||||||
@@ -96,6 +143,65 @@ class ActingContextListResponse(BaseModel):
|
|||||||
active_assignment_id: str | None = None
|
active_assignment_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: Literal["active", "expired", "revoked"]
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
client: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionListResponse(BaseModel):
|
||||||
|
sessions: list[AccountSessionInfo] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionRevocationResponse(BaseModel):
|
||||||
|
session: AccountSessionInfo
|
||||||
|
revoked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OtherSessionRevocationResponse(BaseModel):
|
||||||
|
revoked_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeRequest(BaseModel):
|
||||||
|
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||||
|
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecoveryIssueRequest(BaseModel):
|
||||||
|
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||||
|
identity_verified: Literal[True]
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecoveryCompleteRequest(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
recovery_code: SecretStr = Field(min_length=1, max_length=256)
|
||||||
|
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecoveryIssueResponse(BaseModel):
|
||||||
|
recovery_code: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
|
||||||
|
return AccountSessionInfo(**asdict(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_session(principal: ApiPrincipal) -> AuthSession:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session management requires an interactive browser session.",
|
||||||
|
)
|
||||||
|
return principal.auth_session
|
||||||
|
|
||||||
|
|
||||||
def _acting_assignments(
|
def _acting_assignments(
|
||||||
request: Request,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
@@ -169,7 +275,7 @@ def _cookie_samesite() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _set_auth_cookies(response: Response, created) -> None:
|
def _set_auth_cookies(response: Response, created) -> None:
|
||||||
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
|
max_age = max(0, int((ensure_aware_utc(created.model.expires_at) - utc_now()).total_seconds()))
|
||||||
common = {
|
common = {
|
||||||
"secure": settings.auth_cookie_secure,
|
"secure": settings.auth_cookie_secure,
|
||||||
"samesite": _cookie_samesite(),
|
"samesite": _cookie_samesite(),
|
||||||
@@ -209,12 +315,23 @@ def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
|
|||||||
return UserUiPreferences()
|
return UserUiPreferences()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_appearance_info(session: Session, *, tenant: Tenant, user: User) -> EffectiveAppearanceInfo:
|
||||||
|
system_item = get_system_settings(session)
|
||||||
|
decision = resolve_effective_appearance(
|
||||||
|
system_settings=system_item.settings,
|
||||||
|
tenant_settings=tenant.settings,
|
||||||
|
user_settings=user.settings,
|
||||||
|
)
|
||||||
|
return EffectiveAppearanceInfo.model_validate(decision.as_dict())
|
||||||
|
|
||||||
|
|
||||||
def _user_info(
|
def _user_info(
|
||||||
user: User,
|
user: User,
|
||||||
account: Account,
|
account: Account,
|
||||||
*,
|
*,
|
||||||
preferred_language: str | None = None,
|
preferred_language: str | None = None,
|
||||||
enabled_language_codes: list[str] | None = None,
|
enabled_language_codes: list[str] | None = None,
|
||||||
|
appearance: EffectiveAppearanceInfo | None = None,
|
||||||
) -> UserInfo:
|
) -> UserInfo:
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -224,9 +341,12 @@ def _user_info(
|
|||||||
tenant_display_name=user.display_name,
|
tenant_display_name=user.display_name,
|
||||||
is_tenant_admin=user.is_tenant_admin,
|
is_tenant_admin=user.is_tenant_admin,
|
||||||
password_reset_required=account.password_reset_required,
|
password_reset_required=account.password_reset_required,
|
||||||
|
required_auth_action="change_password" if password_change_required(account) else None,
|
||||||
|
local_password=local_password_account(account),
|
||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=enabled_language_codes or [],
|
enabled_language_codes=enabled_language_codes or [],
|
||||||
ui_preferences=_user_ui_preferences(user.settings),
|
ui_preferences=_user_ui_preferences(user.settings),
|
||||||
|
appearance=appearance or EffectiveAppearanceInfo(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -239,6 +359,8 @@ def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
|
|||||||
tenant_display_name=user.display_name,
|
tenant_display_name=user.display_name,
|
||||||
is_tenant_admin=user.is_tenant_admin,
|
is_tenant_admin=user.is_tenant_admin,
|
||||||
password_reset_required=account.password_reset_required,
|
password_reset_required=account.password_reset_required,
|
||||||
|
required_auth_action="change_password" if password_change_required(account) else None,
|
||||||
|
local_password=local_password_account(account),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -333,11 +455,13 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
|||||||
account = (
|
account = (
|
||||||
session.query(Account)
|
session.query(Account)
|
||||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
|
password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
|
||||||
password_matches = verify_password(payload.password, password_hash)
|
password_matches = verify_password(payload.password, password_hash)
|
||||||
if account is None or not account.password_hash or not password_matches:
|
if account is None or not local_password_account(account) or not account.password_hash or not password_matches:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
@@ -473,7 +597,7 @@ def _session_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
def _resolve_auth_context(request: Request, session: Session, *, allow_password_change: bool = False) -> AuthContext:
|
||||||
token, source = _extract_auth_token(request)
|
token, source = _extract_auth_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||||
@@ -489,6 +613,7 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
|||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
expected_tenant_id=api_key.tenant_id,
|
expected_tenant_id=api_key.tenant_id,
|
||||||
)
|
)
|
||||||
|
enforce_password_change(account)
|
||||||
return AuthContext(
|
return AuthContext(
|
||||||
account=account,
|
account=account,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -510,6 +635,8 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
|||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
expected_tenant_id=auth_session.tenant_id,
|
expected_tenant_id=auth_session.tenant_id,
|
||||||
)
|
)
|
||||||
|
if not allow_password_change:
|
||||||
|
enforce_password_change(account)
|
||||||
return AuthContext(
|
return AuthContext(
|
||||||
account=account,
|
account=account,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -547,7 +674,7 @@ def _shell_response(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
return AuthShellResponse(
|
return AuthShellResponse(
|
||||||
user=_user_info(user, account),
|
user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -573,7 +700,7 @@ def _shell_response(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
||||||
context = _resolve_auth_context(request, session)
|
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||||
return _session_response(
|
return _session_response(
|
||||||
account=context.account,
|
account=context.account,
|
||||||
user=context.user,
|
user=context.user,
|
||||||
@@ -585,7 +712,7 @@ def _resolve_lightweight_session(request: Request, session: Session) -> AuthSess
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
||||||
context = _resolve_auth_context(request, session)
|
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||||
if context.api_key is not None:
|
if context.api_key is not None:
|
||||||
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
||||||
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
||||||
@@ -600,6 +727,8 @@ def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse
|
|||||||
)
|
)
|
||||||
|
|
||||||
scopes = collect_user_scopes(session, context.user, include_system=True)
|
scopes = collect_user_scopes(session, context.user, include_system=True)
|
||||||
|
if password_change_required(context.account):
|
||||||
|
scopes = []
|
||||||
return _shell_response(
|
return _shell_response(
|
||||||
session,
|
session,
|
||||||
account=context.account,
|
account=context.account,
|
||||||
@@ -623,6 +752,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp
|
|||||||
context.account,
|
context.account,
|
||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=user_enabled,
|
enabled_language_codes=user_enabled,
|
||||||
|
appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user),
|
||||||
),
|
),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
@@ -737,7 +867,13 @@ def _me_response(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return MeResponse(
|
return MeResponse(
|
||||||
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
|
user=_user_info(
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
preferred_language=preferred_language,
|
||||||
|
enabled_language_codes=user_enabled,
|
||||||
|
appearance=_effective_appearance_info(session, tenant=tenant, user=user),
|
||||||
|
),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -785,8 +921,13 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
|||||||
system_roles = authorization_context.system_roles
|
system_roles = authorization_context.system_roles
|
||||||
groups = authorization_context.groups
|
groups = authorization_context.groups
|
||||||
effective_scopes = authorization_context.scopes
|
effective_scopes = authorization_context.scopes
|
||||||
|
if password_change_required(account):
|
||||||
|
effective_scopes = []
|
||||||
|
tenant_roles = []
|
||||||
|
system_roles = []
|
||||||
|
groups = []
|
||||||
maintenance_mode = saved_maintenance_mode(session)
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
|
if maintenance_mode.enabled and not scopes_grant(authorization_context.scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail=maintenance_response_detail(maintenance_mode),
|
detail=maintenance_response_detail(maintenance_mode),
|
||||||
@@ -828,6 +969,333 @@ def auth_session(request: Request, session: Session = Depends(get_session)):
|
|||||||
return _resolve_lightweight_session(request, session)
|
return _resolve_lightweight_session(request, session)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _password_operation_throttle() -> LoginThrottle:
|
||||||
|
# Sensitive re-authorization and public recovery remain bounded even if an
|
||||||
|
# operator disables ordinary login throttling for a test installation.
|
||||||
|
return build_login_throttle(
|
||||||
|
redis_url=settings.redis_url,
|
||||||
|
identity_limit=settings.auth_login_throttle_identity_limit,
|
||||||
|
client_limit=settings.auth_login_throttle_client_limit,
|
||||||
|
window_seconds=settings.auth_login_throttle_window_seconds,
|
||||||
|
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
|
||||||
|
key_prefix="govoplan:access:password:v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_password_attempt(request: Request, *, identity: str) -> None:
|
||||||
|
throttle = _password_operation_throttle()
|
||||||
|
context = {
|
||||||
|
"normalized_email": identity,
|
||||||
|
"tenant_slug": None,
|
||||||
|
"client_address": request.client.host if request.client else None,
|
||||||
|
}
|
||||||
|
decision = throttle.check(**context)
|
||||||
|
if decision.allowed:
|
||||||
|
# Reserve before verification, so simultaneous requests cannot all
|
||||||
|
# check an empty bucket and then evade the limit. Count successes too.
|
||||||
|
decision = throttle.record_failure(**context)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
429,
|
||||||
|
detail={
|
||||||
|
"code": "password_rate_limited",
|
||||||
|
"message": "Too many password attempts. Try again later.",
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(max(1, decision.retry_after_seconds))},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _password_context(request: Request, session: Session) -> AuthContext:
|
||||||
|
# Authentication may schedule a last-seen touch. Do not flush that session
|
||||||
|
# row before locking the account: password replacement takes those locks in
|
||||||
|
# account-then-session order and the reverse order could deadlock.
|
||||||
|
with session.no_autoflush:
|
||||||
|
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||||
|
_verify_profile_mutation_allowed(request, context)
|
||||||
|
context.account = locked_local_account(session, context.account.id)
|
||||||
|
# The initial auth read can precede a concurrent password change. Check
|
||||||
|
# credential lifetime and membership again after obtaining the lock.
|
||||||
|
session.refresh(context.auth_session)
|
||||||
|
session.refresh(context.user)
|
||||||
|
session.refresh(context.tenant)
|
||||||
|
current = context.auth_session
|
||||||
|
if (
|
||||||
|
current.revoked_at is not None
|
||||||
|
or ensure_aware_utc(current.expires_at) <= utc_now()
|
||||||
|
):
|
||||||
|
raise HTTPException(401, detail="Invalid session")
|
||||||
|
_active_context_or_401(
|
||||||
|
user=context.user,
|
||||||
|
account=context.account,
|
||||||
|
tenant=context.tenant,
|
||||||
|
expected_tenant_id=current.tenant_id,
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _require_recovery_enabled() -> None:
|
||||||
|
if not settings.auth_local_password_recovery_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
detail={
|
||||||
|
"code": "password_recovery_disabled",
|
||||||
|
"message": "Administrator-assisted local password recovery has not been enabled.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _password_audit(
|
||||||
|
session: Session, *, account: Account, user: User, action: str, details: dict
|
||||||
|
) -> None:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
action=action,
|
||||||
|
object_type="access_account",
|
||||||
|
object_id=account.id,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
source_module="access",
|
||||||
|
resource_type="access_account",
|
||||||
|
resource_id=account.id,
|
||||||
|
reason=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/password/policy")
|
||||||
|
def password_policy():
|
||||||
|
return {
|
||||||
|
"recovery_enabled": settings.auth_local_password_recovery_enabled,
|
||||||
|
"min_length": MIN_PASSWORD_LENGTH,
|
||||||
|
"max_length": MAX_PASSWORD_LENGTH,
|
||||||
|
"recovery_minutes": RECOVERY_MINUTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/change", response_model=LoginResponse)
|
||||||
|
def change_local_password(
|
||||||
|
payload: PasswordChangeRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
context = _password_context(request, session)
|
||||||
|
_consume_password_attempt(
|
||||||
|
request, identity="change:" + context.account.normalized_email
|
||||||
|
)
|
||||||
|
if not verify_password(
|
||||||
|
payload.current_password.get_secret_value(), context.account.password_hash
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
detail={
|
||||||
|
"code": "current_password_invalid",
|
||||||
|
"message": "Current password re-authorization failed.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
counts = replace_password(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
password=payload.new_password.get_secret_value(),
|
||||||
|
)
|
||||||
|
created = create_auth_session(
|
||||||
|
session,
|
||||||
|
user=context.user,
|
||||||
|
hours=settings.auth_session_hours,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
_password_audit(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
action="access.password.changed",
|
||||||
|
details={"authorization": "current_password", **counts},
|
||||||
|
)
|
||||||
|
result = LoginResponse(
|
||||||
|
access_token=created.token,
|
||||||
|
expires_at=created.model.expires_at,
|
||||||
|
**_me_response(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant=context.tenant,
|
||||||
|
effective_scopes=collect_user_scopes(
|
||||||
|
session, context.user, include_system=True
|
||||||
|
),
|
||||||
|
auth_method="session",
|
||||||
|
session_id=created.model.id,
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
_set_auth_cookies(response, created)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/password/recovery/{account_id}", response_model=PasswordRecoveryIssueResponse
|
||||||
|
)
|
||||||
|
def issue_password_recovery(
|
||||||
|
account_id: str,
|
||||||
|
payload: PasswordRecoveryIssueRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_recovery_enabled()
|
||||||
|
context = _password_context(request, session)
|
||||||
|
enforce_password_change(context.account)
|
||||||
|
if not recovery_issuer_authorized(
|
||||||
|
session, account_id=context.account.id, membership_id=context.user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
403, detail={"code": "recovery_issuer_required", "message": "Only a current System owner can issue password recovery codes."}
|
||||||
|
)
|
||||||
|
_consume_password_attempt(
|
||||||
|
request, identity="issue:" + context.account.normalized_email
|
||||||
|
)
|
||||||
|
if not verify_password(
|
||||||
|
payload.current_password.get_secret_value(), context.account.password_hash
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
detail={
|
||||||
|
"code": "current_password_invalid",
|
||||||
|
"message": "Current password re-authorization failed.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Global account takeover must not be granted by tenant or ordinary account
|
||||||
|
# editor permissions. Ownership is checked from current roles above.
|
||||||
|
target = locked_local_account(session, account_id)
|
||||||
|
membership = (
|
||||||
|
session.query(User)
|
||||||
|
.join(Tenant, Tenant.id == User.tenant_id)
|
||||||
|
.filter(
|
||||||
|
User.account_id == target.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
Tenant.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if membership is None:
|
||||||
|
raise HTTPException(
|
||||||
|
409, detail={"code": "recovery_membership_required", "message": "The account needs an active tenant membership before recovery."}
|
||||||
|
)
|
||||||
|
code, recovery = issue_recovery(
|
||||||
|
session, account=target, issuer=context.account, membership=context.user
|
||||||
|
)
|
||||||
|
_password_audit(
|
||||||
|
session,
|
||||||
|
account=target,
|
||||||
|
user=context.user,
|
||||||
|
action="access.password.recovery_issued",
|
||||||
|
details={
|
||||||
|
"identity_verified": True,
|
||||||
|
"expires_at": recovery.expires_at.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return PasswordRecoveryIssueResponse(
|
||||||
|
recovery_code=code, expires_at=recovery.expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/recover")
|
||||||
|
def complete_password_recovery(
|
||||||
|
payload: PasswordRecoveryCompleteRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_recovery_enabled()
|
||||||
|
normalized = normalize_email(payload.email)
|
||||||
|
_consume_password_attempt(request, identity="recover:" + normalized)
|
||||||
|
invalid = HTTPException(
|
||||||
|
400,
|
||||||
|
detail={
|
||||||
|
"code": "recovery_invalid",
|
||||||
|
"message": "The recovery code is invalid, expired, or no longer authorized.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
recovery = (
|
||||||
|
session.query(PasswordRecovery)
|
||||||
|
.filter(
|
||||||
|
PasswordRecovery.code_hash
|
||||||
|
== hash_secret(payload.recovery_code.get_secret_value())
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if recovery is None:
|
||||||
|
raise invalid
|
||||||
|
try:
|
||||||
|
account = locked_local_account(session, recovery.account_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise invalid from None
|
||||||
|
session.refresh(recovery)
|
||||||
|
if (
|
||||||
|
account.normalized_email != normalized
|
||||||
|
or recovery.consumed_at is not None
|
||||||
|
or ensure_aware_utc(recovery.expires_at) <= utc_now()
|
||||||
|
or not recovery_issuer_authorized(
|
||||||
|
session,
|
||||||
|
account_id=recovery.issuer_account_id,
|
||||||
|
membership_id=recovery.issuer_membership_id,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise invalid
|
||||||
|
user = (
|
||||||
|
session.query(User)
|
||||||
|
.join(Tenant, Tenant.id == User.tenant_id)
|
||||||
|
.filter(
|
||||||
|
User.account_id == account.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
Tenant.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise invalid
|
||||||
|
# CAS supplements the account lock on databases where FOR UPDATE is absent.
|
||||||
|
consumed = (
|
||||||
|
session.query(PasswordRecovery)
|
||||||
|
.filter(
|
||||||
|
PasswordRecovery.id == recovery.id,
|
||||||
|
PasswordRecovery.consumed_at.is_(None),
|
||||||
|
PasswordRecovery.expires_at > utc_now(),
|
||||||
|
)
|
||||||
|
.update({PasswordRecovery.consumed_at: utc_now()}, synchronize_session="fetch")
|
||||||
|
)
|
||||||
|
if consumed != 1:
|
||||||
|
raise invalid
|
||||||
|
counts = replace_password(
|
||||||
|
session, account=account, password=payload.new_password.get_secret_value()
|
||||||
|
)
|
||||||
|
_password_audit(
|
||||||
|
session,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
action="access.password.recovered",
|
||||||
|
details={
|
||||||
|
"authorization": "administrator_code",
|
||||||
|
"issuer_account_id": recovery.issuer_account_id,
|
||||||
|
**counts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
# Recovery authorizes password replacement only, never a browser session.
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shell", response_model=AuthShellResponse)
|
@router.get("/shell", response_model=AuthShellResponse)
|
||||||
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
||||||
return _resolve_shell_auth(request, session)
|
return _resolve_shell_auth(request, session)
|
||||||
@@ -945,6 +1413,37 @@ def update_profile(
|
|||||||
if payload.ui_preferences is None:
|
if payload.ui_preferences is None:
|
||||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||||
else:
|
else:
|
||||||
|
appearance = _effective_appearance_info(session, tenant=context.tenant, user=context.user)
|
||||||
|
stored_preferences = _user_ui_preferences(context.user.settings)
|
||||||
|
stored_palette = stored_preferences.palette
|
||||||
|
if (
|
||||||
|
appearance.locked
|
||||||
|
and payload.ui_preferences.palette is not None
|
||||||
|
and payload.ui_preferences.palette != stored_palette
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="The effective appearance policy locks the palette.",
|
||||||
|
)
|
||||||
|
requested_overrides = payload.ui_preferences.appearance_overrides
|
||||||
|
if (
|
||||||
|
"appearance_overrides" in payload.ui_preferences.model_fields_set
|
||||||
|
and not appearance.custom_overrides_allowed
|
||||||
|
and requested_overrides is not None
|
||||||
|
and requested_overrides != stored_preferences.appearance_overrides
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="The effective appearance policy does not allow personal custom overrides.",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
payload.ui_preferences.navigation is not None
|
||||||
|
and payload.ui_preferences.navigation.locked
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Personal navigation preferences cannot lock entries.",
|
||||||
|
)
|
||||||
next_ui = _user_ui_preferences(next_settings).model_dump()
|
next_ui = _user_ui_preferences(next_settings).model_dump()
|
||||||
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
||||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||||
@@ -1002,6 +1501,98 @@ def switch_tenant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=AccountSessionListResponse)
|
||||||
|
def list_own_sessions(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccountSessionListResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
items = list_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
current_session_id=current.id,
|
||||||
|
)
|
||||||
|
return AccountSessionListResponse(
|
||||||
|
sessions=[_account_session_info(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/revoke-others",
|
||||||
|
response_model=OtherSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_own_other_sessions(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OtherSessionRevocationResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
revoked_ids = revoke_other_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
current_session_id=current.id,
|
||||||
|
)
|
||||||
|
if revoked_ids:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="access.sessions.other_sessions_revoked",
|
||||||
|
object_type="access_account",
|
||||||
|
object_id=principal.account_id,
|
||||||
|
details={"revoked_count": len(revoked_ids)},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return OtherSessionRevocationResponse(revoked_count=len(revoked_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/{session_id}/revoke",
|
||||||
|
response_model=AccountSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_own_session(
|
||||||
|
session_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccountSessionRevocationResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
try:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
session_id=session_id,
|
||||||
|
protected_session_id=current.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Session not found.",
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="access.session.revoked",
|
||||||
|
object_type="access_auth_session",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"actor_kind": "self"},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return AccountSessionRevocationResponse(
|
||||||
|
session=_account_session_info(
|
||||||
|
session_summary(item, current_session_id=current.id)
|
||||||
|
),
|
||||||
|
revoked=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/acting-contexts", response_model=ActingContextListResponse)
|
@router.get("/acting-contexts", response_model=ActingContextListResponse)
|
||||||
def list_acting_contexts(
|
def list_acting_contexts(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1078,12 +1669,14 @@ def switch_acting_context(
|
|||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(
|
def logout(
|
||||||
response: Response,
|
response: Response,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
if principal.auth_session is not None:
|
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||||
principal.auth_session.revoked_at = utc_now()
|
if context.auth_session is not None:
|
||||||
session.add(principal.auth_session)
|
_verify_profile_mutation_allowed(request, context)
|
||||||
|
context.auth_session.revoked_at = utc_now()
|
||||||
|
session.add(context.auth_session)
|
||||||
session.commit()
|
session.commit()
|
||||||
_clear_auth_cookies(response)
|
_clear_auth_cookies(response)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.admin.governance import (
|
from govoplan_access.backend.admin.governance import (
|
||||||
@@ -62,6 +64,10 @@ from govoplan_access.backend.api.v1.admin_common import (
|
|||||||
_user_item,
|
_user_item,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
AdminSessionItem,
|
||||||
|
AdminSessionListResponse,
|
||||||
|
AdminSessionRevocationRequest,
|
||||||
|
AdminSessionRevocationResponse,
|
||||||
AdminApiKeyCreateRequest,
|
AdminApiKeyCreateRequest,
|
||||||
AdminApiKeyCreateResponse,
|
AdminApiKeyCreateResponse,
|
||||||
ApiKeyAdminItem,
|
ApiKeyAdminItem,
|
||||||
@@ -120,6 +126,8 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
RoleSummary,
|
RoleSummary,
|
||||||
RoleUpdateRequest,
|
RoleUpdateRequest,
|
||||||
ResourceAccessExplanationResponse,
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationSubjectItem,
|
||||||
|
ResourceAccessExplanationSubjectsResponse,
|
||||||
SystemAccountCreateRequest,
|
SystemAccountCreateRequest,
|
||||||
SystemAccountCreateResponse,
|
SystemAccountCreateResponse,
|
||||||
SystemAccountItem,
|
SystemAccountItem,
|
||||||
@@ -137,6 +145,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
UserUpdateRequest,
|
UserUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.security.api_keys import create_api_key
|
from govoplan_access.backend.security.api_keys import create_api_key
|
||||||
|
from govoplan_access.backend.security.passwords import verify_password
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
SessionSummary,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
session_summary,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
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_core.audit.logging import audit_event, audit_from_principal
|
||||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
||||||
@@ -152,6 +168,10 @@ from govoplan_core.core.configuration_packages import (
|
|||||||
export_configuration_package,
|
export_configuration_package,
|
||||||
validate_configuration_package_catalog,
|
validate_configuration_package_catalog,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceiptError,
|
||||||
|
load_infrastructure_capability_receipt,
|
||||||
|
)
|
||||||
from govoplan_core.core.configuration_control import (
|
from govoplan_core.core.configuration_control import (
|
||||||
CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
||||||
CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||||
@@ -169,7 +189,15 @@ from govoplan_core.core.provider_governance import (
|
|||||||
ExternalProviderStateContext,
|
ExternalProviderStateContext,
|
||||||
collect_external_provider_states,
|
collect_external_provider_states,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_EXPLANATION,
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessDecisionProvenance,
|
||||||
|
AccessExplanationService,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
AccessExplanationSubjectPolicy,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||||
@@ -656,6 +684,40 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_explanation_subject_decision(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
):
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=False,
|
||||||
|
reason="Access explanations are limited to the signed-in user because no subject policy is active.",
|
||||||
|
source="access.safe_default",
|
||||||
|
provenance={"tenant_id": tenant_id, "mode": "current_user"},
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
)
|
||||||
|
if not isinstance(capability, AccessExplanationSubjectPolicy):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(
|
||||||
|
"Invalid capability: "
|
||||||
|
f"{CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return capability.decide_subject_selection(
|
||||||
|
session,
|
||||||
|
principal.principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _idm_assignments_for_user(
|
def _idm_assignments_for_user(
|
||||||
idm_directory: IdmDirectory | None,
|
idm_directory: IdmDirectory | None,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -974,6 +1036,7 @@ def configuration_package_apply_endpoint(
|
|||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
created_refs=dict(result.created_refs),
|
created_refs=dict(result.created_refs),
|
||||||
updated_refs=dict(result.updated_refs),
|
updated_refs=dict(result.updated_refs),
|
||||||
|
rollback=result.rollback.to_dict() if result.rollback is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -994,6 +1057,7 @@ def configuration_package_export_endpoint(
|
|||||||
fragments=[item.to_dict() for item in result.fragments],
|
fragments=[item.to_dict() for item in result.fragments],
|
||||||
data_requirements=[item.to_dict() for item in result.data_requirements],
|
data_requirements=[item.to_dict() for item in result.data_requirements],
|
||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
|
provenance=result.provenance.to_dict() if result.provenance is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1023,6 +1087,12 @@ def _configuration_context(
|
|||||||
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
|
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
|
||||||
external_provider_declarations: dict[str, dict[str, object]] = {}
|
external_provider_declarations: dict[str, dict[str, object]] = {}
|
||||||
external_provider_states: dict[str, dict[str, object]] = {}
|
external_provider_states: dict[str, dict[str, object]] = {}
|
||||||
|
infrastructure_receipt = None
|
||||||
|
infrastructure_receipt_error = None
|
||||||
|
try:
|
||||||
|
infrastructure_receipt = load_infrastructure_capability_receipt()
|
||||||
|
except InfrastructureCapabilityReceiptError as exc:
|
||||||
|
infrastructure_receipt_error = str(exc)
|
||||||
if registry is not None and hasattr(registry, "manifests"):
|
if registry is not None and hasattr(registry, "manifests"):
|
||||||
manifests = registry.manifests()
|
manifests = registry.manifests()
|
||||||
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
||||||
@@ -1048,11 +1118,14 @@ def _configuration_context(
|
|||||||
return ConfigurationPreflightContext(
|
return ConfigurationPreflightContext(
|
||||||
tenant_id=tenant_id or principal.tenant_id,
|
tenant_id=tenant_id or principal.tenant_id,
|
||||||
operator_user_id=principal.user.id,
|
operator_user_id=principal.user.id,
|
||||||
|
operator_scopes=frozenset(getattr(principal, "scopes", ())),
|
||||||
supplied_data=supplied_data or {},
|
supplied_data=supplied_data or {},
|
||||||
installed_modules=installed_modules,
|
installed_modules=installed_modules,
|
||||||
capabilities=frozenset(capabilities),
|
capabilities=frozenset(capabilities),
|
||||||
external_provider_declarations=external_provider_declarations,
|
external_provider_declarations=external_provider_declarations,
|
||||||
external_provider_states=external_provider_states,
|
external_provider_states=external_provider_states,
|
||||||
|
infrastructure_receipt=infrastructure_receipt,
|
||||||
|
infrastructure_receipt_error=infrastructure_receipt_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2367,6 +2440,188 @@ def get_user_access_explanation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_session_item(item: SessionSummary) -> AdminSessionItem:
|
||||||
|
return AdminSessionItem(**asdict(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session_admin_reauthorization(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
current_password: str,
|
||||||
|
) -> None:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Administrative session revocation requires an interactive session.",
|
||||||
|
)
|
||||||
|
if not verify_password(current_password, principal.account.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Current password re-authorization failed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/{user_id}/sessions",
|
||||||
|
response_model=AdminSessionListResponse,
|
||||||
|
)
|
||||||
|
def list_user_sessions(
|
||||||
|
user_id: str,
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("admin:users:read", "access:membership:read")
|
||||||
|
),
|
||||||
|
) -> AdminSessionListResponse:
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
items = list_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=user.account_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
current_session_id=principal.session_id,
|
||||||
|
)
|
||||||
|
return AdminSessionListResponse(
|
||||||
|
sessions=[_admin_session_item(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/{user_id}/sessions/{session_id}/revoke",
|
||||||
|
response_model=AdminSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_user_session(
|
||||||
|
user_id: str,
|
||||||
|
session_id: str,
|
||||||
|
payload: AdminSessionRevocationRequest,
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("admin:users:update", "access:membership:update")
|
||||||
|
),
|
||||||
|
) -> AdminSessionRevocationResponse:
|
||||||
|
_require_session_admin_reauthorization(principal, payload.current_password)
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
session,
|
||||||
|
account_id=user.account_id,
|
||||||
|
session_id=session_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
protected_session_id=principal.session_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Session not found",
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="access.session.revoked_by_administrator",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="access_auth_session",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"target_membership_id": user.id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return AdminSessionRevocationResponse(
|
||||||
|
session=_admin_session_item(
|
||||||
|
session_summary(item, current_session_id=principal.session_id)
|
||||||
|
),
|
||||||
|
revoked=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/access/resource-explanation/subjects",
|
||||||
|
response_model=ResourceAccessExplanationSubjectsResponse,
|
||||||
|
)
|
||||||
|
def get_resource_access_explanation_subjects(
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
query: str | None = Query(default=None, max_length=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:roles:read",
|
||||||
|
"access:membership:read",
|
||||||
|
"access:role:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) -> ResourceAccessExplanationSubjectsResponse:
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
users_query = session.query(User).filter(
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
if not decision.allow_other_users:
|
||||||
|
users_query = users_query.filter(User.id == principal.membership_id)
|
||||||
|
elif query and query.strip():
|
||||||
|
pattern = f"%{query.strip()}%"
|
||||||
|
users_query = users_query.filter(
|
||||||
|
or_(User.display_name.ilike(pattern), User.email.ilike(pattern))
|
||||||
|
)
|
||||||
|
users = users_query.order_by(User.display_name.asc(), User.email.asc()).limit(100).all()
|
||||||
|
if decision.allow_other_users and not query:
|
||||||
|
current_user = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(
|
||||||
|
User.id == principal.membership_id,
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if current_user is not None and all(user.id != current_user.id for user in users):
|
||||||
|
users = [current_user, *users[:99]]
|
||||||
|
return ResourceAccessExplanationSubjectsResponse(
|
||||||
|
mode="cross_user" if decision.allow_other_users else "current_user",
|
||||||
|
can_select_other_users=decision.allow_other_users,
|
||||||
|
reason=decision.reason,
|
||||||
|
source=decision.source,
|
||||||
|
required_scope=decision.required_scope,
|
||||||
|
users=[
|
||||||
|
ResourceAccessExplanationSubjectItem(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
||||||
def get_resource_access_explanation(
|
def get_resource_access_explanation(
|
||||||
user_id: str = Query(...),
|
user_id: str = Query(...),
|
||||||
@@ -2378,6 +2633,16 @@ def get_resource_access_explanation(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
if user_id != principal.membership_id and not decision.allow_other_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=decision.reason,
|
||||||
|
)
|
||||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
@@ -2397,6 +2662,21 @@ def get_resource_access_explanation(
|
|||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
if user_id != principal.membership_id:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="access.resource_explanation.selected_user_viewed",
|
||||||
|
scope="tenant",
|
||||||
|
object_type=resource_type,
|
||||||
|
object_id=resource_id,
|
||||||
|
details={
|
||||||
|
"target_membership_id": user.id,
|
||||||
|
"requested_action": action,
|
||||||
|
"policy_source": decision.source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
return ResourceAccessExplanationResponse(
|
return ResourceAccessExplanationResponse(
|
||||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from govoplan_access.backend.semantic import collect_external_function_roles, id
|
|||||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
from govoplan_access.backend.auth.tokens import hash_secret
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
|
from govoplan_access.backend.security.password_change import enforce_password_change, password_change_required
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
collect_user_authorization_context,
|
collect_user_authorization_context,
|
||||||
@@ -159,6 +160,7 @@ def _api_principal_from_ref(
|
|||||||
|
|
||||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||||
|
enforce_password_change(account)
|
||||||
return ApiPrincipal(
|
return ApiPrincipal(
|
||||||
principal=principal,
|
principal=principal,
|
||||||
account=account,
|
account=account,
|
||||||
@@ -226,6 +228,7 @@ def _resolve_legacy_principal_context(
|
|||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
|
enforce_password_change(cached.account)
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
if source != "cookie":
|
if source != "cookie":
|
||||||
@@ -237,6 +240,7 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
|
enforce_password_change(context.account)
|
||||||
return _cache_resolved_principal_context(
|
return _cache_resolved_principal_context(
|
||||||
session,
|
session,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -256,6 +260,7 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
|
enforce_password_change(context.account)
|
||||||
return _cache_resolved_principal_context(
|
return _cache_resolved_principal_context(
|
||||||
session,
|
session,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -311,6 +316,12 @@ def _rehydrate_cached_principal(
|
|||||||
source: str,
|
source: str,
|
||||||
principal: PrincipalRef,
|
principal: PrincipalRef,
|
||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
# A cache hit must retain the same credential-source rules as a cold read.
|
||||||
|
# API keys are explicit-header credentials, never browser session cookies.
|
||||||
|
if principal.auth_method not in {"session", "api_key"} or (
|
||||||
|
principal.auth_method == "api_key" and source == "cookie"
|
||||||
|
):
|
||||||
|
return None
|
||||||
account = session.get(Account, principal.account_id)
|
account = session.get(Account, principal.account_id)
|
||||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||||
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||||
@@ -327,6 +338,10 @@ def _rehydrate_cached_principal(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if principal.auth_method == "api_key":
|
if principal.auth_method == "api_key":
|
||||||
|
# Service accounts have a separate current scope ceiling and lifecycle.
|
||||||
|
# Do not reuse an ordinary API-key summary for their backing identities.
|
||||||
|
if account.auth_provider == "service_account" or user.auth_provider == "service_account":
|
||||||
|
return None
|
||||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||||
if (
|
if (
|
||||||
api_key is None
|
api_key is None
|
||||||
@@ -408,7 +423,10 @@ def _cache_resolved_principal_context(
|
|||||||
identity_directory: IdentityDirectory | None,
|
identity_directory: IdentityDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
) -> ResolvedPrincipalContext:
|
) -> ResolvedPrincipalContext:
|
||||||
if not settings.auth_principal_cache_enabled:
|
if not settings.auth_principal_cache_enabled or context.principal.auth_method not in {"session", "api_key"}:
|
||||||
|
# In particular, service-account credentials must keep their dedicated
|
||||||
|
# provenance and be intersected with the current service-account ceiling
|
||||||
|
# on every request, not recomputed from interactive membership roles.
|
||||||
return context
|
return context
|
||||||
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
refreshed = _refresh_principal_context(
|
refreshed = _refresh_principal_context(
|
||||||
@@ -991,6 +1009,12 @@ def _resolve_delegated_user_automation(
|
|||||||
"belongs to the tenant."
|
"belongs to the tenant."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if password_change_required(account):
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="password_change_required",
|
||||||
|
reason="The automation owner must complete the required local password change.",
|
||||||
|
)
|
||||||
idm_assignments, idm_roles = _principal_idm_context(
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
session,
|
session,
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ class Account(AccessBase, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecovery(AccessBase, TimestampMixin):
|
||||||
|
"""Bounded, administrator-issued authorization; never retain the code."""
|
||||||
|
|
||||||
|
__tablename__ = "access_password_recoveries"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
account_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
issuer_account_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
issuer_membership_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
class Identity(AccessBase, TimestampMixin):
|
class Identity(AccessBase, TimestampMixin):
|
||||||
__tablename__ = "access_identities"
|
__tablename__ = "access_identities"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Function,
|
||||||
|
FunctionAssignment,
|
||||||
|
Group,
|
||||||
|
Identity,
|
||||||
|
IdentityAccountLink,
|
||||||
|
OrganizationUnit,
|
||||||
|
Role,
|
||||||
|
SystemRoleAssignment,
|
||||||
|
User,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_DSAR_CAPABILITY = "privacy.dsar.access"
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDsarProvider:
|
||||||
|
provider_id = "access"
|
||||||
|
module_id = "access"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
users = _subject_users(db, tenant_id=tenant_id, subject=subject)
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
key = (record.resource_type, record.resource_id)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"membership",
|
||||||
|
user.id,
|
||||||
|
"profile",
|
||||||
|
user.display_name or user.email,
|
||||||
|
{
|
||||||
|
"account_id": user.account_id,
|
||||||
|
"email": user.email,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"auth_provider": user.auth_provider,
|
||||||
|
"last_login_at": _iso(user.last_login_at),
|
||||||
|
"created_at": _iso(user.created_at),
|
||||||
|
"updated_at": _iso(user.updated_at),
|
||||||
|
},
|
||||||
|
observed_at=user.updated_at,
|
||||||
|
source_path=f"/admin?section=tenant-users&user={user.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = db.get(Account, user.account_id)
|
||||||
|
if account is not None:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"account",
|
||||||
|
account.id,
|
||||||
|
"global_identity",
|
||||||
|
account.display_name or account.email,
|
||||||
|
{
|
||||||
|
"email": account.email,
|
||||||
|
"display_name": account.display_name,
|
||||||
|
"is_active": account.is_active,
|
||||||
|
"auth_provider": account.auth_provider,
|
||||||
|
"last_login_at": _iso(account.last_login_at),
|
||||||
|
"created_at": _iso(account.created_at),
|
||||||
|
},
|
||||||
|
observed_at=account.updated_at,
|
||||||
|
source_path=f"/admin?section=system-users&account={account.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_append_identity_records(db, append, account)
|
||||||
|
_append_system_role_records(db, append, account)
|
||||||
|
_append_api_key_records(db, append, user)
|
||||||
|
_append_session_records(db, append, user)
|
||||||
|
_append_group_records(db, append, user)
|
||||||
|
_append_role_records(db, append, user)
|
||||||
|
_append_function_records(db, append, user)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, subject
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
if record.resource_type == "membership":
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:anonymize:membership:{record.resource_id}",
|
||||||
|
"anonymize",
|
||||||
|
record,
|
||||||
|
"Anonymize and deactivate the tenant membership",
|
||||||
|
"Tenant-local profile data can be removed without deleting stable evidence identifiers.",
|
||||||
|
executable=True,
|
||||||
|
irreversible=True,
|
||||||
|
metadata={"tenant_id": tenant_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type == "api_key" and record.data.get("active"):
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:revoke:api-key:{record.resource_id}",
|
||||||
|
"revoke",
|
||||||
|
record,
|
||||||
|
"Revoke API key",
|
||||||
|
"An active credential associated with the data subject must no longer authenticate.",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type == "auth_session" and record.data.get("active"):
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:revoke:session:{record.resource_id}",
|
||||||
|
"revoke",
|
||||||
|
record,
|
||||||
|
"Revoke login session",
|
||||||
|
"An active session associated with the data subject must no longer authenticate.",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type in {"account", "identity"}:
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:review:{record.resource_type}:{record.resource_id}",
|
||||||
|
"manual_review",
|
||||||
|
record,
|
||||||
|
f"Review global {record.resource_type}",
|
||||||
|
"Global identities may serve other tenants or legal obligations and require a system-level decision.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del subject
|
||||||
|
db = _session(session)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
if action.action_id.startswith("access:anonymize:membership:"):
|
||||||
|
row = db.get(User, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "Tenant membership is no longer available."))
|
||||||
|
continue
|
||||||
|
replacement = _erased_email(tenant_id, row.id)
|
||||||
|
unchanged = (
|
||||||
|
row.email == replacement
|
||||||
|
and row.display_name == "Erased data subject"
|
||||||
|
and not row.is_active
|
||||||
|
)
|
||||||
|
row.email = replacement
|
||||||
|
row.display_name = "Erased data subject"
|
||||||
|
row.is_active = False
|
||||||
|
row.is_tenant_admin = False
|
||||||
|
row.password_hash = None
|
||||||
|
row.last_login_at = None
|
||||||
|
row.settings = {}
|
||||||
|
row.mail_profile_policy = {}
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"Tenant membership was already anonymized."
|
||||||
|
if unchanged
|
||||||
|
else "Tenant membership was anonymized and deactivated.",
|
||||||
|
{"request_id": request_id, "replacement_email": replacement},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action.action_id.startswith("access:revoke:api-key:"):
|
||||||
|
row = db.get(ApiKey, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "API key is no longer available."))
|
||||||
|
continue
|
||||||
|
unchanged = row.revoked_at is not None
|
||||||
|
if row.revoked_at is None:
|
||||||
|
row.revoked_at = now
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"API key was already revoked." if unchanged else "API key was revoked.",
|
||||||
|
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action.action_id.startswith("access:revoke:session:"):
|
||||||
|
row = db.get(AuthSession, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "Login session is no longer available."))
|
||||||
|
continue
|
||||||
|
unchanged = row.revoked_at is not None and not row.ip_address and not row.user_agent
|
||||||
|
if row.revoked_at is None:
|
||||||
|
row.revoked_at = now
|
||||||
|
row.ip_address = None
|
||||||
|
row.user_agent = None
|
||||||
|
row.csrf_token_hash = None
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"Login session was already revoked and redacted."
|
||||||
|
if unchanged
|
||||||
|
else "Login session was revoked and client metadata was redacted.",
|
||||||
|
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results.append(_blocked(action, "Access does not execute this action kind."))
|
||||||
|
db.flush()
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_users(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> tuple[User, ...]:
|
||||||
|
candidate_sets: list[set[str]] = []
|
||||||
|
if subject.membership_id:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.id == subject.membership_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.account_id:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id == subject.account_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.identity_id:
|
||||||
|
account_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(IdentityAccountLink.account_id).filter(
|
||||||
|
IdentityAccountLink.identity_id == subject.identity_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
candidate_sets.append(
|
||||||
|
{
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id.in_(account_ids),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if account_ids
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
for key, value in subject.external_references.items():
|
||||||
|
if key in {"access.account", "account_id"}:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id == value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
elif key in {"access.membership", "membership_id"}:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.id == value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.email:
|
||||||
|
normalized = subject.email.strip().casefold()
|
||||||
|
matching_accounts = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(Account.id).filter(
|
||||||
|
Account.normalized_email == normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
email_matches = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
func.lower(User.email) == normalized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if matching_accounts:
|
||||||
|
email_matches.update(
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id.in_(matching_accounts),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidate_sets.append(email_matches)
|
||||||
|
if not candidate_sets:
|
||||||
|
return ()
|
||||||
|
user_ids = set.intersection(*candidate_sets)
|
||||||
|
if not user_ids:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
session.query(User)
|
||||||
|
.filter(User.tenant_id == tenant_id, User.id.in_(user_ids))
|
||||||
|
.order_by(User.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_identity_records(session: Session, append: object, account: Account) -> None:
|
||||||
|
for link, identity in (
|
||||||
|
session.query(IdentityAccountLink, Identity)
|
||||||
|
.join(Identity, Identity.id == IdentityAccountLink.identity_id)
|
||||||
|
.filter(IdentityAccountLink.account_id == account.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"identity",
|
||||||
|
identity.id,
|
||||||
|
"global_identity",
|
||||||
|
identity.display_name or identity.id,
|
||||||
|
{
|
||||||
|
"display_name": identity.display_name,
|
||||||
|
"external_subject": identity.external_subject,
|
||||||
|
"source": identity.source,
|
||||||
|
"is_active": identity.is_active,
|
||||||
|
"is_primary_link": link.is_primary,
|
||||||
|
},
|
||||||
|
observed_at=identity.updated_at,
|
||||||
|
source_path=f"/admin?section=system-users&identity={identity.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_system_role_records(session: Session, append: object, account: Account) -> None:
|
||||||
|
for assignment, role in (
|
||||||
|
session.query(SystemRoleAssignment, Role)
|
||||||
|
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||||
|
.filter(SystemRoleAssignment.account_id == account.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"system_role_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"System role: {role.name}",
|
||||||
|
{"role_id": role.id, "role_name": role.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="System authorization history is institutional evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_api_key_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for item in session.query(ApiKey).filter(ApiKey.user_id == user.id).all():
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"api_key",
|
||||||
|
item.id,
|
||||||
|
"credential",
|
||||||
|
item.name,
|
||||||
|
{
|
||||||
|
"prefix": item.prefix,
|
||||||
|
"scopes": list(item.scopes or ()),
|
||||||
|
"active": item.revoked_at is None,
|
||||||
|
"expires_at": _iso(item.expires_at),
|
||||||
|
"last_used_at": _iso(item.last_used_at),
|
||||||
|
"revoked_at": _iso(item.revoked_at),
|
||||||
|
},
|
||||||
|
observed_at=item.updated_at,
|
||||||
|
source_path="/admin?section=tenant-api-keys",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_session_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for item in session.query(AuthSession).filter(AuthSession.user_id == user.id).all():
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"auth_session",
|
||||||
|
item.id,
|
||||||
|
"authentication",
|
||||||
|
f"Login session {item.id[:8]}",
|
||||||
|
{
|
||||||
|
"active": item.revoked_at is None,
|
||||||
|
"expires_at": _iso(item.expires_at),
|
||||||
|
"last_seen_at": _iso(item.last_seen_at),
|
||||||
|
"revoked_at": _iso(item.revoked_at),
|
||||||
|
},
|
||||||
|
observed_at=item.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_group_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for assignment, group in (
|
||||||
|
session.query(UserGroupMembership, Group)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(UserGroupMembership.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"group_membership",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"Group: {group.name}",
|
||||||
|
{"group_id": group.id, "group_name": group.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Group assignment history is institutional access evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_role_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for assignment, role in (
|
||||||
|
session.query(UserRoleAssignment, Role)
|
||||||
|
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||||
|
.filter(UserRoleAssignment.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"role_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"Role: {role.name}",
|
||||||
|
{"role_id": role.id, "role_name": role.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Role assignment history is institutional access evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_function_records(session: Session, append: object, user: User) -> None:
|
||||||
|
rows = (
|
||||||
|
session.query(FunctionAssignment, Function, OrganizationUnit)
|
||||||
|
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||||
|
.join(OrganizationUnit, OrganizationUnit.id == FunctionAssignment.organization_unit_id)
|
||||||
|
.filter(
|
||||||
|
FunctionAssignment.tenant_id == user.tenant_id,
|
||||||
|
FunctionAssignment.account_id == user.account_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for assignment, function, unit in rows:
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"function_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"{function.name} in {unit.name}",
|
||||||
|
{
|
||||||
|
"function_id": function.id,
|
||||||
|
"function_name": function.name,
|
||||||
|
"organization_unit_id": unit.id,
|
||||||
|
"organization_unit_name": unit.name,
|
||||||
|
"source": assignment.source,
|
||||||
|
"valid_from": _iso(assignment.valid_from),
|
||||||
|
"valid_until": _iso(assignment.valid_until),
|
||||||
|
"is_active": assignment.is_active,
|
||||||
|
},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Function incumbency is effective-dated institutional evidence.",
|
||||||
|
source_path="/admin?section=tenant-function-role-mappings",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: dict[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
immutable: bool = False,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="access",
|
||||||
|
module_id="access",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path=source_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action(
|
||||||
|
action_id: str,
|
||||||
|
kind: str,
|
||||||
|
record: DsarRecordRef,
|
||||||
|
title: str,
|
||||||
|
rationale: str,
|
||||||
|
*,
|
||||||
|
executable: bool,
|
||||||
|
irreversible: bool = False,
|
||||||
|
metadata: dict[str, object] | None = None,
|
||||||
|
) -> DsarErasureActionRef:
|
||||||
|
return DsarErasureActionRef(
|
||||||
|
action_id=action_id,
|
||||||
|
provider_id="access",
|
||||||
|
module_id="access",
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=title,
|
||||||
|
rationale=rationale,
|
||||||
|
executable=executable,
|
||||||
|
irreversible=irreversible,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
action: DsarErasureActionRef,
|
||||||
|
result_status: str,
|
||||||
|
summary: str,
|
||||||
|
evidence: dict[str, object] | None = None,
|
||||||
|
) -> DsarExecutionResultRef:
|
||||||
|
return DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=result_status, # type: ignore[arg-type]
|
||||||
|
summary=summary,
|
||||||
|
evidence=evidence or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
|
||||||
|
return _result(action, "blocked", summary)
|
||||||
|
|
||||||
|
|
||||||
|
def _erased_email(tenant_id: str, membership_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{tenant_id}\0{membership_id}".encode()).hexdigest()[:24]
|
||||||
|
return f"erased+{digest}@invalid.govoplan"
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Access DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ACCESS_DSAR_CAPABILITY", "AccessDsarProvider"]
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'access.reference.admin-access-fields': {'fields': [{'admin_description': 'Auf dem Konto und den '
|
||||||
|
'Mitglieder-Payloads '
|
||||||
|
'gespeichert. Es muss '
|
||||||
|
'normalisiert und '
|
||||||
|
'eindeutig für das '
|
||||||
|
'entsprechende '
|
||||||
|
'Login-Konto sein.',
|
||||||
|
'api_field': 'email',
|
||||||
|
'api_path': '/api/v1/admin/users',
|
||||||
|
'field_id': 'access.user.email',
|
||||||
|
'label': 'E-Mail',
|
||||||
|
'permission_scope': 'access:membership:create',
|
||||||
|
'provenance': 'Mandantenmitgliedschaft oder '
|
||||||
|
'Kontosuche.',
|
||||||
|
'user_description': 'Die Adresse, die '
|
||||||
|
'verwendet wird, um die '
|
||||||
|
'Person zu '
|
||||||
|
'identifizieren, wenn '
|
||||||
|
'sie sich anmelden.',
|
||||||
|
'validation': 'Muss eine gültige '
|
||||||
|
'E-Mail-Adresse sein.'},
|
||||||
|
{'admin_description': 'Wird, sofern verfügbar, '
|
||||||
|
'in Benutzer- und '
|
||||||
|
'Kontoantworten als '
|
||||||
|
'display_name ausgegeben.',
|
||||||
|
'api_field': 'display_name',
|
||||||
|
'api_path': '/api/v1/admin/users',
|
||||||
|
'field_id': 'access.user.display_name',
|
||||||
|
'label': 'Anzeigename',
|
||||||
|
'permission_scope': 'access:membership:update',
|
||||||
|
'provenance': 'Profil der '
|
||||||
|
'Mandantenmitgliedschaft.',
|
||||||
|
'user_description': 'Der lesbare Name, der '
|
||||||
|
'in Benutzerlisten und '
|
||||||
|
'Bewertungsbildschirmen '
|
||||||
|
'angezeigt wird.',
|
||||||
|
'validation': 'Menschenlesbarer Text; Halten '
|
||||||
|
'Sie ihn für Administratoren '
|
||||||
|
'erkennbar.'},
|
||||||
|
{'admin_description': 'Wird beim Aktualisieren '
|
||||||
|
'einer Benutzer- oder '
|
||||||
|
'Gruppenmitgliedschaft '
|
||||||
|
'als group_ids übertragen.',
|
||||||
|
'api_field': 'group_ids',
|
||||||
|
'api_path': '/api/v1/admin/users/{user_id}',
|
||||||
|
'field_id': 'access.user.groups',
|
||||||
|
'label': 'Gruppen',
|
||||||
|
'permission_scope': 'access:group:manage_members',
|
||||||
|
'provenance': 'Benutzergruppenmitgliedschaftszeilen.',
|
||||||
|
'user_description': 'Gemeinsame Zugriffsbündel, '
|
||||||
|
'die Rollen für viele '
|
||||||
|
'Personen gleichzeitig '
|
||||||
|
'hinzufügen können.',
|
||||||
|
'validation': 'Gruppen müssen zum gleichen '
|
||||||
|
'Mandant gehören.'},
|
||||||
|
{'admin_description': 'Wird bei '
|
||||||
|
'Aktualisierungsanforderungen '
|
||||||
|
'für Benutzer- und '
|
||||||
|
'Gruppenrollen als role_ids '
|
||||||
|
'übertragen.',
|
||||||
|
'api_field': 'role_ids',
|
||||||
|
'api_path': '/api/v1/admin/users/{user_id}',
|
||||||
|
'field_id': 'access.user.roles',
|
||||||
|
'label': 'Rollen',
|
||||||
|
'permission_scope': 'access:role:assign',
|
||||||
|
'provenance': 'Direkte Benutzerrollen plus '
|
||||||
|
'Gruppenrollenvererbung.',
|
||||||
|
'user_description': 'Direktzugangszuschüsse, '
|
||||||
|
'die einer Person '
|
||||||
|
'zugewiesen oder von '
|
||||||
|
'Gruppen geerbt wurden.',
|
||||||
|
'validation': 'Rollen müssen zuordenbar sein '
|
||||||
|
'und dürfen das '
|
||||||
|
'Delegationslimit der '
|
||||||
|
'handelnden Person nicht '
|
||||||
|
'überschreiten.'},
|
||||||
|
{'admin_description': 'Bildet Scopes beim '
|
||||||
|
'Erstellen eines '
|
||||||
|
'API-Schlüssel zu und '
|
||||||
|
'wird mit den aktuellen '
|
||||||
|
'Berechtigungen des '
|
||||||
|
'Besitzers geschnitten.',
|
||||||
|
'api_field': 'scopes',
|
||||||
|
'api_path': '/api/v1/admin/api-keys',
|
||||||
|
'field_id': 'access.api_key.scopes',
|
||||||
|
'label': 'Anwendungsbereiche',
|
||||||
|
'permission_scope': 'access:api_key:create',
|
||||||
|
'provenance': 'API-Schlüssel Grant plus '
|
||||||
|
'Eigentümerdelegation.',
|
||||||
|
'user_description': 'Die Aktionen, die ein '
|
||||||
|
'API-Schlüssel ausführen '
|
||||||
|
'kann.',
|
||||||
|
'validation': 'Verwenden Sie möglichst enge '
|
||||||
|
'Berechtigungsbereiche.'}]},
|
||||||
|
'access.reference.personal-navigation': {'outcome': 'Die Seitenschiene des Benutzers spiegelt die '
|
||||||
|
'persönlichen Präferenzen wider, während '
|
||||||
|
'verschlossene und unzugängliche Einträge '
|
||||||
|
'durch übergeordnete Richtlinien geregelt '
|
||||||
|
'bleiben.'},
|
||||||
|
'access.workflow.configuration-packages': {'limitations': ['Die Paketübernahme installiert keine '
|
||||||
|
'fehlenden Module.',
|
||||||
|
'Die anbieterübergreifende Übernahme ist keine atomar '
|
||||||
|
'verteilte Transaktion.',
|
||||||
|
'Generisches Rollback hängt von einem '
|
||||||
|
'beibehaltenen '
|
||||||
|
'vor der Übernahme erstellten Datenbank-Snapshot ab.'],
|
||||||
|
'operational_consequences': ['Ein abgestandener oder '
|
||||||
|
'blockierter Preflight '
|
||||||
|
'muss vor der Anwendung '
|
||||||
|
'erneut durchgeführt '
|
||||||
|
'werden.',
|
||||||
|
'Eine teilweise Anwendung '
|
||||||
|
'erfordert eine '
|
||||||
|
'Wiederherstellung, bevor '
|
||||||
|
'das Paket erneut '
|
||||||
|
'getestet wird.',
|
||||||
|
'Geheimwerte bleiben '
|
||||||
|
'außerhalb tragbarer '
|
||||||
|
'Fragmente und '
|
||||||
|
'Herkunft.']},
|
||||||
|
'access.workflow.data-subject-request': {'limitations': ['Module ohne DSAR-Anbieter werden als '
|
||||||
|
'Deckungslücken gemeldet.',
|
||||||
|
'Globale Konten und Identitäten werden '
|
||||||
|
'nicht automatisch gelöscht.']},
|
||||||
|
'access.workflow.grant-user-access': {'outcome': 'Eine Person kann sich beim Mandant anmelden und '
|
||||||
|
'erhält den beabsichtigten Zugang durch Gruppen '
|
||||||
|
'und Rollen.',
|
||||||
|
'prerequisites': ['Sie können Admin öffnen.',
|
||||||
|
'Sie können Benutzer, Gruppen und Rollen '
|
||||||
|
'lesen.',
|
||||||
|
'Schreib- oder Zuweisungsaktionen '
|
||||||
|
'erfordern übereinstimmende '
|
||||||
|
'Verwaltungsberechtigungen.'],
|
||||||
|
'result': 'Die Mitgliedschaft hat die beabsichtigten '
|
||||||
|
'effektiven Berechtigungen und keine breiteren '
|
||||||
|
'Rollen als nötig.',
|
||||||
|
'steps': ['Öffnen Sie Admin und gehen Sie zu Benutzern.',
|
||||||
|
'Finden Sie die bestehende Person oder erstellen '
|
||||||
|
'Sie eine Mitgliedschaft mit ihrer E-Mail-Adresse '
|
||||||
|
'und dem Anzeigenamen.',
|
||||||
|
'Überprüfen Sie aktuelle Gruppen und direkte '
|
||||||
|
'Rollen, bevor Sie etwas ändern.',
|
||||||
|
'Fügen Sie die Person der kleinsten Gruppe hinzu, '
|
||||||
|
'die den erforderlichen gemeinsamen Zugriff '
|
||||||
|
'gewährt.',
|
||||||
|
'Weisen Sie direkte Rollen nur zu, wenn eine '
|
||||||
|
'Gruppe nicht mit dem Fall übereinstimmt.',
|
||||||
|
'Speichern und überprüfen Sie eine '
|
||||||
|
'Blockernachricht, bevor Sie einen System- oder '
|
||||||
|
'Mandantbesitzer um Hilfe bitten.'],
|
||||||
|
'verification': 'Öffnen Sie den Benutzer erneut und '
|
||||||
|
'vergleichen Sie Gruppen, direkte Rollen '
|
||||||
|
'und effektive Berechtigungen mit der '
|
||||||
|
'Anforderung.'},
|
||||||
|
'access.workflow.manage-api-keys': {'consequences': ['Der Widerruf lehnt nachfolgende Anfragen, '
|
||||||
|
'die mit dem Schlüssel gestellt wurden, '
|
||||||
|
'sofort ab.',
|
||||||
|
'Durch das Entfernen von Berechtigungen vom '
|
||||||
|
'Besitzer wird der effektive '
|
||||||
|
'Schlüsselzugriff sofort eingeschränkt.'],
|
||||||
|
'limitations': ['Ein einmaliges Geheimnis kann nach dem '
|
||||||
|
'Schließen des Erstellungsdialogs nicht '
|
||||||
|
'angezeigt oder wiederhergestellt werden.',
|
||||||
|
'Ändern des Besitzers, Ablauf oder Scopes '
|
||||||
|
'erfordert einen Ersatzschlüssel.',
|
||||||
|
'Der Widerruf aktualisiert keine externen '
|
||||||
|
'Clients; die Betreiber müssen bei Bedarf '
|
||||||
|
'einen Ersatz installieren.'],
|
||||||
|
'outcome': 'Der Automatisierungsclient verfügt über einen '
|
||||||
|
'zeitlich begrenzten Berechtigungsnachweis, dessen '
|
||||||
|
'effektiver Zugriff weder seine gespeicherten '
|
||||||
|
'Berechtigungsbereiche noch die aktuellen '
|
||||||
|
'Berechtigungen seines Besitzers überschreiten '
|
||||||
|
'kann.',
|
||||||
|
'prerequisites': ['Der Mandant erlaubt '
|
||||||
|
'API-Anmeldeinformationen.',
|
||||||
|
'Die handelnde Person kann API-Schlüssel '
|
||||||
|
'erstellen oder widerrufen und jeden '
|
||||||
|
'ausgewählten Bereich delegieren.',
|
||||||
|
'Ein zugelassener externer Geheimmanager '
|
||||||
|
'und rechenschaftspflichtiger Eigentümer '
|
||||||
|
'sind bekannt.'],
|
||||||
|
'steps': ['Wählen Sie den verantwortlichen Eigentümer und die '
|
||||||
|
'engsten erforderlichen Berechtigungsbereiche.',
|
||||||
|
'Legen Sie den kürzesten praktischen Ablauf fest, '
|
||||||
|
'bevor Sie den Schlüssel erstellen.',
|
||||||
|
'Übertragen Sie das einmalige Geheimnis direkt in '
|
||||||
|
'den genehmigten Geheimmanager.',
|
||||||
|
'Widerrufen Sie den Schlüssel, wenn sein Client, '
|
||||||
|
'Eigentümer oder Zweck nicht mehr gültig ist.'],
|
||||||
|
'verification': 'Laden Sie das Schlüsselverzeichnis neu, '
|
||||||
|
'überprüfen Sie Eigentümer, Präfix, '
|
||||||
|
'Berechtigungsumfang, Ablauf und Status und '
|
||||||
|
'testen Sie dann den beabsichtigten Client, '
|
||||||
|
'ohne geheimes Material in Nachweise zu '
|
||||||
|
'kopieren.'},
|
||||||
|
'access.workflow.manage-reusable-credentials': {'limitations': ['GovOPlaN kann ein konfiguriertes '
|
||||||
|
'Geheimnis nicht anzeigen oder '
|
||||||
|
'wiederherstellen.',
|
||||||
|
'Eine leere Modul- oder '
|
||||||
|
'Serverbeschränkung bedeutet '
|
||||||
|
'jeden Wert, der nach '
|
||||||
|
'Berechtigungsumfang zulässig '
|
||||||
|
'ist.',
|
||||||
|
'Das Löschen oder Leeren eines '
|
||||||
|
'Geheimnisses schreibt keine '
|
||||||
|
'abhängigen Verbindungsreferenzen '
|
||||||
|
'neu.'],
|
||||||
|
'outcome': 'Die Zugangsdaten bleiben '
|
||||||
|
'schreibgeschützt und sind nur '
|
||||||
|
'innerhalb seines aktiven '
|
||||||
|
'Berechtigungsumfangs, Moduls, Servers '
|
||||||
|
'und Autorisierungsgrenzen verwendbar.',
|
||||||
|
'prerequisites': ['Der beabsichtigte '
|
||||||
|
'Berechtigungsinhaber wird '
|
||||||
|
'ausgewählt.',
|
||||||
|
'Die handelnde Person kann '
|
||||||
|
'Anmeldeinformationen lesen und '
|
||||||
|
'hat Schreibautorität für '
|
||||||
|
'Mutationen.',
|
||||||
|
'Der externe '
|
||||||
|
'Secret-Manager-Eigentümer und '
|
||||||
|
'abhängige Verbindungen sind '
|
||||||
|
'bekannt.'],
|
||||||
|
'steps': ['Wählen Sie den engsten Besitzumfang '
|
||||||
|
'und Anmeldetyp.',
|
||||||
|
'Beschränken Sie Module und Server '
|
||||||
|
'explizit, wenn eine breite Nutzung '
|
||||||
|
'nicht beabsichtigt ist.',
|
||||||
|
'Speichern Sie ein neues oder '
|
||||||
|
'Ersatzgeheimnis, ohne zu erwarten, '
|
||||||
|
'dass es erneut angezeigt wird.',
|
||||||
|
'Überprüfen Sie abhängige Verbindungen '
|
||||||
|
'vor der Deaktivierung, geheimen '
|
||||||
|
'Löschung oder Löschung.'],
|
||||||
|
'verification': 'Laden Sie die Liste der '
|
||||||
|
'Zugangsdaten neu, bestätigen Sie '
|
||||||
|
'deren Berechtigungsumfang und '
|
||||||
|
'Verfügbarkeit und testen Sie '
|
||||||
|
'dann jede beabsichtigte '
|
||||||
|
'abhängige Verbindung, ohne das '
|
||||||
|
'Geheimnis zu enthüllen.'},
|
||||||
|
'access.workflow.manage-service-account-credentials': {'consequences': ['Rotation widerruft den '
|
||||||
|
'vorherigen Nachweis in '
|
||||||
|
'der gleichen '
|
||||||
|
'Transaktion, die seinen '
|
||||||
|
'Ersatz schafft.',
|
||||||
|
'Der Widerruf, die '
|
||||||
|
'Deaktivierung des Kontos '
|
||||||
|
'und der Ruhestand lehnen '
|
||||||
|
'betroffene '
|
||||||
|
'Kundenanfragen sofort '
|
||||||
|
'ab.',
|
||||||
|
'Eine veraltete Revision '
|
||||||
|
'wird abgelehnt, so dass '
|
||||||
|
'ein gleichzeitiger '
|
||||||
|
'Verwaltungswechsel nicht '
|
||||||
|
'überschrieben wird.'],
|
||||||
|
'limitations': ['Einmalige '
|
||||||
|
'Anmeldegeheimnisse können '
|
||||||
|
'nach dem Schließen des '
|
||||||
|
'Erstellungsdialogs nicht '
|
||||||
|
'angezeigt oder '
|
||||||
|
'wiederhergestellt werden.',
|
||||||
|
'Deaktivierung und eine '
|
||||||
|
'reduzierte '
|
||||||
|
'Berechtigungsumfangsobergrenze '
|
||||||
|
'betreffen Clients sofort, '
|
||||||
|
'schreiben ihre externe '
|
||||||
|
'Konfiguration jedoch '
|
||||||
|
'nicht neu.',
|
||||||
|
'Der Ruhestand widerruft '
|
||||||
|
'alle aktiven '
|
||||||
|
'Anmeldeinformationen und '
|
||||||
|
'erfordert ein neues '
|
||||||
|
'Servicekonto für die '
|
||||||
|
'spätere '
|
||||||
|
'Wiederverwendung.'],
|
||||||
|
'outcome': 'Der Automatisierungsprinzipal '
|
||||||
|
'bleibt nicht interaktiv und '
|
||||||
|
'kann sich nur durch einen '
|
||||||
|
'aktiven Berechtigungsnachweis '
|
||||||
|
'authentifizieren, dessen '
|
||||||
|
'Gewährung innerhalb der '
|
||||||
|
'aktuellen '
|
||||||
|
'Berechtigungsumfangsobergrenze '
|
||||||
|
'des Kontos liegt.',
|
||||||
|
'prerequisites': ['Der Mandant erlaubt '
|
||||||
|
'API-Anmeldeinformationen.',
|
||||||
|
'Sie haben eine '
|
||||||
|
'Service-Account-Schreibberechtigung '
|
||||||
|
'und können jeden '
|
||||||
|
'ausgewählten Bereich '
|
||||||
|
'delegieren.'],
|
||||||
|
'steps': ['Erstellen Sie ein Servicekonto '
|
||||||
|
'und definieren Sie die engste '
|
||||||
|
'Nutzumfangsobergrenze.',
|
||||||
|
'Öffnen Sie das Konto und '
|
||||||
|
'erstellen Sie einen '
|
||||||
|
'Berechtigungsnachweis mit einem '
|
||||||
|
'gleichen oder engeren '
|
||||||
|
'Berechtigungsumfang.',
|
||||||
|
'Notieren Sie das einmalige '
|
||||||
|
'Geheimnis in einem externen '
|
||||||
|
'Geheimmanager.',
|
||||||
|
'Anmeldeinformationen vor Ablauf '
|
||||||
|
'drehen und Anmeldeinformationen '
|
||||||
|
'widerrufen, die nicht mehr '
|
||||||
|
'verwendet werden.'],
|
||||||
|
'verification': 'Die Verwaltungstabelle '
|
||||||
|
'zeigt die erwartete '
|
||||||
|
'Anzahl der aktiven '
|
||||||
|
'Anmeldeinformationen, den '
|
||||||
|
'Zeitstempel für die '
|
||||||
|
'letzte Verwendung, die '
|
||||||
|
'Revision und die '
|
||||||
|
'Audit-Ereignisse, ohne '
|
||||||
|
'geheimes Material '
|
||||||
|
'preiszugeben.'}}
|
||||||
@@ -1,26 +1,186 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
|
from govoplan_access.backend.db.models import (
|
||||||
|
Group,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
Role,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
new_uuid,
|
||||||
|
)
|
||||||
from govoplan_core.admin.common import AdminConflictError
|
from govoplan_core.admin.common import AdminConflictError
|
||||||
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
|
from govoplan_core.core.access import (
|
||||||
|
AccessGovernanceMaterializer,
|
||||||
|
AccessGovernanceProjectionV1,
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceProjectionOutcome,
|
||||||
|
GovernanceProjectionResult,
|
||||||
|
GovernanceTemplateMaterialization,
|
||||||
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
class SqlAccessGovernanceMaterializer(
|
||||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
AccessGovernanceMaterializer,
|
||||||
|
AccessGovernanceProjectionV1,
|
||||||
|
):
|
||||||
|
"""Reconcile governance projections with a constant number of bulk reads."""
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
batch: GovernanceProjectionBatch,
|
||||||
|
) -> GovernanceProjectionResult:
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
if template.kind == "group":
|
commands = tuple(batch.commands)
|
||||||
group = (
|
group_commands = tuple(item for item in commands if item.template.kind == "group")
|
||||||
db.query(Group)
|
role_commands = tuple(item for item in commands if item.template.kind == "role")
|
||||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
|
||||||
.first()
|
groups, duplicate_group_keys = _managed_groups(db, group_commands)
|
||||||
|
roles, duplicate_role_keys = _managed_roles(db, role_commands)
|
||||||
|
used_group_slugs = _used_slugs(db, Group, group_commands)
|
||||||
|
used_role_slugs = _used_slugs(db, Role, role_commands)
|
||||||
|
|
||||||
|
group_ids = {item.id for item in groups.values()}
|
||||||
|
role_ids = {item.id for item in roles.values()}
|
||||||
|
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
|
||||||
|
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
|
||||||
|
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
|
||||||
|
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
|
||||||
|
|
||||||
|
outcomes: list[GovernanceProjectionOutcome] = []
|
||||||
|
for command in commands:
|
||||||
|
key = (command.template.tenant_id, command.template.template_id)
|
||||||
|
if command.template.kind == "group":
|
||||||
|
outcome = self._reconcile_group(
|
||||||
|
db,
|
||||||
|
command,
|
||||||
|
groups,
|
||||||
|
duplicate_group_keys,
|
||||||
|
used_group_slugs,
|
||||||
|
group_memberships,
|
||||||
|
group_role_links,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
outcome = self._reconcile_role(
|
||||||
|
db,
|
||||||
|
command,
|
||||||
|
roles,
|
||||||
|
duplicate_role_keys,
|
||||||
|
used_role_slugs,
|
||||||
|
role_user_links,
|
||||||
|
role_group_links,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
)
|
||||||
|
outcomes.append(outcome)
|
||||||
|
if outcome.status in {"removed", "absent"}:
|
||||||
|
groups.pop(key, None)
|
||||||
|
roles.pop(key, None)
|
||||||
|
|
||||||
|
if not batch.dry_run:
|
||||||
|
db.flush()
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=batch.operation_id,
|
||||||
|
outcomes=tuple(outcomes),
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||||
|
self._legacy_reconcile(session, template, operation="upsert")
|
||||||
|
|
||||||
|
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||||
|
self._legacy_reconcile(session, template, operation="remove")
|
||||||
|
|
||||||
|
def _legacy_reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
template: GovernanceTemplateMaterialization,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> None:
|
||||||
|
command = GovernanceProjectionCommand(
|
||||||
|
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
|
||||||
|
operation=operation, # type: ignore[arg-type]
|
||||||
|
template=template,
|
||||||
|
provenance={"contract": "access.governanceMaterializer"},
|
||||||
|
)
|
||||||
|
result = self.reconcile(
|
||||||
|
session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id=command.assignment_id,
|
||||||
|
commands=(command,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.blocked:
|
||||||
|
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
|
||||||
|
|
||||||
|
def _reconcile_group(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
existing: dict[tuple[str, str], Group],
|
||||||
|
duplicate_keys: set[tuple[str, str]],
|
||||||
|
used_slugs: dict[str, set[str]],
|
||||||
|
membership_counts: dict[str, int],
|
||||||
|
role_counts: dict[str, int],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
key = (template.tenant_id, template.template_id)
|
||||||
|
group = existing.get(key)
|
||||||
|
if key in duplicate_keys:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="failed",
|
||||||
|
blocker_codes=("duplicate_managed_projection",),
|
||||||
|
message="Multiple managed groups exist for this template and tenant.",
|
||||||
|
)
|
||||||
|
if command.operation == "remove":
|
||||||
if group is None:
|
if group is None:
|
||||||
|
return _outcome(command, status="absent")
|
||||||
|
blockers: list[str] = []
|
||||||
|
if membership_counts.get(group.id, 0):
|
||||||
|
blockers.append("group_has_members")
|
||||||
|
if role_counts.get(group.id, 0):
|
||||||
|
blockers.append("group_has_roles")
|
||||||
|
if blockers:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=group.id,
|
||||||
|
blocker_codes=tuple(blockers),
|
||||||
|
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||||
|
except AdminConflictError as exc:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=group.id,
|
||||||
|
blocker_codes=("module_delete_veto",),
|
||||||
|
message=str(exc),
|
||||||
|
)
|
||||||
|
db.delete(group)
|
||||||
|
return _outcome(command, status="removed", resource_id=group.id)
|
||||||
|
|
||||||
|
if group is None:
|
||||||
|
resource_id = new_uuid()
|
||||||
|
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||||
|
if not dry_run:
|
||||||
group = Group(
|
group = Group(
|
||||||
|
id=resource_id,
|
||||||
tenant_id=template.tenant_id,
|
tenant_id=template.tenant_id,
|
||||||
slug=_available_slug(db, Group, template.tenant_id, template.slug),
|
slug=slug,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
is_active=template.is_active,
|
is_active=template.is_active,
|
||||||
@@ -28,24 +188,72 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
|||||||
system_required=template.required,
|
system_required=template.required,
|
||||||
)
|
)
|
||||||
db.add(group)
|
db.add(group)
|
||||||
else:
|
existing[key] = group
|
||||||
group.name = template.name
|
return _outcome(command, status="created", resource_id=resource_id)
|
||||||
group.description = template.description
|
|
||||||
group.system_required = template.required
|
|
||||||
if template.required:
|
|
||||||
group.is_active = template.is_active
|
|
||||||
db.flush()
|
|
||||||
return
|
|
||||||
|
|
||||||
role = (
|
changes = {
|
||||||
db.query(Role)
|
"name": template.name,
|
||||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
"description": template.description,
|
||||||
.first()
|
"system_required": template.required,
|
||||||
|
}
|
||||||
|
if template.required:
|
||||||
|
changes["is_active"] = template.is_active
|
||||||
|
changed = any(getattr(group, field) != value for field, value in changes.items())
|
||||||
|
if changed and not dry_run:
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(group, field, value)
|
||||||
|
return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
|
||||||
|
|
||||||
|
def _reconcile_role(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
existing: dict[tuple[str, str], Role],
|
||||||
|
duplicate_keys: set[tuple[str, str]],
|
||||||
|
used_slugs: dict[str, set[str]],
|
||||||
|
user_counts: dict[str, int],
|
||||||
|
group_counts: dict[str, int],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
key = (template.tenant_id, template.template_id)
|
||||||
|
role = existing.get(key)
|
||||||
|
if key in duplicate_keys:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="failed",
|
||||||
|
blocker_codes=("duplicate_managed_projection",),
|
||||||
|
message="Multiple managed roles exist for this template and tenant.",
|
||||||
)
|
)
|
||||||
|
if command.operation == "remove":
|
||||||
if role is None:
|
if role is None:
|
||||||
|
return _outcome(command, status="absent")
|
||||||
|
blockers: list[str] = []
|
||||||
|
if user_counts.get(role.id, 0):
|
||||||
|
blockers.append("role_has_users")
|
||||||
|
if group_counts.get(role.id, 0):
|
||||||
|
blockers.append("role_has_groups")
|
||||||
|
if blockers:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=role.id,
|
||||||
|
blocker_codes=tuple(blockers),
|
||||||
|
message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
db.delete(role)
|
||||||
|
return _outcome(command, status="removed", resource_id=role.id)
|
||||||
|
|
||||||
|
if role is None:
|
||||||
|
resource_id = new_uuid()
|
||||||
|
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||||
|
if not dry_run:
|
||||||
role = Role(
|
role = Role(
|
||||||
|
id=resource_id,
|
||||||
tenant_id=template.tenant_id,
|
tenant_id=template.tenant_id,
|
||||||
slug=_available_slug(db, Role, template.tenant_id, template.slug),
|
slug=slug,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
permissions=list(template.permissions),
|
permissions=list(template.permissions),
|
||||||
@@ -55,51 +263,124 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
|||||||
system_required=template.required,
|
system_required=template.required,
|
||||||
)
|
)
|
||||||
db.add(role)
|
db.add(role)
|
||||||
else:
|
existing[key] = role
|
||||||
role.name = template.name
|
return _outcome(command, status="created", resource_id=resource_id)
|
||||||
role.description = template.description
|
|
||||||
role.permissions = list(template.permissions)
|
changes: dict[str, object] = {
|
||||||
role.system_required = template.required
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"permissions": list(template.permissions),
|
||||||
|
"system_required": template.required,
|
||||||
|
}
|
||||||
if template.required:
|
if template.required:
|
||||||
role.is_assignable = template.is_active
|
changes["is_assignable"] = template.is_active
|
||||||
db.flush()
|
changed = any(getattr(role, field) != value for field, value in changes.items())
|
||||||
|
if changed and not dry_run:
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(role, field, value)
|
||||||
|
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
|
||||||
|
|
||||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
|
||||||
db = _session(session)
|
|
||||||
if template.kind == "group":
|
|
||||||
group = (
|
|
||||||
db.query(Group)
|
|
||||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if group is None:
|
|
||||||
return
|
|
||||||
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
|
||||||
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
|
||||||
if membership_count or role_count:
|
|
||||||
raise AdminConflictError(
|
|
||||||
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
|
|
||||||
)
|
|
||||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
|
||||||
db.delete(group)
|
|
||||||
db.flush()
|
|
||||||
return
|
|
||||||
|
|
||||||
role = (
|
def _managed_groups(
|
||||||
db.query(Role)
|
session: Session,
|
||||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
.first()
|
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
|
||||||
|
if not commands:
|
||||||
|
return {}, set()
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
templates = {item.template.template_id for item in commands}
|
||||||
|
rows = session.query(Group).filter(
|
||||||
|
Group.tenant_id.in_(tenants),
|
||||||
|
Group.system_template_id.in_(templates),
|
||||||
|
).all()
|
||||||
|
return _indexed_managed(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_roles(
|
||||||
|
session: Session,
|
||||||
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
|
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
|
||||||
|
if not commands:
|
||||||
|
return {}, set()
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
templates = {item.template.template_id for item in commands}
|
||||||
|
rows = session.query(Role).filter(
|
||||||
|
Role.tenant_id.in_(tenants),
|
||||||
|
Role.system_template_id.in_(templates),
|
||||||
|
).all()
|
||||||
|
return _indexed_managed(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _indexed_managed(rows):
|
||||||
|
indexed = {}
|
||||||
|
duplicates = set()
|
||||||
|
for row in rows:
|
||||||
|
key = (row.tenant_id, row.system_template_id)
|
||||||
|
if key in indexed:
|
||||||
|
duplicates.add(key)
|
||||||
|
else:
|
||||||
|
indexed[key] = row
|
||||||
|
return indexed, duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def _used_slugs(
|
||||||
|
session: Session,
|
||||||
|
model: type[Group] | type[Role],
|
||||||
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
used: dict[str, set[str]] = defaultdict(set)
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
if tenants:
|
||||||
|
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
|
||||||
|
used[str(tenant_id)].add(slug)
|
||||||
|
for tenant_id in tenants:
|
||||||
|
used[tenant_id]
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
|
||||||
|
if not resource_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
resource_id: count
|
||||||
|
for resource_id, count in session.query(column, func.count(model.id))
|
||||||
|
.filter(column.in_(resource_ids))
|
||||||
|
.group_by(column)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _available_slug(used: set[str], base: str) -> str:
|
||||||
|
candidate = base
|
||||||
|
suffix = 2
|
||||||
|
while candidate in used:
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
used.add(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _outcome(
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
blocker_codes: tuple[str, ...] = (),
|
||||||
|
message: str | None = None,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
return GovernanceProjectionOutcome(
|
||||||
|
assignment_id=command.assignment_id,
|
||||||
|
template_id=template.template_id,
|
||||||
|
tenant_id=template.tenant_id,
|
||||||
|
kind=template.kind,
|
||||||
|
operation=command.operation,
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
resource_id=resource_id,
|
||||||
|
blocker_codes=blocker_codes,
|
||||||
|
message=message,
|
||||||
|
provenance=dict(command.provenance),
|
||||||
)
|
)
|
||||||
if role is None:
|
|
||||||
return
|
|
||||||
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
|
||||||
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
|
||||||
if user_count or group_count:
|
|
||||||
raise AdminConflictError(
|
|
||||||
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
|
|
||||||
)
|
|
||||||
db.delete(role)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _session(session: object) -> Session:
|
def _session(session: object) -> Session:
|
||||||
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
|
|||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AdminConflictError(str(exc)) from exc
|
raise AdminConflictError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
|
||||||
candidate = base
|
|
||||||
suffix = 2
|
|
||||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
|
||||||
candidate = f"{base}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
return candidate
|
|
||||||
|
|||||||
+1672
-107
File diff suppressed because it is too large
Load Diff
+62
@@ -0,0 +1,62 @@
|
|||||||
|
"""Repair missing external function role mappings without replaying the baseline.
|
||||||
|
|
||||||
|
Revision ID: d8f1b4e7a0c3
|
||||||
|
Revises: b6d9f2a5c8e1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d8f1b4e7a0c3"
|
||||||
|
down_revision = "b6d9f2a5c8e1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
TABLE_NAME = "access_external_function_role_assignments"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Some older installations record the Access baseline without this table.
|
||||||
|
# Never recreate an existing mapping table or derive permission grants.
|
||||||
|
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
TABLE_NAME,
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("settings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["role_id"],
|
||||||
|
["access_roles.id"],
|
||||||
|
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "source_module", "function_id", "role_id",
|
||||||
|
name="uq_external_function_role_assignments",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||||
|
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||||
|
# before or after repair; dropping it would silently remove permission policy.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add bounded administrator-assisted local password recovery.
|
||||||
|
|
||||||
|
Revision ID: e9a2c5f8b1d4
|
||||||
|
Revises: d8f1b4e7a0c3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "e9a2c5f8b1d4"
|
||||||
|
down_revision = "d8f1b4e7a0c3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"access_password_recoveries",
|
||||||
|
sa.Column("id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("account_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("code_hash"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_access_password_recoveries_account_id",
|
||||||
|
"access_password_recoveries",
|
||||||
|
["account_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_access_password_recoveries_issuer_account_id",
|
||||||
|
"access_password_recoveries",
|
||||||
|
["issuer_account_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("access_password_recoveries")
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
"""Repair missing external function role mappings without replaying the baseline.
|
||||||
|
|
||||||
|
Revision ID: d8f1b4e7a0c3
|
||||||
|
Revises: c7e0a3d6f9b2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d8f1b4e7a0c3"
|
||||||
|
down_revision = "c7e0a3d6f9b2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLE_NAME = "access_external_function_role_assignments"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Some older installations record the Access baseline without this table.
|
||||||
|
# Never recreate an existing mapping table or derive permission grants.
|
||||||
|
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
TABLE_NAME,
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("settings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["role_id"],
|
||||||
|
["access_roles.id"],
|
||||||
|
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "source_module", "function_id", "role_id",
|
||||||
|
name="uq_external_function_role_assignments",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||||
|
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||||
|
# before or after repair; dropping it would silently remove permission policy.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add bounded administrator-assisted local password recovery.
|
||||||
|
|
||||||
|
Revision ID: e9a2c5f8b1d4
|
||||||
|
Revises: d8f1b4e7a0c3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "e9a2c5f8b1d4"
|
||||||
|
down_revision = "d8f1b4e7a0c3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"access_password_recoveries",
|
||||||
|
sa.Column("id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("account_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("code_hash"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_access_password_recoveries_account_id",
|
||||||
|
"access_password_recoveries",
|
||||||
|
["account_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_access_password_recoveries_issuer_account_id",
|
||||||
|
"access_password_recoveries",
|
||||||
|
["issuer_account_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("access_password_recoveries")
|
||||||
@@ -212,11 +212,29 @@ def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[st
|
|||||||
allowed.update(
|
allowed.update(
|
||||||
scope
|
scope
|
||||||
for scope in user_raw.intersection(key_raw)
|
for scope in user_raw.intersection(key_raw)
|
||||||
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
|
if _is_concrete_tenant_credential_scope(scope, catalog)
|
||||||
)
|
)
|
||||||
return sorted(allowed)
|
return sorted(allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_concrete_tenant_credential_scope(
|
||||||
|
scope: str,
|
||||||
|
catalog: Mapping[str, PermissionDefinition],
|
||||||
|
) -> bool:
|
||||||
|
# Wildcards are expanded against the tenant catalogue above. Returning the
|
||||||
|
# wildcard itself could grant system permissions sharing the module prefix,
|
||||||
|
# or permissions outside the currently known tenant catalogue.
|
||||||
|
if scope == "*" or scope.endswith(":*"):
|
||||||
|
return False
|
||||||
|
# System permissions can use module-native names (e.g. access:tenant:create),
|
||||||
|
# so excluding only the historical system: prefix is not sufficient.
|
||||||
|
return all(
|
||||||
|
not alias.startswith("system:")
|
||||||
|
and (alias not in catalog or catalog[alias].level == "tenant")
|
||||||
|
for alias in compatible_required_scopes(scope)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
||||||
registry = _registry()
|
registry = _registry()
|
||||||
if registry is not None and hasattr(registry, "permissions"):
|
if registry is not None and hasattr(registry, "permissions"):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from govoplan_core.security.time import ensure_aware_utc, utc_now
|
|||||||
|
|
||||||
API_KEY_PREFIX_LENGTH = 12
|
API_KEY_PREFIX_LENGTH = 12
|
||||||
API_KEY_RANDOM_BYTES = 32
|
API_KEY_RANDOM_BYTES = 32
|
||||||
|
API_KEY_SECRET_PREFIX = "gpn_"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -28,7 +29,7 @@ def verify_api_key(secret: str, expected_hash: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def generate_api_key_secret() -> str:
|
def generate_api_key_secret() -> str:
|
||||||
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
|
return generate_secret(API_KEY_SECRET_PREFIX, random_bytes=API_KEY_RANDOM_BYTES)
|
||||||
|
|
||||||
|
|
||||||
def api_key_prefix(secret: str) -> str:
|
def api_key_prefix(secret: str) -> str:
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ def build_login_throttle(
|
|||||||
client_limit: int,
|
client_limit: int,
|
||||||
window_seconds: int,
|
window_seconds: int,
|
||||||
redis_retry_seconds: int,
|
redis_retry_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:access:login:v1",
|
||||||
) -> LoginThrottle:
|
) -> LoginThrottle:
|
||||||
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||||
resilient_store = ResilientLoginAttemptStore(
|
resilient_store = ResilientLoginAttemptStore(
|
||||||
@@ -355,4 +356,5 @@ def build_login_throttle(
|
|||||||
identity_limit=identity_limit,
|
identity_limit=identity_limit,
|
||||||
client_limit=client_limit,
|
client_limit=client_limit,
|
||||||
window_seconds=window_seconds,
|
window_seconds=window_seconds,
|
||||||
|
key_prefix=key_prefix,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
PasswordRecovery,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||||
|
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||||
|
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
MIN_PASSWORD_LENGTH = 10
|
||||||
|
MAX_PASSWORD_LENGTH = 1024
|
||||||
|
RECOVERY_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
def local_password_account(account: Account) -> bool:
|
||||||
|
return account.auth_provider == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def password_change_required(account: Account) -> bool:
|
||||||
|
return bool(
|
||||||
|
settings.auth_local_password_recovery_enabled
|
||||||
|
and local_password_account(account)
|
||||||
|
and account.password_reset_required
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_password_change(account: Account) -> None:
|
||||||
|
if password_change_required(account):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
detail={
|
||||||
|
"code": "password_change_required",
|
||||||
|
"message": "Change your initial local password to continue.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_new_password(password: str, account: Account) -> None:
|
||||||
|
if not MIN_PASSWORD_LENGTH <= len(password) <= MAX_PASSWORD_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={
|
||||||
|
"code": "invalid_new_password",
|
||||||
|
"message": "Use a password between 10 and 1024 characters.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if verify_password(password, account.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={
|
||||||
|
"code": "password_unchanged",
|
||||||
|
"message": "Choose a different password.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def locked_local_account(session: Session, account_id: str) -> Account:
|
||||||
|
account = (
|
||||||
|
session.query(Account)
|
||||||
|
.filter(Account.id == account_id)
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if account is None or not account.is_active or not local_password_account(account):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
detail={
|
||||||
|
"code": "local_password_unavailable",
|
||||||
|
"message": "Local password changes are unavailable for this account.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def recovery_issuer_authorized(
|
||||||
|
session: Session, *, account_id: str, membership_id: str
|
||||||
|
) -> bool:
|
||||||
|
account = session.get(Account, account_id)
|
||||||
|
user = session.get(User, membership_id)
|
||||||
|
tenant = session.get(Tenant, user.tenant_id) if user else None
|
||||||
|
return bool(
|
||||||
|
account
|
||||||
|
and user
|
||||||
|
and tenant
|
||||||
|
and account.is_active
|
||||||
|
and user.is_active
|
||||||
|
and tenant.is_active
|
||||||
|
and user.account_id == account.id
|
||||||
|
and local_password_account(account)
|
||||||
|
and not password_change_required(account)
|
||||||
|
and scopes_grant(
|
||||||
|
collect_user_scopes(session, user, include_system=True), "system:*"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_recovery(
|
||||||
|
session: Session, *, account: Account, issuer: Account, membership: User
|
||||||
|
) -> tuple[str, PasswordRecovery]:
|
||||||
|
# Caller holds the target account lock: issue/redeem/change operations for
|
||||||
|
# that account serialize, and only the latest issued code remains usable.
|
||||||
|
now = utc_now()
|
||||||
|
session.query(PasswordRecovery).filter(
|
||||||
|
PasswordRecovery.account_id == account.id,
|
||||||
|
PasswordRecovery.consumed_at.is_(None),
|
||||||
|
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||||
|
code = generate_secret("pr_", random_bytes=32)
|
||||||
|
model = PasswordRecovery(
|
||||||
|
account_id=account.id,
|
||||||
|
issuer_account_id=issuer.id,
|
||||||
|
issuer_membership_id=membership.id,
|
||||||
|
code_hash=hash_secret(code),
|
||||||
|
expires_at=now + timedelta(minutes=RECOVERY_MINUTES),
|
||||||
|
)
|
||||||
|
session.add(model)
|
||||||
|
session.flush()
|
||||||
|
return code, model
|
||||||
|
|
||||||
|
|
||||||
|
def replace_password(
|
||||||
|
session: Session, *, account: Account, password: str
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Caller must hold the account lock and commit audit + changes together."""
|
||||||
|
validate_new_password(password, account)
|
||||||
|
now = utc_now()
|
||||||
|
encoded = hash_password(password)
|
||||||
|
replaced = (
|
||||||
|
session.query(Account)
|
||||||
|
.filter(
|
||||||
|
Account.id == account.id,
|
||||||
|
Account.password_hash == account.password_hash,
|
||||||
|
Account.auth_provider == "local",
|
||||||
|
Account.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{Account.password_hash: encoded, Account.password_reset_required: False},
|
||||||
|
synchronize_session="fetch",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if replaced != 1:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
detail={
|
||||||
|
"code": "password_changed_concurrently",
|
||||||
|
"message": "The account changed during authorization. Sign in again.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Keep compatibility membership hashes synchronized; account remains the
|
||||||
|
# only interactive password authority.
|
||||||
|
session.query(User).filter(User.account_id == account.id).update(
|
||||||
|
{User.password_hash: encoded}, synchronize_session="fetch"
|
||||||
|
)
|
||||||
|
sessions = (
|
||||||
|
session.query(AuthSession)
|
||||||
|
.filter(AuthSession.account_id == account.id, AuthSession.revoked_at.is_(None))
|
||||||
|
.update({AuthSession.revoked_at: now}, synchronize_session="fetch")
|
||||||
|
)
|
||||||
|
membership_ids = session.query(User.id).filter(User.account_id == account.id)
|
||||||
|
keys = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.filter(ApiKey.user_id.in_(membership_ids), ApiKey.revoked_at.is_(None))
|
||||||
|
.update({ApiKey.revoked_at: now}, synchronize_session="fetch")
|
||||||
|
)
|
||||||
|
# Treat recovery handoffs as credentials delegated by the issuer too. A
|
||||||
|
# compromised owner's password replacement must not leave previously
|
||||||
|
# issued takeover authorizations for other accounts usable.
|
||||||
|
recoveries = session.query(PasswordRecovery).filter(
|
||||||
|
or_(
|
||||||
|
PasswordRecovery.account_id == account.id,
|
||||||
|
PasswordRecovery.issuer_account_id == account.id,
|
||||||
|
),
|
||||||
|
PasswordRecovery.consumed_at.is_(None),
|
||||||
|
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||||
|
return {
|
||||||
|
"revoked_sessions": sessions,
|
||||||
|
"revoked_api_keys": keys,
|
||||||
|
"revoked_password_recoveries": recoveries,
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import AuthSession
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SESSION_LIST_ITEMS = 100
|
||||||
|
MAX_CLIENT_LABEL_LENGTH = 160
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionSummary:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None
|
||||||
|
client: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def session_summary(
|
||||||
|
item: AuthSession,
|
||||||
|
*,
|
||||||
|
current_session_id: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> SessionSummary:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
expires_at = ensure_aware_utc(item.expires_at)
|
||||||
|
revoked_at = ensure_aware_utc(item.revoked_at)
|
||||||
|
if revoked_at is not None:
|
||||||
|
status = "revoked"
|
||||||
|
elif expires_at is None or expires_at <= effective_at:
|
||||||
|
status = "expired"
|
||||||
|
else:
|
||||||
|
status = "active"
|
||||||
|
return SessionSummary(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
current=item.id == current_session_id,
|
||||||
|
status=status,
|
||||||
|
created_at=item.created_at,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
expires_at=item.expires_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
client=_bounded_client(item.user_agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_account_sessions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
current_session_id: str | None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = MAX_SESSION_LIST_ITEMS,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[SessionSummary, ...]:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.filter(
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
AuthSession.expires_at > effective_at,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc())
|
||||||
|
.limit(max(1, min(limit, MAX_SESSION_LIST_ITEMS)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
session_summary(
|
||||||
|
item,
|
||||||
|
current_session_id=current_session_id,
|
||||||
|
now=effective_at,
|
||||||
|
)
|
||||||
|
for item in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_account_session(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
session_id: str,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
protected_session_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[AuthSession | None, bool]:
|
||||||
|
query = session.query(AuthSession).filter(
|
||||||
|
AuthSession.id == session_id,
|
||||||
|
AuthSession.account_id == account_id,
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||||
|
item = query.one_or_none()
|
||||||
|
if item is None:
|
||||||
|
return None, False
|
||||||
|
if protected_session_id is not None and item.id == protected_session_id:
|
||||||
|
raise ValueError("The current session cannot be revoked through session management.")
|
||||||
|
if item.revoked_at is not None:
|
||||||
|
return item, False
|
||||||
|
item.revoked_at = now or utc_now()
|
||||||
|
session.add(item)
|
||||||
|
return item, True
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_other_account_sessions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
current_session_id: str,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
rows = (
|
||||||
|
session.query(AuthSession)
|
||||||
|
.filter(
|
||||||
|
AuthSession.account_id == account_id,
|
||||||
|
AuthSession.id != current_session_id,
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
revoked: list[str] = []
|
||||||
|
for item in rows:
|
||||||
|
expires_at = ensure_aware_utc(item.expires_at)
|
||||||
|
if expires_at is None or expires_at <= effective_at:
|
||||||
|
continue
|
||||||
|
item.revoked_at = effective_at
|
||||||
|
session.add(item)
|
||||||
|
revoked.append(item.id)
|
||||||
|
return tuple(sorted(revoked))
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_client(value: str | None) -> str | None:
|
||||||
|
normalized = " ".join(str(value or "").split())
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
return normalized[:MAX_CLIENT_LABEL_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_CLIENT_LABEL_LENGTH",
|
||||||
|
"MAX_SESSION_LIST_ITEMS",
|
||||||
|
"SessionSummary",
|
||||||
|
"list_account_sessions",
|
||||||
|
"revoke_account_session",
|
||||||
|
"revoke_other_account_sessions",
|
||||||
|
"session_summary",
|
||||||
|
]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
|
Function,
|
||||||
|
FunctionAssignment,
|
||||||
|
FunctionDelegation,
|
||||||
|
FunctionRoleAssignment,
|
||||||
|
Group,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
OrganizationUnit,
|
||||||
|
Role,
|
||||||
|
ServiceAccount,
|
||||||
|
User,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.tenant_erasure import (
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||||
|
TenantErasurePreview,
|
||||||
|
TenantErasureResource,
|
||||||
|
TenantErasureStep,
|
||||||
|
TenantErasureStepResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_TENANT_ERASURE_CAPABILITY = (
|
||||||
|
f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}access"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CREDENTIAL_MODELS = (AuthSession, ApiKey)
|
||||||
|
_TENANT_ACCESS_MODELS = (
|
||||||
|
ServiceAccount,
|
||||||
|
FunctionDelegation,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
|
FunctionRoleAssignment,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
FunctionAssignment,
|
||||||
|
Function,
|
||||||
|
OrganizationUnit,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
|
_ALL_MODELS = _CREDENTIAL_MODELS + _TENANT_ACCESS_MODELS
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
model.__tablename__: session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
for model in _ALL_MODELS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_models(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
models: tuple[type, ...],
|
||||||
|
) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for model in models:
|
||||||
|
deleted += (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTenantErasureProvider:
|
||||||
|
module_id = "access"
|
||||||
|
|
||||||
|
def preview_tenant_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> TenantErasurePreview:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Access tenant erasure requires a database session.")
|
||||||
|
counts = _counts(session, tenant_id)
|
||||||
|
credential_count = sum(
|
||||||
|
counts[model.__tablename__] for model in _CREDENTIAL_MODELS
|
||||||
|
)
|
||||||
|
access_count = sum(
|
||||||
|
counts[model.__tablename__] for model in _TENANT_ACCESS_MODELS
|
||||||
|
)
|
||||||
|
resources = tuple(
|
||||||
|
TenantErasureResource(
|
||||||
|
resource_type=table_name,
|
||||||
|
count=count,
|
||||||
|
disposition="erase",
|
||||||
|
summary=f"{count} tenant-scoped Access records will be erased.",
|
||||||
|
)
|
||||||
|
for table_name, count in sorted(counts.items())
|
||||||
|
)
|
||||||
|
steps: list[TenantErasureStep] = []
|
||||||
|
if credential_count:
|
||||||
|
steps.append(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="revoke-tenant-credentials",
|
||||||
|
kind="erase",
|
||||||
|
summary="Revoke tenant sessions and erase tenant API keys.",
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if access_count:
|
||||||
|
steps.append(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="erase-tenant-access",
|
||||||
|
kind="erase",
|
||||||
|
summary=(
|
||||||
|
"Erase tenant memberships, service accounts, groups, roles, "
|
||||||
|
"organization units, functions, assignments, and delegations."
|
||||||
|
),
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
depends_on=(
|
||||||
|
("revoke-tenant-credentials",) if credential_count else ()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TenantErasurePreview(
|
||||||
|
module_id=self.module_id,
|
||||||
|
complete=True,
|
||||||
|
resources=resources,
|
||||||
|
steps=tuple(steps),
|
||||||
|
warnings=(
|
||||||
|
"Global accounts and identity links are retained because they may belong to other tenants.",
|
||||||
|
),
|
||||||
|
provider_revision="access-tenant-erasure-v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Access tenant erasure requires a database session.")
|
||||||
|
if not idempotency_key.strip():
|
||||||
|
raise ValueError("Access tenant erasure requires an idempotency key.")
|
||||||
|
if step_id == "revoke-tenant-credentials":
|
||||||
|
deleted = _delete_models(session, tenant_id, _CREDENTIAL_MODELS)
|
||||||
|
summary = "Tenant sessions and API keys were erased."
|
||||||
|
elif step_id == "erase-tenant-access":
|
||||||
|
deleted = _delete_models(session, tenant_id, _TENANT_ACCESS_MODELS)
|
||||||
|
summary = "Tenant-scoped Access records were erased."
|
||||||
|
else:
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="blocked",
|
||||||
|
summary="Access tenant erasure step is unknown.",
|
||||||
|
)
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="completed",
|
||||||
|
summary=summary,
|
||||||
|
receipt_ref=f"access:tenant-erasure:{tenant_id}:{step_id}",
|
||||||
|
metrics={"deleted": deleted},
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
return self.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
tenant_id,
|
||||||
|
step_id,
|
||||||
|
idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACCESS_TENANT_ERASURE_CAPABILITY",
|
||||||
|
"AccessTenantErasureProvider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from govoplan_access.backend.auth.dependencies import _resolve_legacy_principal_context
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, AuthSession, Role, ServiceAccount, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.security.api_keys import create_api_key
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class AuthCacheSecurityTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(self.engine)
|
||||||
|
self.revision_tables = [ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]
|
||||||
|
Base.metadata.create_all(self.engine, tables=self.revision_tables)
|
||||||
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
|
self.cache_setting = patch.object(settings, "auth_principal_cache_enabled", True)
|
||||||
|
self.cache_setting.start()
|
||||||
|
self.tenant = Tenant(id="cache-tenant", slug="cache-tenant", name="Cache tenant")
|
||||||
|
self.session.add(self.tenant)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.cache_setting.stop()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(self.engine)
|
||||||
|
scope_registry.metadata.drop_all(self.engine)
|
||||||
|
Base.metadata.drop_all(self.engine, tables=self.revision_tables)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def identity(self, *, service: bool = False) -> tuple[Account, User]:
|
||||||
|
account = Account(id="cache-account", email="cache@example.test", normalized_email="cache@example.test", auth_provider="service_account" if service else "local")
|
||||||
|
user = User(id="cache-user", tenant_id=self.tenant.id, account_id=account.id, email=account.email, auth_provider=account.auth_provider)
|
||||||
|
role = Role(id="cache-role", tenant_id=self.tenant.id, slug="reader", name="Reader", permissions=["files:file:read"])
|
||||||
|
assignment = UserRoleAssignment(tenant_id=self.tenant.id, user_id=user.id, role_id=role.id)
|
||||||
|
self.session.add_all([account, user, role, assignment])
|
||||||
|
self.session.commit()
|
||||||
|
return account, user
|
||||||
|
|
||||||
|
def resolve(self, token: str, *, cookie: bool = False, csrf: str | None = None):
|
||||||
|
headers = []
|
||||||
|
if cookie:
|
||||||
|
cookies = f"{settings.auth_session_cookie_name}={token}"
|
||||||
|
if csrf is not None:
|
||||||
|
cookies += f"; {settings.auth_csrf_cookie_name}={csrf}"
|
||||||
|
headers.append((b"x-csrf-token", csrf.encode()))
|
||||||
|
headers.append((b"cookie", cookies.encode()))
|
||||||
|
request = Request({"type": "http", "method": "POST", "path": "/protected", "headers": headers})
|
||||||
|
return _resolve_legacy_principal_context(request, self.session, authorization=None if cookie else f"Bearer {token}", x_api_key=None)
|
||||||
|
|
||||||
|
def test_warmed_api_key_is_never_accepted_as_a_session_cookie(self) -> None:
|
||||||
|
_, user = self.identity()
|
||||||
|
key = create_api_key(self.session, user=user, name="Test", scopes=["files:file:read"])
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaises(HTTPException) as cold:
|
||||||
|
self.resolve(key.secret, cookie=True)
|
||||||
|
self.assertEqual(401, cold.exception.status_code)
|
||||||
|
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||||
|
with self.assertRaises(HTTPException) as warm:
|
||||||
|
self.resolve(key.secret, cookie=True)
|
||||||
|
self.assertEqual(401, warm.exception.status_code)
|
||||||
|
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||||
|
|
||||||
|
def test_service_account_keeps_current_ceiling_and_provenance_with_cache_enabled(self) -> None:
|
||||||
|
account, user = self.identity(service=True)
|
||||||
|
item = ServiceAccount(id="cache-service", tenant_id=self.tenant.id, account_id=account.id, membership_id=user.id, name="Cache worker", normalized_name="cache worker", scope_ceiling=["dataflow:pipeline:run"])
|
||||||
|
# A credential issued before a ceiling reduction can retain wider stored
|
||||||
|
# scopes. Ordinary membership roles must not override the current ceiling.
|
||||||
|
key = create_api_key(self.session, user=user, name="Worker", scopes=["dataflow:pipeline:run", "files:file:read"])
|
||||||
|
self.session.add(item)
|
||||||
|
self.session.commit()
|
||||||
|
for _ in range(2):
|
||||||
|
context = self.resolve(key.secret)
|
||||||
|
self.assertEqual(frozenset({"dataflow:pipeline:run"}), context.principal.scopes)
|
||||||
|
self.assertEqual("service_account", context.principal.auth_method)
|
||||||
|
self.assertEqual(item.id, context.principal.service_account_id)
|
||||||
|
self.assertFalse(context.principal.role_ids)
|
||||||
|
item.scope_ceiling = []
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(frozenset(), self.resolve(key.secret).principal.scopes)
|
||||||
|
item.is_active = False
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaises(HTTPException) as inactive:
|
||||||
|
self.resolve(key.secret)
|
||||||
|
self.assertEqual(401, inactive.exception.status_code)
|
||||||
|
|
||||||
|
def test_warmed_session_cookie_still_requires_matching_csrf(self) -> None:
|
||||||
|
account, user = self.identity()
|
||||||
|
token, csrf = "ms_cache-session", "cache-csrf"
|
||||||
|
auth_session = AuthSession(id="cache-session", tenant_id=self.tenant.id, user_id=user.id, account_id=account.id, token_hash=hash_secret(token), csrf_token_hash=hash_secret(csrf), expires_at=utc_now() + timedelta(hours=1))
|
||||||
|
self.session.add(auth_session)
|
||||||
|
self.session.commit()
|
||||||
|
self.resolve(token)
|
||||||
|
for supplied in (None, "incorrect"):
|
||||||
|
with self.subTest(csrf=supplied), self.assertRaises(HTTPException) as denied:
|
||||||
|
self.resolve(token, cookie=True, csrf=supplied)
|
||||||
|
self.assertEqual(403, denied.exception.status_code)
|
||||||
|
self.assertEqual("session", self.resolve(token, cookie=True, csrf=csrf).principal.auth_method)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -29,6 +29,7 @@ from govoplan_core.tenancy.scope import (
|
|||||||
create_scope_tables,
|
create_scope_tables,
|
||||||
scope_registry,
|
scope_registry,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class AutomationPrincipalTests(unittest.TestCase):
|
class AutomationPrincipalTests(unittest.TestCase):
|
||||||
@@ -189,6 +190,14 @@ class AutomationPrincipalTests(unittest.TestCase):
|
|||||||
suspended.provenance["status"],
|
suspended.provenance["status"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_required_local_password_change_denies_delegated_automation(self) -> None:
|
||||||
|
self.account.password_reset_required = True
|
||||||
|
self.session.commit()
|
||||||
|
with patch.object(settings, "auth_local_password_recovery_enabled", True):
|
||||||
|
result = self.provider.resolve_automation_principal(self.session, request=self._request())
|
||||||
|
self.assertFalse(result.allowed)
|
||||||
|
self.assertEqual("password_change_required", result.provenance["status"])
|
||||||
|
|
||||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||||
account = Account(
|
account = Account(
|
||||||
id="service-account-backing",
|
id="service-account-backing",
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ from types import SimpleNamespace
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
ConfigurationPackageApplyResponse,
|
||||||
|
ConfigurationPackageExportResponse,
|
||||||
|
)
|
||||||
from govoplan_access.backend.api.v1.routes import _configuration_context
|
from govoplan_access.backend.api.v1.routes import _configuration_context
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceiptError,
|
||||||
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ExternalProviderRuntimeState,
|
ExternalProviderRuntimeState,
|
||||||
ExternalProviderStateProviderRegistration,
|
ExternalProviderStateProviderRegistration,
|
||||||
@@ -13,6 +20,87 @@ from govoplan_core.core.provider_governance import (
|
|||||||
|
|
||||||
|
|
||||||
class ConfigurationPackageContextTests(unittest.TestCase):
|
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||||
|
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
|
||||||
|
applied = ConfigurationPackageApplyResponse(
|
||||||
|
rollback={
|
||||||
|
"status": "database_restore_required",
|
||||||
|
"summary": "Snapshot is the generic rollback boundary.",
|
||||||
|
"recovery_action": "Retain the snapshot.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exported = ConfigurationPackageExportResponse(
|
||||||
|
provenance={
|
||||||
|
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||||
|
"source_core_version": "0.1.35",
|
||||||
|
"module_versions": {"forms": "0.1.20"},
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"exporter_id": "user-1",
|
||||||
|
"selection": {
|
||||||
|
"scopes": ["tenant"],
|
||||||
|
"module_ids": ["forms"],
|
||||||
|
"object_refs": [],
|
||||||
|
},
|
||||||
|
"redacted_secret_keys": ["credential_ref"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"database_restore_required",
|
||||||
|
applied.model_dump()["rollback"]["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["credential_ref"],
|
||||||
|
exported.model_dump()["provenance"]["redacted_secret_keys"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||||
|
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
scopes=frozenset({"system:settings:write"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||||
|
return_value=receipt,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal)
|
||||||
|
|
||||||
|
self.assertIs(receipt, context.infrastructure_receipt)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset({"system:settings:write"}),
|
||||||
|
context.operator_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
scopes=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||||
|
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal)
|
||||||
|
|
||||||
|
self.assertIsNone(context.infrastructure_receipt)
|
||||||
|
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
|
||||||
|
|
||||||
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
||||||
declaration = SimpleNamespace(
|
declaration = SimpleNamespace(
|
||||||
id="connectors.example",
|
id="connectors.example",
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||||
|
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||||
|
from govoplan_core.core.dsar import DsarSubjectRef
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite+pysqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="ada@example.test",
|
||||||
|
normalized_email="ada@example.test",
|
||||||
|
display_name="Ada Example",
|
||||||
|
password_hash="secret-hash",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email="ada@example.test",
|
||||||
|
display_name="Ada Example",
|
||||||
|
password_hash="tenant-secret-hash",
|
||||||
|
settings={"locale": "de"},
|
||||||
|
mail_profile_policy={"profile": "one"},
|
||||||
|
)
|
||||||
|
self.key = ApiKey(
|
||||||
|
id="key-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=self.user.id,
|
||||||
|
name="Automation",
|
||||||
|
prefix="gpn_example",
|
||||||
|
key_hash="do-not-export",
|
||||||
|
scopes=["files:read"],
|
||||||
|
)
|
||||||
|
self.auth_session = AuthSession(
|
||||||
|
id="session-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=self.user.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
token_hash="do-not-export",
|
||||||
|
csrf_token_hash="do-not-export",
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
user_agent="Browser fingerprint",
|
||||||
|
ip_address="192.0.2.10",
|
||||||
|
)
|
||||||
|
self.session.add_all([self.account, self.user, self.key, self.auth_session])
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = AccessDsarProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(email="ADA@example.test"),
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
self.assertIn("membership-1", serialized)
|
||||||
|
self.assertNotIn("do-not-export", serialized)
|
||||||
|
self.assertNotIn("Browser fingerprint", serialized)
|
||||||
|
self.assertNotIn("192.0.2.10", serialized)
|
||||||
|
|
||||||
|
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
membership_id=self.user.id,
|
||||||
|
email="different@example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
executable = tuple(action for action in actions if action.executable)
|
||||||
|
self.assertEqual(3, len(executable))
|
||||||
|
self.assertTrue(any(action.kind == "manual_review" for action in actions))
|
||||||
|
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
actions=executable,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"executed"}, {result.status for result in results})
|
||||||
|
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
|
||||||
|
self.assertFalse(self.user.is_active)
|
||||||
|
self.assertEqual({}, self.user.settings)
|
||||||
|
self.assertIsNotNone(self.key.revoked_at)
|
||||||
|
self.assertIsNotNone(self.auth_session.revoked_at)
|
||||||
|
self.assertIsNone(self.auth_session.user_agent)
|
||||||
|
self.assertIsNone(self.auth_session.ip_address)
|
||||||
|
self.assertEqual("ada@example.test", self.account.email)
|
||||||
|
|
||||||
|
repeated = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
actions=executable,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, event, inspect, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.routes import router
|
||||||
|
from govoplan_access.backend.auth.dependencies import get_api_principal
|
||||||
|
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.db.migrations import alembic_config
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
TABLE_NAME = "access_external_function_role_assignments"
|
||||||
|
REPAIR_REVISION = "d8f1b4e7a0c3"
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalFunctionMappingMigrationTests(unittest.TestCase):
|
||||||
|
def test_release_missing_table_repair(self) -> None:
|
||||||
|
self._verify_upgrade("release", missing=True)
|
||||||
|
|
||||||
|
def test_release_existing_mappings_preserved(self) -> None:
|
||||||
|
self._verify_upgrade("release", missing=False)
|
||||||
|
|
||||||
|
def test_dev_missing_table_repair(self) -> None:
|
||||||
|
self._verify_upgrade("dev", missing=True)
|
||||||
|
|
||||||
|
def test_dev_existing_mappings_preserved(self) -> None:
|
||||||
|
self._verify_upgrade("dev", missing=False)
|
||||||
|
|
||||||
|
def _verify_upgrade(self, track: str, *, missing: bool) -> None:
|
||||||
|
previous = "c7e0a3d6f9b2" if track == "release" else "b6d9f2a5c8e1"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-function-mapping-upgrade-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
|
||||||
|
config = alembic_config(database_url=url, enabled_modules=("access",), migration_track=track)
|
||||||
|
command.upgrade(config, "4f2a9c8e7b6d")
|
||||||
|
command.upgrade(config, previous)
|
||||||
|
engine = create_engine(url, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def enforce_foreign_keys(connection, _record) -> None:
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add_all([
|
||||||
|
Tenant(id="tenant-1", slug="tenant-1", name="Existing tenant"),
|
||||||
|
Tenant(id="tenant-2", slug="tenant-2", name="Other tenant"),
|
||||||
|
])
|
||||||
|
session.flush()
|
||||||
|
session.add_all([
|
||||||
|
Role(id="role-1", tenant_id="tenant-1", slug="role-1", name="Existing role", permissions=["access:function:read"]),
|
||||||
|
Role(id="role-2", tenant_id="tenant-2", slug="role-2", name="Other role", permissions=["access:role:read"]),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
if missing:
|
||||||
|
# Reproduce only in this isolated database: a recorded baseline
|
||||||
|
# with the exact missing table observed in the live 500 response.
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("DROP TABLE access_external_function_role_assignments"))
|
||||||
|
else:
|
||||||
|
self._insert_mapping(engine, "mapping-1", "tenant-1", "role-1")
|
||||||
|
self._insert_mapping(engine, "mapping-2", "tenant-2", "role-2")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
tables_before = set(inspect(connection).get_table_names())
|
||||||
|
parents_before = self._parent_rows(connection)
|
||||||
|
mappings_before = [] if missing else self._mapping_rows(connection)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix="/api/v1")
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset({"access:function:read"})),
|
||||||
|
account=None,
|
||||||
|
user=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session():
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = test_session
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as client:
|
||||||
|
path = "/api/v1/admin/external-function-role-mappings"
|
||||||
|
if missing:
|
||||||
|
self.assertEqual(client.get(f"{path}/delta").status_code, 500)
|
||||||
|
command.upgrade(config, REPAIR_REVISION)
|
||||||
|
command.upgrade(config, REPAIR_REVISION)
|
||||||
|
for suffix in ("", "/delta"):
|
||||||
|
response = client.get(f"{path}{suffix}")
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual([item["id"] for item in response.json()["mappings"]], [] if missing else ["mapping-1"])
|
||||||
|
self.assertEqual(response.json()["total"], 0 if missing else 1)
|
||||||
|
self.assertEqual(client.get(f"{path}{suffix}?tenant_id=tenant-2").status_code, 409)
|
||||||
|
principal.principal = PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset())
|
||||||
|
self.assertEqual(client.get(f"{path}/delta").status_code, 403)
|
||||||
|
|
||||||
|
with engine.connect() as connection:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
self.assertEqual(set(inspector.get_table_names()), tables_before | {TABLE_NAME})
|
||||||
|
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||||
|
self.assertEqual(self._mapping_rows(connection), mappings_before)
|
||||||
|
columns = inspector.get_columns(TABLE_NAME)
|
||||||
|
self.assertEqual({item["name"] for item in columns}, {"id", "tenant_id", "source_module", "function_id", "role_id", "settings", "created_at", "updated_at"})
|
||||||
|
self.assertTrue(all(not item["nullable"] for item in columns))
|
||||||
|
self.assertEqual(inspector.get_pk_constraint(TABLE_NAME)["constrained_columns"], ["id"])
|
||||||
|
self.assertIn(["tenant_id", "source_module", "function_id", "role_id"], [item["column_names"] for item in inspector.get_unique_constraints(TABLE_NAME)])
|
||||||
|
self.assertEqual({tuple(item["column_names"]) for item in inspector.get_indexes(TABLE_NAME)}, {("tenant_id",), ("role_id",), ("function_id",), ("source_module",)})
|
||||||
|
self.assertEqual({(tuple(item["constrained_columns"]), item["referred_table"], item["options"]["ondelete"]) for item in inspector.get_foreign_keys(TABLE_NAME)}, {(("role_id",), "access_roles", "CASCADE"), (("tenant_id",), "core_scopes", "CASCADE")})
|
||||||
|
|
||||||
|
self._insert_mapping(engine, "mapping-after-repair", "tenant-1", "role-1", function_id="new-function")
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
self._insert_mapping(engine, "duplicate", "tenant-1", "role-1", function_id="new-function")
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
self._insert_mapping(engine, "bad-role", "tenant-1", "missing-role")
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
self._insert_mapping(engine, "bad-tenant", "missing-tenant", "role-1")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
all_mappings = self._mapping_rows(connection)
|
||||||
|
command.downgrade(config, previous)
|
||||||
|
command.upgrade(config, REPAIR_REVISION)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertEqual(self._mapping_rows(connection), all_mappings)
|
||||||
|
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _insert_mapping(engine, mapping_id: str, tenant_id: str, role_id: str, *, function_id: str = "function-1") -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(ExternalFunctionRoleAssignment(
|
||||||
|
id=mapping_id, tenant_id=tenant_id, role_id=role_id,
|
||||||
|
source_module="organizations", function_id=function_id,
|
||||||
|
settings={"meaning": "Existing mapping", "nested": {"retained": True}},
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mapping_rows(connection):
|
||||||
|
return [dict(row) for row in connection.execute(text("SELECT * FROM access_external_function_role_assignments ORDER BY id")).mappings()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parent_rows(connection):
|
||||||
|
return {
|
||||||
|
"roles": [dict(row) for row in connection.execute(text("SELECT * FROM access_roles ORDER BY id")).mappings()],
|
||||||
|
"tenants": [dict(row) for row in connection.execute(text("SELECT * FROM core_scopes ORDER BY id")).mappings()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceTemplateMaterialization,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _command(index: int, *, kind: str = "role", operation: str = "upsert") -> GovernanceProjectionCommand:
|
||||||
|
return GovernanceProjectionCommand(
|
||||||
|
assignment_id=f"assignment-{kind}-{index}",
|
||||||
|
operation=operation, # type: ignore[arg-type]
|
||||||
|
template=GovernanceTemplateMaterialization(
|
||||||
|
template_id=f"template-{kind}",
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
tenant_id=f"tenant-{index}",
|
||||||
|
slug=f"managed-{kind}",
|
||||||
|
name=f"Managed {kind}",
|
||||||
|
permissions=("access:role:read",) if kind == "role" else (),
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
provenance={"source": "test", "assignment_mode": "required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceProjectionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.materializer = SqlAccessGovernanceMaterializer()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_bulk_projection_is_idempotent_and_returns_per_assignment_outcomes(self) -> None:
|
||||||
|
commands = tuple(_command(index) for index in range(5))
|
||||||
|
first = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="first", commands=commands),
|
||||||
|
)
|
||||||
|
second = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="second", commands=commands),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["created"] * 5, [item.status for item in first.outcomes])
|
||||||
|
self.assertEqual(["unchanged"] * 5, [item.status for item in second.outcomes])
|
||||||
|
self.assertEqual(5, self.session.query(Role).count())
|
||||||
|
self.assertEqual(
|
||||||
|
{item.assignment_id for item in commands},
|
||||||
|
{item.assignment_id for item in second.outcomes},
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item.provenance["source"] == "test" for item in second.outcomes))
|
||||||
|
|
||||||
|
def test_removal_isolated_blocker_preserves_other_batch_outcomes(self) -> None:
|
||||||
|
first, second = _command(1), _command(2)
|
||||||
|
created = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="create", commands=(first, second)),
|
||||||
|
)
|
||||||
|
roles = {item.tenant_id: item.resource_id for item in created.outcomes}
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="assigned@example.test",
|
||||||
|
normalized_email="assigned@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all([account, user])
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
UserRoleAssignment(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=user.id,
|
||||||
|
role_id=roles["tenant-1"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
removals = tuple(
|
||||||
|
GovernanceProjectionCommand(
|
||||||
|
assignment_id=item.assignment_id,
|
||||||
|
operation="remove",
|
||||||
|
template=item.template,
|
||||||
|
provenance=item.provenance,
|
||||||
|
)
|
||||||
|
for item in (first, second)
|
||||||
|
)
|
||||||
|
result = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="remove", commands=removals),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["blocked", "removed"], [item.status for item in result.outcomes])
|
||||||
|
self.assertEqual(("role_has_users",), result.outcomes[0].blocker_codes)
|
||||||
|
self.assertIsNotNone(self.session.get(Role, roles["tenant-1"]))
|
||||||
|
self.assertIsNone(self.session.get(Role, roles["tenant-2"]))
|
||||||
|
|
||||||
|
def test_dry_run_does_not_mutate(self) -> None:
|
||||||
|
result = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id="preview",
|
||||||
|
commands=(_command(1, kind="group"),),
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("created", result.outcomes[0].status)
|
||||||
|
self.assertEqual(0, self.session.query(Group).count())
|
||||||
|
|
||||||
|
def test_bulk_read_query_count_does_not_grow_per_assignment(self) -> None:
|
||||||
|
def select_count(size: int) -> int:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||||
|
nonlocal count
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||||
|
try:
|
||||||
|
self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id=f"preview-{size}",
|
||||||
|
commands=tuple(
|
||||||
|
_command(index, kind="group" if index % 2 else "role")
|
||||||
|
for index in range(size)
|
||||||
|
),
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||||
|
return count
|
||||||
|
|
||||||
|
small = select_count(2)
|
||||||
|
large = select_count(200)
|
||||||
|
self.assertEqual(small, large)
|
||||||
|
self.assertLessEqual(large, 4)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,6 +6,49 @@ from govoplan_access.backend.manifest import manifest
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_password_f1_contexts_resolve_once_to_concise_bilingual_topics(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"access.password.change": "access.help.password-change",
|
||||||
|
"access.password.recover": "access.help.password-recovery",
|
||||||
|
"access.password.issue-recovery": "access.help.password-issue-recovery",
|
||||||
|
}
|
||||||
|
for context, topic_id in expected.items():
|
||||||
|
with self.subTest(context=context):
|
||||||
|
matches = [topic for topic in manifest.documentation if context in (topic.metadata or {}).get("help_contexts", ())]
|
||||||
|
self.assertEqual([topic_id], [topic.id for topic in matches])
|
||||||
|
topic = matches[0]
|
||||||
|
self.assertEqual({"admin", "user"}, set(topic.documentation_types))
|
||||||
|
for body in (topic.body, topic.translations["de"]["body"]):
|
||||||
|
self.assertLessEqual(len(body.split()), 180)
|
||||||
|
self.assertIn("API", body)
|
||||||
|
self.assertIn("URLs", body)
|
||||||
|
if context != "access.password.change":
|
||||||
|
self.assertIn("15 minutes", topic.body)
|
||||||
|
self.assertIn("15 Minuten", topic.translations["de"]["body"])
|
||||||
|
|
||||||
|
def test_password_change_flag_documents_opt_in_and_complete_recovery(self) -> None:
|
||||||
|
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
|
||||||
|
self.assertIn("defaults to false", topic.body)
|
||||||
|
self.assertIn("remains advisory metadata", topic.body)
|
||||||
|
self.assertIn("standardmäßig false", topic.translations["de"]["body"])
|
||||||
|
recovery = next(item for item in manifest.documentation if item.id == "access.workflow.local-password-recovery")
|
||||||
|
for required in ("15 minutes", "single-use", "system:*", "human API keys", "e9a2c5f8b1d4", "does not send email"):
|
||||||
|
self.assertIn(required, recovery.body)
|
||||||
|
self.assertIn("issued by that account for other people", recovery.body)
|
||||||
|
self.assertIn("von diesem Konto für andere Personen ausgestellte", recovery.translations["de"]["body"])
|
||||||
|
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(german),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||||
topics = {topic.id: topic for topic in manifest.documentation}
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
|
||||||
@@ -22,9 +65,29 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"access.admin.tenant-users",
|
"access.admin.tenant-users",
|
||||||
"access.admin.tenant-groups",
|
"access.admin.tenant-groups",
|
||||||
"access.admin.tenant-roles",
|
"access.admin.tenant-roles",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-api-keys": {
|
||||||
"access.admin.api-keys",
|
"access.admin.api-keys",
|
||||||
"access.admin.service-accounts",
|
"access.api-keys.action.create",
|
||||||
|
"access.api-keys.action.revoke",
|
||||||
|
"access.api-keys.field.owner",
|
||||||
|
"access.api-keys.field.expiry",
|
||||||
|
"access.api-keys.field.scopes",
|
||||||
|
"access.api-keys.secret",
|
||||||
|
"access.api-keys.confirm-revoke",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-reusable-credentials": {
|
||||||
|
"access.admin.system-credentials",
|
||||||
|
"access.admin.tenant-credentials",
|
||||||
|
"access.admin.group-credentials",
|
||||||
|
"access.admin.user-credentials",
|
||||||
|
"access.settings.credentials",
|
||||||
"access.credentials",
|
"access.credentials",
|
||||||
|
"access.credentials.field.secret",
|
||||||
|
"access.credentials.field.clear-secret",
|
||||||
|
"access.credentials.field.inherit-to-lower-scopes",
|
||||||
|
"access.credentials.action.delete",
|
||||||
|
"access.credentials.confirm-delete",
|
||||||
},
|
},
|
||||||
"access.reference.external-function-role-mappings": {
|
"access.reference.external-function-role-mappings": {
|
||||||
"access.admin.function-mappings",
|
"access.admin.function-mappings",
|
||||||
@@ -32,6 +95,22 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
"access.workflow.manage-service-account-credentials": {
|
"access.workflow.manage-service-account-credentials": {
|
||||||
"access.admin.service-accounts",
|
"access.admin.service-accounts",
|
||||||
|
"access.service-accounts.action.create",
|
||||||
|
"access.service-accounts.action.activation",
|
||||||
|
"access.service-accounts.action.retire",
|
||||||
|
"access.service-accounts.field.scope-ceiling",
|
||||||
|
"access.service-accounts.action.rotate-credential",
|
||||||
|
"access.service-accounts.action.revoke-credential",
|
||||||
|
"access.service-accounts.field.credential-expiry",
|
||||||
|
"access.service-accounts.field.credential-scopes",
|
||||||
|
"access.service-accounts.secret",
|
||||||
|
"access.service-accounts.confirm-retire",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-sessions": {
|
||||||
|
"access.settings.sessions",
|
||||||
|
"access.sessions.action.revoke",
|
||||||
|
"access.sessions.action.revoke-others",
|
||||||
|
"access.admin.user-sessions",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +122,40 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
topic_id,
|
topic_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
credential_topic = topics["access.workflow.manage-reusable-credentials"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(credential_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"nicht rückgängig gemacht",
|
||||||
|
credential_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key_topic = topics["access.workflow.manage-api-keys"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(api_key_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
|
||||||
|
api_key_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
service_account_topic = topics[
|
||||||
|
"access.workflow.manage-service-account-credentials"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(service_account_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"widerruft sämtliche aktiven Zugangsdaten",
|
||||||
|
service_account_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_access_admin_surfaces_remain_declared(self) -> None:
|
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||||
surface_ids = {
|
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||||
surface.id for surface in manifest.frontend.view_surfaces
|
|
||||||
}
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
{
|
{
|
||||||
"access.admin.system-roles",
|
"access.admin.system-roles",
|
||||||
@@ -62,6 +171,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"access.admin.group-credentials",
|
"access.admin.group-credentials",
|
||||||
"access.admin.user-credentials",
|
"access.admin.user-credentials",
|
||||||
"access.settings.credentials",
|
"access.settings.credentials",
|
||||||
|
"access.settings.sessions",
|
||||||
}.issubset(surface_ids)
|
}.issubset(surface_ids)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class LoginSecurityTests(unittest.TestCase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _session_with_account(account: object | None) -> MagicMock:
|
def _session_with_account(account: object | None) -> MagicMock:
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
session.query.return_value.filter.return_value.populate_existing.return_value.with_for_update.return_value.one_or_none.return_value = (
|
||||||
account
|
account
|
||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
@@ -43,7 +43,7 @@ class LoginSecurityTests(unittest.TestCase):
|
|||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||||
account = SimpleNamespace(password_hash=account_hash)
|
account = SimpleNamespace(password_hash=account_hash, auth_provider="local")
|
||||||
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||||
|
|
||||||
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||||
@@ -55,7 +55,7 @@ class LoginSecurityTests(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||||
account = SimpleNamespace(password_hash=None)
|
account = SimpleNamespace(password_hash=None, auth_provider="local")
|
||||||
payload = LoginRequest(
|
payload = LoginRequest(
|
||||||
email="passwordless@example.test", password="not-a-user-password"
|
email="passwordless@example.test", password="not-a-user-password"
|
||||||
)
|
)
|
||||||
@@ -69,6 +69,7 @@ class LoginSecurityTests(unittest.TestCase):
|
|||||||
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||||
account = SimpleNamespace(
|
account = SimpleNamespace(
|
||||||
id="account-1",
|
id="account-1",
|
||||||
|
auth_provider="local",
|
||||||
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||||
)
|
)
|
||||||
session = self._session_with_account(account)
|
session = self._session_with_account(account)
|
||||||
|
|||||||
@@ -5,17 +5,30 @@ import pathlib
|
|||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
SystemSettingsItem,
|
||||||
|
TenantCreateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
class OptionalTenancyContractTests(unittest.TestCase):
|
class OptionalTenancyContractTests(unittest.TestCase):
|
||||||
|
def test_compatibility_admin_schemas_use_german_reference_default(self) -> None:
|
||||||
|
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||||
|
|
||||||
|
self.assertEqual("de", tenant.default_locale)
|
||||||
|
self.assertEqual("de", SystemSettingsItem().default_locale)
|
||||||
|
|
||||||
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
||||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
|
||||||
dependencies = tuple(project["dependencies"])
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
self.assertIn("govoplan-core>=0.1.11", dependencies)
|
self.assertTrue(
|
||||||
|
any(item.startswith("govoplan-core>=") for item in dependencies)
|
||||||
|
)
|
||||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
AccessApiPrincipalProvider,
|
||||||
|
get_api_principal,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
PasswordRecovery,
|
||||||
|
Role,
|
||||||
|
SystemRoleAssignment,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.security.api_keys import create_api_key
|
||||||
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
|
InMemoryLoginAttemptStore,
|
||||||
|
LoginThrottle,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||||
|
from govoplan_access.backend.security.password_change import replace_password
|
||||||
|
from govoplan_access.backend.security.sessions import create_auth_session
|
||||||
|
from govoplan_core.admin.models import SystemSettings
|
||||||
|
from govoplan_core.auth import get_api_principal as get_core_api_principal
|
||||||
|
from govoplan_core.core.access import CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
ChangeSequenceRetentionFloor,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecoveryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(self.engine)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
SystemSettings.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.factory = sessionmaker(bind=self.engine)
|
||||||
|
self.db = self.factory()
|
||||||
|
self.tenant = Tenant(id="tenant", slug="tenant", name="Tenant")
|
||||||
|
self.account = Account(
|
||||||
|
id="person",
|
||||||
|
email="person@example.test",
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
password_hash=hash_password("Initial-password"),
|
||||||
|
password_reset_required=True,
|
||||||
|
)
|
||||||
|
self.owner = Account(
|
||||||
|
id="owner",
|
||||||
|
email="owner@example.test",
|
||||||
|
normalized_email="owner@example.test",
|
||||||
|
password_hash=hash_password("Owner-password"),
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="person-member",
|
||||||
|
account_id="person",
|
||||||
|
tenant_id="tenant",
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
self.owner_user = User(
|
||||||
|
id="owner-member",
|
||||||
|
account_id="owner",
|
||||||
|
tenant_id="tenant",
|
||||||
|
email=self.owner.email,
|
||||||
|
)
|
||||||
|
role = Role(
|
||||||
|
id="owner-role",
|
||||||
|
tenant_id=None,
|
||||||
|
slug="system_owner",
|
||||||
|
name="System owner",
|
||||||
|
permissions=["system:*"],
|
||||||
|
)
|
||||||
|
self.owner_assignment = SystemRoleAssignment(
|
||||||
|
id="owner-assignment", account_id="owner", role_id=role.id
|
||||||
|
)
|
||||||
|
self.db.add_all(
|
||||||
|
[
|
||||||
|
self.tenant,
|
||||||
|
self.account,
|
||||||
|
self.owner,
|
||||||
|
self.user,
|
||||||
|
self.owner_user,
|
||||||
|
role,
|
||||||
|
self.owner_assignment,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.db.flush()
|
||||||
|
self.current = create_auth_session(self.db, user=self.user)
|
||||||
|
self.other = create_auth_session(self.db, user=self.user)
|
||||||
|
self.owner_session = create_auth_session(self.db, user=self.owner_user)
|
||||||
|
self.key = create_api_key(
|
||||||
|
self.db, user=self.user, name="Human automation", scopes=[]
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
self.audit = patch.object(auth, "audit_event").start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
patch.object(settings, "auth_local_password_recovery_enabled", True).start()
|
||||||
|
patch.object(settings, "auth_principal_cache_enabled", True).start()
|
||||||
|
patch.object(settings, "auth_login_throttle_enabled", False).start()
|
||||||
|
self.throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=50,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=900,
|
||||||
|
)
|
||||||
|
patch.object(
|
||||||
|
auth, "_password_operation_throttle", return_value=self.throttle
|
||||||
|
).start()
|
||||||
|
app = FastAPI()
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=settings)
|
||||||
|
)
|
||||||
|
registry.register_capability_factory(
|
||||||
|
"access",
|
||||||
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
lambda _context: AccessApiPrincipalProvider(),
|
||||||
|
)
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
def protected(principal=Depends(get_api_principal)):
|
||||||
|
return {"account": principal.account_id}
|
||||||
|
|
||||||
|
@app.get("/core-protected")
|
||||||
|
def core_protected(principal=Depends(get_core_api_principal)):
|
||||||
|
return {"account": principal.account_id}
|
||||||
|
|
||||||
|
def session_dependency():
|
||||||
|
with self.factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except BaseException:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = session_dependency
|
||||||
|
self.client = TestClient(app)
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
self.db.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
|
def headers(self, created=None):
|
||||||
|
return {"authorization": "Bearer " + (created or self.current).token}
|
||||||
|
|
||||||
|
def change(self, **overrides):
|
||||||
|
return self.client.post(
|
||||||
|
"/api/v1/auth/password/change",
|
||||||
|
headers=self.headers(),
|
||||||
|
json={
|
||||||
|
"current_password": "Initial-password",
|
||||||
|
"new_password": "New-secret-password",
|
||||||
|
**overrides,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def issue(self):
|
||||||
|
return self.client.post(
|
||||||
|
"/api/v1/auth/password/recovery/person",
|
||||||
|
headers=self.headers(self.owner_session),
|
||||||
|
json={"current_password": "Owner-password", "identity_verified": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
def recover(self, code, **overrides):
|
||||||
|
return self.client.post(
|
||||||
|
"/api/v1/auth/password/recover",
|
||||||
|
json={
|
||||||
|
"email": "person@example.test",
|
||||||
|
"recovery_code": code,
|
||||||
|
"new_password": "Recovered-password",
|
||||||
|
**overrides,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_first_login_restricts_then_rotates_and_revokes_all_credentials(self):
|
||||||
|
login = self.client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": self.account.email, "password": "Initial-password"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, login.status_code, login.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"change_password", login.json()["user"]["required_auth_action"]
|
||||||
|
)
|
||||||
|
self.assertEqual([], login.json()["scopes"])
|
||||||
|
self.assertEqual(
|
||||||
|
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
self.client.patch(
|
||||||
|
"/api/v1/auth/profile",
|
||||||
|
headers=self.headers(),
|
||||||
|
json={"display_name": "Escaped"},
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
for path in ("session", "shell"):
|
||||||
|
result = self.client.get("/api/v1/auth/" + path, headers=self.headers())
|
||||||
|
self.assertEqual(200, result.status_code, result.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"change_password", result.json()["user"]["required_auth_action"]
|
||||||
|
)
|
||||||
|
changed = self.change()
|
||||||
|
self.assertEqual(200, changed.status_code, changed.text)
|
||||||
|
self.assertIsNone(changed.json()["user"]["required_auth_action"])
|
||||||
|
self.assertNotEqual(self.current.token, changed.json()["access_token"])
|
||||||
|
for old in (self.current.token, self.other.token, self.key.secret):
|
||||||
|
self.assertEqual(
|
||||||
|
401,
|
||||||
|
self.client.get(
|
||||||
|
"/protected", headers={"authorization": "Bearer " + old}
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
200,
|
||||||
|
self.client.get(
|
||||||
|
"/protected",
|
||||||
|
headers={"authorization": "Bearer " + changed.json()["access_token"]},
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("New-secret-password", self.account.password_hash)
|
||||||
|
)
|
||||||
|
self.assertTrue(verify_password("New-secret-password", self.user.password_hash))
|
||||||
|
self.assertFalse(self.account.password_reset_required)
|
||||||
|
self.assertNotIn("New-secret-password", str(self.audit.call_args_list))
|
||||||
|
self.assertNotIn("Initial-password", str(self.audit.call_args_list))
|
||||||
|
|
||||||
|
def test_failed_current_password_leaves_credentials_and_flag_unchanged(self):
|
||||||
|
result = self.change(current_password="wrong-password")
|
||||||
|
self.assertEqual(403, result.status_code, result.text)
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertTrue(self.account.password_reset_required)
|
||||||
|
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||||
|
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||||
|
self.assertEqual(422, self.change(new_password="Initial-password").status_code)
|
||||||
|
|
||||||
|
def test_core_api_provider_enforces_current_flag_for_sessions_and_keys(self):
|
||||||
|
for headers in (self.headers(), {"x-api-key": self.key.secret}):
|
||||||
|
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||||
|
self.assertEqual(
|
||||||
|
200, self.client.get("/core-protected", headers=headers).status_code
|
||||||
|
)
|
||||||
|
result = self.client.get("/core-protected", headers=headers)
|
||||||
|
self.assertEqual(403, result.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"password_change_required", result.json()["detail"]["code"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validation_responses_do_not_echo_rejected_secrets(self):
|
||||||
|
response = self.change(new_password="tiny")
|
||||||
|
self.assertEqual(422, response.status_code)
|
||||||
|
self.assertNotIn("tiny", response.text)
|
||||||
|
self.assertNotIn("Initial-password", response.text)
|
||||||
|
self.assertNotIn("input", response.json()["detail"][0])
|
||||||
|
|
||||||
|
def test_cookie_change_requires_matching_csrf_and_replaces_csrf(self):
|
||||||
|
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||||
|
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||||
|
payload = {
|
||||||
|
"current_password": "Initial-password",
|
||||||
|
"new_password": "New-secret-password",
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
self.client.post("/api/v1/auth/password/change", json=payload).status_code,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/auth/password/change",
|
||||||
|
headers={"x-csrf-token": self.current.csrf_token},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertIn("HttpOnly", response.headers["set-cookie"])
|
||||||
|
self.assertNotIn(self.current.csrf_token, response.headers["set-cookie"])
|
||||||
|
|
||||||
|
def test_opt_in_preserves_advisory_behavior_and_still_allows_change(self):
|
||||||
|
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||||
|
self.assertEqual(
|
||||||
|
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.client.get("/api/v1/auth/session", headers=self.headers()).json()[
|
||||||
|
"user"
|
||||||
|
]["required_auth_action"]
|
||||||
|
)
|
||||||
|
self.assertEqual(409, self.issue().status_code)
|
||||||
|
self.assertEqual(409, self.recover("unknown").status_code)
|
||||||
|
self.assertEqual(200, self.change().status_code)
|
||||||
|
|
||||||
|
def test_warm_cache_and_human_api_keys_do_not_bypass_flag(self):
|
||||||
|
self.account.password_reset_required = False
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(
|
||||||
|
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
200,
|
||||||
|
self.client.get(
|
||||||
|
"/protected", headers={"x-api-key": self.key.secret}
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.account.password_reset_required = True
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(
|
||||||
|
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
self.client.get(
|
||||||
|
"/protected", headers={"x-api-key": self.key.secret}
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
self.client.get(
|
||||||
|
"/api/v1/auth/session", headers={"x-api-key": self.key.secret}
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/auth/password/change",
|
||||||
|
headers={"x-api-key": self.key.secret},
|
||||||
|
json={
|
||||||
|
"current_password": "Initial-password",
|
||||||
|
"new_password": "New-secret-password",
|
||||||
|
},
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.account.password_reset_required = False
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(
|
||||||
|
400,
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/auth/password/change",
|
||||||
|
headers={"x-api-key": self.key.secret},
|
||||||
|
json={
|
||||||
|
"current_password": "Initial-password",
|
||||||
|
"new_password": "New-secret-password",
|
||||||
|
},
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_provider_is_not_forced_or_allowed_to_change_local_password(self):
|
||||||
|
for provider in ("oidc", "service_account"):
|
||||||
|
with self.subTest(provider=provider):
|
||||||
|
self.account.auth_provider = provider
|
||||||
|
self.db.commit()
|
||||||
|
session_info = self.client.get(
|
||||||
|
"/api/v1/auth/session", headers=self.headers()
|
||||||
|
)
|
||||||
|
self.assertIsNone(session_info.json()["user"]["required_auth_action"])
|
||||||
|
self.assertFalse(session_info.json()["user"]["local_password"])
|
||||||
|
self.assertEqual(403, self.change().status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
401,
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": self.account.email,
|
||||||
|
"password": "Initial-password",
|
||||||
|
},
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recovery_code_is_hashed_one_use_and_revokes_sessions_and_keys(self):
|
||||||
|
response = self.issue()
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
code = response.json()["recovery_code"]
|
||||||
|
stored = self.db.query(PasswordRecovery).one()
|
||||||
|
self.assertNotEqual(code, stored.code_hash)
|
||||||
|
self.assertNotIn(code, str(self.audit.call_args_list))
|
||||||
|
self.assertEqual("no-store", response.headers["cache-control"])
|
||||||
|
self.assertEqual(200, self.recover(code).status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
400, self.recover(code, new_password="Another-secret").status_code
|
||||||
|
)
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("Recovered-password", self.account.password_hash)
|
||||||
|
)
|
||||||
|
self.assertFalse(self.account.password_reset_required)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
self.db.query(AuthSession)
|
||||||
|
.filter(
|
||||||
|
AuthSession.account_id == self.account.id,
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.db.get(ApiKey, self.key.model.id).revoked_at)
|
||||||
|
|
||||||
|
def test_expiry_supersession_and_current_issuer_authority(self):
|
||||||
|
first = self.issue().json()["recovery_code"]
|
||||||
|
second = self.issue().json()["recovery_code"]
|
||||||
|
self.assertEqual(400, self.recover(first).status_code)
|
||||||
|
latest = (
|
||||||
|
self.db.query(PasswordRecovery)
|
||||||
|
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
latest.expires_at = utc_now() - timedelta(seconds=1)
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(second).status_code)
|
||||||
|
third = self.issue().json()["recovery_code"]
|
||||||
|
self.db.delete(self.owner_assignment)
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(third).status_code)
|
||||||
|
self.assertEqual(403, self.issue().status_code)
|
||||||
|
|
||||||
|
def test_recovery_rechecks_account_and_membership_state(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
self.user.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(code).status_code)
|
||||||
|
self.user.is_active = True
|
||||||
|
self.account.auth_provider = "oidc"
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(code).status_code)
|
||||||
|
|
||||||
|
def test_recovery_rechecks_tenant_and_issuer_activation(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
self.tenant.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(code).status_code)
|
||||||
|
self.tenant.is_active = True
|
||||||
|
self.owner.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(400, self.recover(code).status_code)
|
||||||
|
|
||||||
|
def test_recovery_requires_current_owner_password_and_identity_verification(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/auth/password/recovery/person",
|
||||||
|
headers=self.headers(self.owner_session),
|
||||||
|
json={"current_password": "wrong-password", "identity_verified": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(403, response.status_code)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/auth/password/recovery/person",
|
||||||
|
headers=self.headers(self.owner_session),
|
||||||
|
json={"current_password": "Owner-password", "identity_verified": False},
|
||||||
|
)
|
||||||
|
self.assertEqual(422, response.status_code)
|
||||||
|
self.assertEqual(0, self.db.query(PasswordRecovery).count())
|
||||||
|
role = self.db.get(Role, "owner-role")
|
||||||
|
role.permissions = ["system:accounts:update"]
|
||||||
|
self.db.commit()
|
||||||
|
self.assertEqual(403, self.issue().status_code)
|
||||||
|
|
||||||
|
def test_invalid_recovery_email_or_unchanged_password_does_not_consume_code(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
response = self.recover(code, email="different@example.test")
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
422, self.recover(code, new_password="Initial-password").status_code
|
||||||
|
)
|
||||||
|
self.assertEqual(200, self.recover(code).status_code)
|
||||||
|
|
||||||
|
def test_recovery_abuse_is_bounded_even_with_login_throttle_disabled(self):
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=900,
|
||||||
|
)
|
||||||
|
with patch.object(auth, "_password_operation_throttle", return_value=throttle):
|
||||||
|
self.assertEqual(400, self.recover("wrong-code").status_code)
|
||||||
|
response = self.recover("different-wrong-code")
|
||||||
|
self.assertEqual(429, response.status_code)
|
||||||
|
self.assertIn("retry-after", response.headers)
|
||||||
|
|
||||||
|
def test_failed_audit_rolls_back_password_and_session_rotation(self):
|
||||||
|
with patch.object(
|
||||||
|
auth, "audit_event", side_effect=RuntimeError("audit unavailable")
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.change()
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||||
|
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||||
|
|
||||||
|
def test_stale_password_authorization_cannot_overwrite_concurrent_change(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
with self.factory() as competing:
|
||||||
|
current = competing.get(Account, self.account.id)
|
||||||
|
replace_password(competing, account=current, password="Concurrent-password")
|
||||||
|
competing.commit()
|
||||||
|
with self.assertRaises(HTTPException) as conflict:
|
||||||
|
# This intentionally uses the account state read before the other
|
||||||
|
# transaction, modelling a database without row-lock support.
|
||||||
|
replace_password(self.db, account=self.account, password="Stale-password")
|
||||||
|
self.assertEqual(409, conflict.exception.status_code)
|
||||||
|
self.db.rollback()
|
||||||
|
self.db.refresh(self.account)
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("Concurrent-password", self.account.password_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_current_password_change_invalidates_outstanding_recovery(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
self.assertEqual(200, self.change().status_code)
|
||||||
|
self.assertEqual(400, self.recover(code).status_code)
|
||||||
|
|
||||||
|
def test_owner_password_change_invalidates_codes_issued_for_other_accounts(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
changed = self.client.post(
|
||||||
|
"/api/v1/auth/password/change",
|
||||||
|
headers=self.headers(self.owner_session),
|
||||||
|
json={
|
||||||
|
"current_password": "Owner-password",
|
||||||
|
"new_password": "Owner-new-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, changed.status_code, changed.text)
|
||||||
|
rejected = self.recover(code)
|
||||||
|
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||||
|
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertIsNotNone(self.db.query(PasswordRecovery).one().consumed_at)
|
||||||
|
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||||
|
self.assertEqual(
|
||||||
|
1, self.audit.call_args.kwargs["details"]["revoked_password_recoveries"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_owner_password_recovery_invalidates_codes_issued_for_other_accounts(self):
|
||||||
|
code = self.issue().json()["recovery_code"]
|
||||||
|
owner_recovery = self.client.post(
|
||||||
|
"/api/v1/auth/password/recovery/owner",
|
||||||
|
headers=self.headers(self.owner_session),
|
||||||
|
json={"current_password": "Owner-password", "identity_verified": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, owner_recovery.status_code, owner_recovery.text)
|
||||||
|
recovered = self.recover(
|
||||||
|
owner_recovery.json()["recovery_code"],
|
||||||
|
email=self.owner.email,
|
||||||
|
new_password="Owner-recovered-password",
|
||||||
|
)
|
||||||
|
self.assertEqual(200, recovered.status_code, recovered.text)
|
||||||
|
rejected = self.recover(code)
|
||||||
|
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||||
|
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("Owner-recovered-password", self.owner.password_hash)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
self.db.query(PasswordRecovery)
|
||||||
|
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_flagged_account_can_sign_out_and_requires_csrf_for_cookie_logout(self):
|
||||||
|
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||||
|
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||||
|
self.assertEqual(403, self.client.post("/api/v1/auth/logout").status_code)
|
||||||
|
result = self.client.post(
|
||||||
|
"/api/v1/auth/logout", headers={"x-csrf-token": self.current.csrf_token}
|
||||||
|
)
|
||||||
|
self.assertEqual(200, result.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
401,
|
||||||
|
self.client.get("/api/v1/auth/session", headers=self.headers()).status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account
|
||||||
|
from govoplan_access.backend.security.passwords import hash_password
|
||||||
|
from govoplan_core.db.migrations import alembic_config
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRecoveryMigrationTests(unittest.TestCase):
|
||||||
|
def test_release_and_development_upgrade_preserve_existing_credentials(self):
|
||||||
|
for track in ("release", "dev"):
|
||||||
|
with (
|
||||||
|
self.subTest(track=track),
|
||||||
|
tempfile.TemporaryDirectory(
|
||||||
|
prefix="govoplan-password-migration-"
|
||||||
|
) as directory,
|
||||||
|
):
|
||||||
|
url = f"sqlite:///{Path(directory) / 'isolated-upgrade.db'}"
|
||||||
|
config = alembic_config(
|
||||||
|
database_url=url, enabled_modules=("access",), migration_track=track
|
||||||
|
)
|
||||||
|
command.upgrade(config, "4f2a9c8e7b6d")
|
||||||
|
command.upgrade(config, "d8f1b4e7a0c3")
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(
|
||||||
|
Account(
|
||||||
|
id="existing",
|
||||||
|
email="existing@example.test",
|
||||||
|
normalized_email="existing@example.test",
|
||||||
|
password_hash=hash_password("Existing-password"),
|
||||||
|
password_reset_required=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
with engine.connect() as connection:
|
||||||
|
before = list(
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT * FROM access_accounts")
|
||||||
|
).mappings()
|
||||||
|
)
|
||||||
|
tables = set(inspect(connection).get_table_names())
|
||||||
|
command.upgrade(config, "e9a2c5f8b1d4")
|
||||||
|
command.upgrade(config, "e9a2c5f8b1d4")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
self.assertEqual(
|
||||||
|
tables | {"access_password_recoveries"},
|
||||||
|
set(inspector.get_table_names()),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
before,
|
||||||
|
list(
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT * FROM access_accounts")
|
||||||
|
).mappings()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"id",
|
||||||
|
"account_id",
|
||||||
|
"issuer_account_id",
|
||||||
|
"issuer_membership_id",
|
||||||
|
"code_hash",
|
||||||
|
"expires_at",
|
||||||
|
"consumed_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column["name"]
|
||||||
|
for column in inspector.get_columns(
|
||||||
|
"access_password_recoveries"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
3,
|
||||||
|
len(
|
||||||
|
inspector.get_foreign_keys("access_password_recoveries")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
["code_hash"],
|
||||||
|
[
|
||||||
|
constraint["column_names"]
|
||||||
|
for constraint in inspector.get_unique_constraints(
|
||||||
|
"access_password_recoveries"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
import test_password_recovery as recovery_fixture
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.db.models import PasswordRecovery
|
||||||
|
from govoplan_access.backend.security.passwords import verify_password
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
os.environ.get("GOVOPLAN_ACCESS_TEST_POSTGRES_URL"),
|
||||||
|
"set GOVOPLAN_ACCESS_TEST_POSTGRES_URL to a disposable PostgreSQL test database",
|
||||||
|
)
|
||||||
|
class PasswordRecoveryPostgresTests(unittest.TestCase):
|
||||||
|
"""Opt-in real transaction races; no application/default database fallback."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
url = make_url(os.environ["GOVOPLAN_ACCESS_TEST_POSTGRES_URL"])
|
||||||
|
if url.get_backend_name() != "postgresql" or not url.database:
|
||||||
|
raise ValueError("An explicit disposable PostgreSQL database is required")
|
||||||
|
self.schema = "access_password_race_" + uuid.uuid4().hex
|
||||||
|
self.admin_engine = create_engine(url)
|
||||||
|
self.addCleanup(self.admin_engine.dispose)
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
connection.execute(text(f'CREATE SCHEMA "{self.schema}"'))
|
||||||
|
self.addCleanup(self._drop_schema)
|
||||||
|
self.engine = create_engine(
|
||||||
|
url,
|
||||||
|
connect_args={
|
||||||
|
"options": (
|
||||||
|
f"-c search_path={self.schema} -c statement_timeout=30000 "
|
||||||
|
"-c lock_timeout=15000 -c idle_in_transaction_session_timeout=60000"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.addCleanup(self.engine.dispose)
|
||||||
|
self.access = recovery_fixture.PasswordRecoveryTests(methodName="runTest")
|
||||||
|
self.addCleanup(self._close_fixture)
|
||||||
|
# Reuse the synthetic HTTP/security fixture, replacing only its SQLite
|
||||||
|
# engine. The production account locks/CAS and request SQL sessions run.
|
||||||
|
with patch.object(recovery_fixture, "create_engine", return_value=self.engine):
|
||||||
|
self.access.setUp()
|
||||||
|
|
||||||
|
def _drop_schema(self) -> None:
|
||||||
|
# The identifier is generated here, never read from the supplied URL.
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
connection.execute(text(f'DROP SCHEMA "{self.schema}" CASCADE'))
|
||||||
|
|
||||||
|
def _close_fixture(self) -> None:
|
||||||
|
try:
|
||||||
|
if hasattr(self.access, "client"):
|
||||||
|
self.access.client.close()
|
||||||
|
if hasattr(self.access, "db"):
|
||||||
|
self.access.db.close()
|
||||||
|
finally:
|
||||||
|
self.access.doCleanups()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
|
def _post(self, endpoint, payload, headers=None):
|
||||||
|
# Separate cookie jars and real per-request SQL sessions in each thread.
|
||||||
|
with TestClient(self.access.client.app) as client:
|
||||||
|
return client.post(
|
||||||
|
"/api/v1/auth/password/" + endpoint,
|
||||||
|
json=payload,
|
||||||
|
headers=headers or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _race(self, endpoint, payloads, *, headers=None, locked_account="person"):
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
real_lock = auth.locked_local_account
|
||||||
|
|
||||||
|
def synchronized_lock(session, account_id):
|
||||||
|
if account_id == locked_account:
|
||||||
|
barrier.wait(timeout=10)
|
||||||
|
return real_lock(session, account_id)
|
||||||
|
|
||||||
|
with patch.object(auth, "locked_local_account", side_effect=synchronized_lock):
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(self._post, endpoint, payload, headers)
|
||||||
|
for payload in payloads
|
||||||
|
]
|
||||||
|
return [future.result(timeout=35) for future in futures]
|
||||||
|
|
||||||
|
def test_same_recovery_code_has_exactly_one_winner(self):
|
||||||
|
issued = self.access.issue()
|
||||||
|
self.assertEqual(200, issued.status_code)
|
||||||
|
code = issued.json()["recovery_code"]
|
||||||
|
passwords = ["Concurrent-recovery-one", "Concurrent-recovery-two"]
|
||||||
|
responses = self._race(
|
||||||
|
"recover",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"email": "person@example.test",
|
||||||
|
"recovery_code": code,
|
||||||
|
"new_password": value,
|
||||||
|
}
|
||||||
|
for value in passwords
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual([200, 400], sorted(item.status_code for item in responses))
|
||||||
|
winner = next(
|
||||||
|
index for index, item in enumerate(responses) if item.status_code == 200
|
||||||
|
)
|
||||||
|
self.access.db.expire_all()
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password(passwords[winner], self.access.account.password_hash)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||||
|
|
||||||
|
def test_same_old_password_has_exactly_one_winner(self):
|
||||||
|
passwords = ["Concurrent-change-one", "Concurrent-change-two"]
|
||||||
|
responses = self._race(
|
||||||
|
"change",
|
||||||
|
[
|
||||||
|
{"current_password": "Initial-password", "new_password": value}
|
||||||
|
for value in passwords
|
||||||
|
],
|
||||||
|
headers=self.access.headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual([200, 401], sorted(item.status_code for item in responses))
|
||||||
|
winner = next(
|
||||||
|
index for index, item in enumerate(responses) if item.status_code == 200
|
||||||
|
)
|
||||||
|
self.access.db.expire_all()
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password(passwords[winner], self.access.account.password_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_competing_issuance_leaves_exactly_one_usable_code(self):
|
||||||
|
responses = self._race(
|
||||||
|
"recovery/person",
|
||||||
|
[{"current_password": "Owner-password", "identity_verified": True}] * 2,
|
||||||
|
headers=self.access.headers(self.access.owner_session),
|
||||||
|
locked_account="owner",
|
||||||
|
)
|
||||||
|
self.assertEqual([200, 200], [item.status_code for item in responses])
|
||||||
|
codes = [item.json()["recovery_code"] for item in responses]
|
||||||
|
self.assertEqual(2, len(set(codes)))
|
||||||
|
self.access.db.expire_all()
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
self.access.db.query(PasswordRecovery)
|
||||||
|
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[200, 400], sorted(self.access.recover(code).status_code for code in codes)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_issuer_password_revocation_wins_paused_redemption(self):
|
||||||
|
issued = self.access.issue()
|
||||||
|
self.assertEqual(200, issued.status_code)
|
||||||
|
code = issued.json()["recovery_code"]
|
||||||
|
reached = threading.Event()
|
||||||
|
proceed = threading.Event()
|
||||||
|
real_authorized = auth.recovery_issuer_authorized
|
||||||
|
|
||||||
|
def paused_authorization(*args, **kwargs):
|
||||||
|
reached.set()
|
||||||
|
self.assertTrue(proceed.wait(timeout=35))
|
||||||
|
return real_authorized(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
auth, "recovery_issuer_authorized", side_effect=paused_authorization
|
||||||
|
):
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
redemption = executor.submit(
|
||||||
|
self._post,
|
||||||
|
"recover",
|
||||||
|
{
|
||||||
|
"email": "person@example.test",
|
||||||
|
"recovery_code": code,
|
||||||
|
"new_password": "Must-not-replace-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.assertTrue(reached.wait(timeout=10))
|
||||||
|
changed = self._post(
|
||||||
|
"change",
|
||||||
|
{
|
||||||
|
"current_password": "Owner-password",
|
||||||
|
"new_password": "Owner-race-password",
|
||||||
|
},
|
||||||
|
self.access.headers(self.access.owner_session),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, changed.status_code)
|
||||||
|
finally:
|
||||||
|
proceed.set()
|
||||||
|
rejected = redemption.result(timeout=35)
|
||||||
|
self.assertEqual(400, rejected.status_code)
|
||||||
|
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||||
|
self.access.db.expire_all()
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("Initial-password", self.access.account.password_hash)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
verify_password("Owner-race-password", self.access.owner.password_hash)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
|
|||||||
self.assertIn("files:file:read", scopes)
|
self.assertIn("files:file:read", scopes)
|
||||||
self.assertIn("files:read", scopes)
|
self.assertIn("files:read", scopes)
|
||||||
|
|
||||||
|
def test_api_key_intersection_excludes_canonical_and_legacy_system_scopes(self) -> None:
|
||||||
|
for scope in ("access:system_credential:write", "access:tenant:create", "system:tenants:create"):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
self.assertEqual([], access_catalog.intersect_api_key_scopes([scope], [scope]))
|
||||||
|
|
||||||
|
def test_api_key_module_wildcards_expand_only_to_concrete_tenant_scopes(self) -> None:
|
||||||
|
scopes = access_catalog.intersect_api_key_scopes(["access:*"], ["access:*"])
|
||||||
|
self.assertIn("access:membership:read", scopes)
|
||||||
|
self.assertNotIn("access:*", scopes)
|
||||||
|
self.assertFalse(access_catalog.scopes_grant(scopes, "access:system_credential:write"))
|
||||||
|
catalog = access_catalog.permission_map()
|
||||||
|
self.assertTrue(all(catalog[scope].level == "tenant" for scope in scopes if scope in catalog))
|
||||||
|
|
||||||
|
def test_api_key_intersection_preserves_unknown_concrete_module_grants(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
["optional-module:record:read"],
|
||||||
|
access_catalog.intersect_api_key_scopes(["optional-module:record:read"], ["optional-module:record:read"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_key_intersection_preserves_concrete_tenant_compatibility_aliases(self) -> None:
|
||||||
|
scopes = access_catalog.intersect_api_key_scopes(["files:read"], ["files:file:read"])
|
||||||
|
self.assertIn("files:read", scopes)
|
||||||
|
self.assertIn("files:file:read", scopes)
|
||||||
|
self.assertTrue(access_catalog.scopes_grant(scopes, "files:file:read"))
|
||||||
|
|
||||||
|
def test_api_key_intersection_does_not_retain_unknown_wildcards(self) -> None:
|
||||||
|
self.assertEqual([], access_catalog.intersect_api_key_scopes(["optional-module:*"], ["optional-module:*"]))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.api.v1.routes import (
|
||||||
|
_access_explanation_subject_decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SubjectPolicy:
|
||||||
|
def decide_subject_selection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
del session, principal, tenant_id
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=True,
|
||||||
|
reason="Permitted by test policy.",
|
||||||
|
source="test.policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: object | None = None) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
and self.provider is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
|
||||||
|
def test_missing_policy_defaults_to_current_user(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(decision.allow_other_users)
|
||||||
|
self.assertEqual("access.safe_default", decision.source)
|
||||||
|
|
||||||
|
def test_policy_capability_controls_cross_user_selection(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=_Registry(_SubjectPolicy()),
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(decision.allow_other_users)
|
||||||
|
self.assertEqual("test.policy", decision.source)
|
||||||
|
|
||||||
|
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
|
||||||
|
source = (
|
||||||
|
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('User.tenant_id == tenant.id', source)
|
||||||
|
self.assertIn('User.id == principal.membership_id', source)
|
||||||
|
self.assertIn(
|
||||||
|
'action="access.resource_explanation.selected_user_viewed"',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -225,7 +225,7 @@ class ServiceAccountTests(unittest.TestCase):
|
|||||||
expires_at=None,
|
expires_at=None,
|
||||||
)
|
)
|
||||||
self.assertEqual(2, item.revision)
|
self.assertEqual(2, item.revision)
|
||||||
self.assertTrue(first.secret.startswith("mm_"))
|
self.assertTrue(first.secret.startswith("gpn_"))
|
||||||
self.assertNotEqual(first.secret, first.model.key_hash)
|
self.assertNotEqual(first.secret, first.model.key_hash)
|
||||||
|
|
||||||
item, previous, replacement = rotate_service_account_credential(
|
item, previous, replacement = rotate_service_account_credential(
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, AuthSession, User
|
||||||
|
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
|
||||||
|
from govoplan_access.backend.security.passwords import hash_password
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
|
||||||
|
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
|
||||||
|
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
MAX_CLIENT_LABEL_LENGTH,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
revoke_other_account_sessions,
|
||||||
|
)
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManagementTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="person@example.test",
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
)
|
||||||
|
other_account = Account(
|
||||||
|
id="account-2",
|
||||||
|
email="other@example.test",
|
||||||
|
normalized_email="other@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
other_user = User(
|
||||||
|
id="user-2",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=other_account.id,
|
||||||
|
email=other_account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
|
||||||
|
self.session.flush()
|
||||||
|
self.tokens = {
|
||||||
|
"current": "ms_current",
|
||||||
|
"other": "ms_other",
|
||||||
|
"expired": "ms_expired",
|
||||||
|
"revoked": "ms_revoked",
|
||||||
|
"other-account": "ms_other_account",
|
||||||
|
}
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
self._auth_session("current", "tenant-1", "user-1", "account-1"),
|
||||||
|
self._auth_session("other", "tenant-2", "user-1", "account-1"),
|
||||||
|
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
|
||||||
|
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
|
||||||
|
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _auth_session(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
expires: int = 2,
|
||||||
|
revoked: bool = False,
|
||||||
|
) -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
id=f"session-{name}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
token_hash=hash_session_token(self.tokens[name]),
|
||||||
|
expires_at=self.now + timedelta(hours=expires),
|
||||||
|
last_seen_at=self.now - timedelta(minutes=5),
|
||||||
|
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
|
||||||
|
user_agent="Browser " + ("x" * 500),
|
||||||
|
ip_address="192.0.2.55",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
|
||||||
|
sensitive = {
|
||||||
|
"token",
|
||||||
|
"token_hash",
|
||||||
|
"csrf_token_hash",
|
||||||
|
"cookie",
|
||||||
|
"ip_address",
|
||||||
|
}
|
||||||
|
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
|
||||||
|
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
|
||||||
|
active = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
|
||||||
|
self.assertTrue(next(item for item in active if item.id == "session-current").current)
|
||||||
|
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
|
||||||
|
self.assertNotIn("192.0.2.55", repr(active))
|
||||||
|
self.assertNotIn("token_hash", repr(active))
|
||||||
|
|
||||||
|
all_states = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
include_inactive=True,
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"active", "expired", "revoked"},
|
||||||
|
{item.status for item in all_states},
|
||||||
|
)
|
||||||
|
tenant_only = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
include_inactive=True,
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertNotIn("session-other", {item.id for item in tenant_only})
|
||||||
|
|
||||||
|
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertIsNotNone(item)
|
||||||
|
self.assertIsNone(
|
||||||
|
authenticate_session_token(self.session, self.tokens["other"])
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertIs(item, repeated)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
hidden, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other-account",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertIsNone(hidden)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
|
||||||
|
def test_listing_applies_activity_filter_and_limit_before_loading_history(self) -> None:
|
||||||
|
statements: list[str] = []
|
||||||
|
|
||||||
|
def capture_query(connection, cursor, statement, parameters, context, executemany):
|
||||||
|
if statement.lstrip().upper().startswith("SELECT") and "access_auth_sessions" in statement:
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", capture_query)
|
||||||
|
try:
|
||||||
|
for include_inactive in (False, True):
|
||||||
|
with self.subTest(include_inactive=include_inactive):
|
||||||
|
statements.clear()
|
||||||
|
summaries = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id="account-1",
|
||||||
|
current_session_id="session-current",
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
limit=1,
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(summaries))
|
||||||
|
self.assertEqual(1, len(statements))
|
||||||
|
self.assertIn("LIMIT", statements[0])
|
||||||
|
if not include_inactive:
|
||||||
|
self.assertEqual("active", summaries[0].status)
|
||||||
|
self.assertIn("revoked_at IS NULL", statements[0])
|
||||||
|
self.assertIn("expires_at >", statements[0])
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", capture_query)
|
||||||
|
|
||||||
|
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "current session"):
|
||||||
|
revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-current",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
revoked = revoke_other_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual(("session-other",), revoked)
|
||||||
|
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
|
||||||
|
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
revoke_other_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
|
||||||
|
self.account.password_hash = hash_password("correct horse")
|
||||||
|
membership = self.session.get(User, "user-1")
|
||||||
|
current = self.session.get(AuthSession, "session-current")
|
||||||
|
principal_ref = PrincipalRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
membership_id=membership.id,
|
||||||
|
tenant_id=membership.tenant_id,
|
||||||
|
scopes=frozenset({"access:membership:update"}),
|
||||||
|
auth_method="session",
|
||||||
|
session_id=current.id,
|
||||||
|
)
|
||||||
|
without_session = ApiPrincipal(
|
||||||
|
principal=principal_ref,
|
||||||
|
account=self.account,
|
||||||
|
user=membership,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as missing:
|
||||||
|
_require_session_admin_reauthorization(without_session, "correct horse")
|
||||||
|
self.assertEqual(403, missing.exception.status_code)
|
||||||
|
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=principal_ref,
|
||||||
|
account=self.account,
|
||||||
|
user=membership,
|
||||||
|
auth_session=current,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as incorrect:
|
||||||
|
_require_session_admin_reauthorization(principal, "incorrect")
|
||||||
|
self.assertEqual(403, incorrect.exception.status_code)
|
||||||
|
_require_session_admin_reauthorization(principal, "correct horse")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Group,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.tenant_erasure_provider import (
|
||||||
|
AccessTenantErasureProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
AccessBase.metadata.create_all(engine)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
account = Account(
|
||||||
|
email="shared@example.test",
|
||||||
|
normalized_email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
session.flush()
|
||||||
|
first = User(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=True,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
second = User(
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
account_id=account.id,
|
||||||
|
email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=False,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
Group(tenant_id="tenant-1", slug="group", name="Group"),
|
||||||
|
Group(tenant_id="tenant-2", slug="group", name="Group"),
|
||||||
|
Role(tenant_id="tenant-1", slug="role", name="Role"),
|
||||||
|
Role(tenant_id="tenant-2", slug="role", name="Role"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ApiKey(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=first.id,
|
||||||
|
name="key",
|
||||||
|
prefix="prefix",
|
||||||
|
key_hash="hash",
|
||||||
|
scopes=[],
|
||||||
|
),
|
||||||
|
AuthSession(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=first.id,
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash="token-hash",
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
provider = AccessTenantErasureProvider()
|
||||||
|
preview = provider.preview_tenant_erasure(session, "tenant-1")
|
||||||
|
|
||||||
|
assert preview.allowed
|
||||||
|
assert [step.step_id for step in preview.steps] == [
|
||||||
|
"revoke-tenant-credentials",
|
||||||
|
"erase-tenant-access",
|
||||||
|
]
|
||||||
|
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
|
||||||
|
|
||||||
|
revoked = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"revoke-tenant-credentials",
|
||||||
|
"operation:access:credentials",
|
||||||
|
)
|
||||||
|
erased = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert revoked.state == "completed"
|
||||||
|
assert erased.state == "completed"
|
||||||
|
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
|
||||||
|
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
|
||||||
|
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
|
||||||
|
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
|
||||||
|
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_erasure_replay_is_idempotent() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
AccessBase.metadata.create_all(engine)
|
||||||
|
provider = AccessTenantErasureProvider()
|
||||||
|
with Session(engine) as session:
|
||||||
|
first = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
second = provider.reconcile_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.metrics == {"deleted": 0}
|
||||||
|
assert second.metrics == {"deleted": 0}
|
||||||
+4
-3
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||||
|
"test:passwords": "node --test scripts/test-passwords.mjs"
|
||||||
},
|
},
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
|||||||
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
||||||
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
||||||
const moduleSource = read("src/module.ts");
|
const moduleSource = read("src/module.ts");
|
||||||
|
const sessions = read("src/features/sessions/SessionSettingsPanel.tsx");
|
||||||
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
||||||
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
|
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
|
||||||
|
|
||||||
@@ -48,7 +49,47 @@ assert.match(serviceAccounts, /rotateServiceAccountCredential/);
|
|||||||
assert.match(serviceAccounts, /revokeServiceAccountCredential/);
|
assert.match(serviceAccounts, /revokeServiceAccountCredential/);
|
||||||
assert.match(serviceAccounts, /Secrets are shown once/);
|
assert.match(serviceAccounts, /Secrets are shown once/);
|
||||||
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
|
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
|
||||||
|
|
||||||
|
for (const contextId of [
|
||||||
|
"access.api-keys.action.create",
|
||||||
|
"access.api-keys.action.revoke",
|
||||||
|
"access.api-keys.field.owner",
|
||||||
|
"access.api-keys.field.expiry",
|
||||||
|
"access.api-keys.field.scopes",
|
||||||
|
"access.api-keys.secret",
|
||||||
|
"access.api-keys.confirm-revoke"
|
||||||
|
]) {
|
||||||
|
assert.ok(apiKeys.includes(contextId), `API-key help context ${contextId} is missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contextId of [
|
||||||
|
"access.service-accounts.action.create",
|
||||||
|
"access.service-accounts.action.activation",
|
||||||
|
"access.service-accounts.action.retire",
|
||||||
|
"access.service-accounts.field.scope-ceiling",
|
||||||
|
"access.service-accounts.action.rotate-credential",
|
||||||
|
"access.service-accounts.action.revoke-credential",
|
||||||
|
"access.service-accounts.field.credential-expiry",
|
||||||
|
"access.service-accounts.field.credential-scopes",
|
||||||
|
"access.service-accounts.secret",
|
||||||
|
"access.service-accounts.confirm-retire"
|
||||||
|
]) {
|
||||||
|
assert.ok(serviceAccounts.includes(contextId), `Service-account help context ${contextId} is missing`);
|
||||||
|
}
|
||||||
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||||
|
assert.match(moduleSource, /access\.settings\.sessions/);
|
||||||
|
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
|
||||||
|
assert.match(sessions, /PageActionBar/);
|
||||||
|
assert.match(sessions, /reloadAction/);
|
||||||
|
assert.match(sessions, /destructiveActions/);
|
||||||
|
assert.match(sessions, /DataGrid/);
|
||||||
|
assert.match(sessions, /ConfirmDialog/);
|
||||||
|
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
|
||||||
|
assert.match(users, /fetchAdminUserSessions/);
|
||||||
|
assert.match(users, /revokeAdminUserSession/);
|
||||||
|
assert.match(users, /admin-user-sessions-v1/);
|
||||||
|
assert.match(users, /PasswordField/);
|
||||||
|
assert.match(users, /canRevokeSessions/);
|
||||||
assert.match(moduleSource, /translations,/);
|
assert.match(moduleSource, /translations,/);
|
||||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
// Use the workspace's shared frontend compiler, without loading the application.
|
||||||
|
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||||
|
const { transformSync } = require("esbuild");
|
||||||
|
const calls = [];
|
||||||
|
function load(relativePath, imports = {}) {
|
||||||
|
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||||
|
const code = transformSync(source, { loader: "ts", format: "cjs", target: "es2022" }).code;
|
||||||
|
const context = vm.createContext({ module: { exports: {} }, require: () => imports });
|
||||||
|
context.exports = context.module.exports;
|
||||||
|
vm.runInContext(code, context);
|
||||||
|
return context.module.exports;
|
||||||
|
}
|
||||||
|
const api = load("../src/api/passwords.ts", {
|
||||||
|
apiFetch: (...args) => { calls.push(args); return Promise.resolve({}); },
|
||||||
|
isApiError: (error) => Boolean(error?.fixtureApiError)
|
||||||
|
});
|
||||||
|
const { passwordTranslations } = load("../src/i18n/passwordTranslations.ts");
|
||||||
|
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "fixture-key", accessToken: "legacy-fixture-token" };
|
||||||
|
const current = "fixture-current-password";
|
||||||
|
const next = "fixture-next-password";
|
||||||
|
const code = "fixture-recovery-code";
|
||||||
|
|
||||||
|
test("password requests carry credentials only in POST bodies and public calls discard bearer settings", async () => {
|
||||||
|
calls.length = 0;
|
||||||
|
await api.fetchPasswordPolicy(settings);
|
||||||
|
await api.changePassword(settings, current, next);
|
||||||
|
await api.issuePasswordRecovery(settings, "account/1", current, true);
|
||||||
|
await api.recoverPassword(settings, "person@example.test", code, next);
|
||||||
|
assert.equal(calls[0][0].apiKey, "");
|
||||||
|
assert.equal(calls[0][0].accessToken, "");
|
||||||
|
assert.equal(calls[0][2].cache, "no-store");
|
||||||
|
assert.equal(calls[1][0], settings);
|
||||||
|
assert.deepEqual(JSON.parse(calls[1][2].body), { current_password: current, new_password: next });
|
||||||
|
assert.equal(calls[2][1], "/api/v1/auth/password/recovery/account%2F1");
|
||||||
|
assert.deepEqual(JSON.parse(calls[2][2].body), { current_password: current, identity_verified: true });
|
||||||
|
assert.equal(calls[3][0].apiKey, "");
|
||||||
|
assert.equal(calls[3][0].accessToken, "");
|
||||||
|
assert.deepEqual(JSON.parse(calls[3][2].body), { email: "person@example.test", recovery_code: code, new_password: next });
|
||||||
|
for (const [, path, options] of calls.slice(1)) {
|
||||||
|
assert.equal(options.method, "POST");
|
||||||
|
for (const secret of [current, next, code]) assert.equal(path.includes(secret), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all stable password errors have EN/DE messages, while arbitrary input never becomes display text", () => {
|
||||||
|
const codes = ["current_password_invalid", "invalid_new_password", "password_unchanged", "password_recovery_disabled", "password_rate_limited", "local_password_unavailable", "recovery_invalid", "recovery_issuer_required", "recovery_membership_required", "password_changed_concurrently", "password_change_required"];
|
||||||
|
for (const value of codes) {
|
||||||
|
const key = api.passwordErrorMessage({ fixtureApiError: true, status: 400, body: JSON.stringify({ detail: { code: value, input: current } }) });
|
||||||
|
assert.ok(passwordTranslations.en[key], value);
|
||||||
|
assert.ok(passwordTranslations.de[key], value);
|
||||||
|
assert.notEqual(key, "i18n:govoplan-access.password.request_failed");
|
||||||
|
}
|
||||||
|
for (const body of [current, JSON.stringify({ detail: [{ input: current }] }), JSON.stringify({ detail: { code: "toString", input: code } })]) {
|
||||||
|
assert.equal(api.passwordErrorMessage({ fixtureApiError: true, status: 500, body }), "i18n:govoplan-access.password.request_failed");
|
||||||
|
}
|
||||||
|
assert.equal(api.passwordErrorMessage(new Error(current)), "i18n:govoplan-access.password.request_failed");
|
||||||
|
for (const status of [401, 403, 422, 429]) {
|
||||||
|
const key = api.passwordErrorMessage({ fixtureApiError: true, status, body: JSON.stringify({ detail: current }) });
|
||||||
|
assert.ok(passwordTranslations.en[key]);
|
||||||
|
assert.ok(passwordTranslations.de[key]);
|
||||||
|
assert.equal(key.includes(current), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("password translations keep the EN/DE workflow and placeholder contracts aligned", () => {
|
||||||
|
assert.deepEqual(Object.keys(passwordTranslations.en).sort(), Object.keys(passwordTranslations.de).sort());
|
||||||
|
for (const key of Object.keys(passwordTranslations.en)) {
|
||||||
|
assert.deepEqual(passwordTranslations.en[key].match(/\{value\d+\}/g) ?? [], passwordTranslations.de[key].match(/\{value\d+\}/g) ?? [], key);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,8 +4,7 @@ import type {
|
|||||||
DeltaDeletedItem,
|
DeltaDeletedItem,
|
||||||
PrivacyRetentionPolicy,
|
PrivacyRetentionPolicy,
|
||||||
ResourceAccessExplanationOptions,
|
ResourceAccessExplanationOptions,
|
||||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse
|
||||||
TenantAdminItem
|
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||||
@@ -137,6 +136,7 @@ export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationRes
|
|||||||
|
|
||||||
export type SystemAccountItem = {
|
export type SystemAccountItem = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
|
local_password?: boolean;
|
||||||
email: string;
|
email: string;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { apiFetch, isApiError, type ApiSettings, type LoginResponse } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type PasswordPolicy = {
|
||||||
|
recovery_enabled: boolean;
|
||||||
|
min_length: number;
|
||||||
|
max_length: number;
|
||||||
|
recovery_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PasswordRecoveryCode = { recovery_code: string; expires_at: string };
|
||||||
|
|
||||||
|
export function fetchPasswordPolicy(settings: ApiSettings): Promise<PasswordPolicy> {
|
||||||
|
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/policy", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(settings: ApiSettings, currentPassword: string, newPassword: string): Promise<LoginResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/auth/password/change", {
|
||||||
|
method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issuePasswordRecovery(settings: ApiSettings, accountId: string, currentPassword: string, identityVerified: true): Promise<PasswordRecoveryCode> {
|
||||||
|
return apiFetch(settings, `/api/v1/auth/password/recovery/${encodeURIComponent(accountId)}`, {
|
||||||
|
method: "POST", body: JSON.stringify({ current_password: currentPassword, identity_verified: identityVerified })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverPassword(settings: ApiSettings, email: string, recoveryCode: string, newPassword: string): Promise<{ ok: boolean }> {
|
||||||
|
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/recover", {
|
||||||
|
method: "POST", body: JSON.stringify({ email, recovery_code: recoveryCode, new_password: newPassword })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordErrors: Record<string, string> = {
|
||||||
|
current_password_invalid: "i18n:govoplan-access.password.current_invalid",
|
||||||
|
invalid_new_password: "i18n:govoplan-access.password.invalid_new",
|
||||||
|
password_unchanged: "i18n:govoplan-access.password.unchanged",
|
||||||
|
password_recovery_disabled: "i18n:govoplan-access.password.recovery_disabled",
|
||||||
|
password_rate_limited: "i18n:govoplan-access.password.rate_limited",
|
||||||
|
local_password_unavailable: "i18n:govoplan-access.password.local_only",
|
||||||
|
recovery_invalid: "i18n:govoplan-access.password.recovery_invalid",
|
||||||
|
recovery_issuer_required: "i18n:govoplan-access.password.issuer_required",
|
||||||
|
recovery_membership_required: "i18n:govoplan-access.password.membership_required",
|
||||||
|
password_changed_concurrently: "i18n:govoplan-access.password.changed_concurrently",
|
||||||
|
password_change_required: "i18n:govoplan-access.password.required"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function passwordErrorMessage(error: unknown): string {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
try {
|
||||||
|
const code: unknown = JSON.parse(error.body)?.detail?.code;
|
||||||
|
if (typeof code === "string" && Object.prototype.hasOwnProperty.call(passwordErrors, code)) return passwordErrors[code];
|
||||||
|
} catch { /* Never display arbitrary response content from a secret-bearing request. */ }
|
||||||
|
if (error.status === 401) return "i18n:govoplan-access.password.session_expired";
|
||||||
|
if (error.status === 403) return "i18n:govoplan-access.password.not_allowed";
|
||||||
|
if (error.status === 422) return "i18n:govoplan-access.password.invalid_fields";
|
||||||
|
if (error.status === 429) return "i18n:govoplan-access.password.rate_limited";
|
||||||
|
}
|
||||||
|
return "i18n:govoplan-access.password.request_failed";
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type AccountSession = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
current: boolean;
|
||||||
|
status: "active" | "expired" | "revoked";
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
expires_at: string;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
client?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountSessionList = {
|
||||||
|
sessions: AccountSession[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchAccountSessions(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<AccountSessionList> {
|
||||||
|
return apiFetch<AccountSessionList>(settings, "/api/v1/auth/sessions", {
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeAccountSession(
|
||||||
|
settings: ApiSettings,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||||
|
return apiFetch(settings, `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeOtherAccountSessions(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<{ revoked_count: number }> {
|
||||||
|
return apiFetch(settings, "/api/v1/auth/sessions/revoke-others", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminUserSessions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
userId: string
|
||||||
|
): Promise<AccountSessionList> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeAdminUserSession(
|
||||||
|
settings: ApiSettings,
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
|
currentPassword: string
|
||||||
|
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ current_password: currentPassword })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||||
import { ActionBlockerHint } from "@govoplan/core-webui";
|
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||||
|
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
TreeSubnav,
|
TreeSubnav,
|
||||||
@@ -322,15 +323,27 @@ export default function AdminPage({
|
|||||||
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace module-workspace">
|
<WorkspaceLayout
|
||||||
|
className="module-workspace"
|
||||||
|
primary={(
|
||||||
<TreeSubnav
|
<TreeSubnav
|
||||||
active={active}
|
active={active}
|
||||||
nodes={adminTree}
|
nodes={adminTree}
|
||||||
onSelect={selectSection}
|
onSelect={selectSection}
|
||||||
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
/>
|
/>
|
||||||
<section className="workspace-content">
|
)}
|
||||||
<div className="content-pad workspace-data-page">
|
primaryLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
contentLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
documentationType="admin"
|
||||||
|
>
|
||||||
|
<PageLayout
|
||||||
|
archetype="workspace"
|
||||||
|
title="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
mode="workspace"
|
||||||
|
showHeader={false}
|
||||||
|
documentationType="admin"
|
||||||
|
>
|
||||||
{contributedSection && contributedSection.render(contributionContext)}
|
{contributedSection && contributedSection.render(contributionContext)}
|
||||||
{!contributedSection && active === "system-mail-servers" && (
|
{!contributedSection && active === "system-mail-servers" && (
|
||||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||||
@@ -345,6 +358,7 @@ export default function AdminPage({
|
|||||||
{!contributedSection && active === "system-users" && (
|
{!contributedSection && active === "system-users" && (
|
||||||
<SystemUsersPanel
|
<SystemUsersPanel
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
auth={auth}
|
||||||
canCreate={hasScope(auth, "system:accounts:create")}
|
canCreate={hasScope(auth, "system:accounts:create")}
|
||||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||||
@@ -354,7 +368,7 @@ export default function AdminPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} canRevokeSessions={hasAnyScope(auth, ["admin:users:update", "access:membership:update"])} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
||||||
@@ -368,9 +382,8 @@ export default function AdminPage({
|
|||||||
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||||
</div>
|
</PageLayout>
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Plus, Search, Trash2 } from "lucide-react";
|
import { Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
@@ -102,8 +103,8 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
{ 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: "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: "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: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
|
||||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canRevoke]);
|
[canRevoke]);
|
||||||
|
|
||||||
@@ -152,33 +153,33 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />} description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" helpContextId="access.api-keys.action.create" helpModuleId="access" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322" helpContextId="access.api-keys.field.name" helpModuleId="access"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.owner.89ff3122"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-access.owner.89ff3122" helpContextId="access.api-keys.field.owner" helpModuleId="access"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
<FormField label="i18n:govoplan-access.expiry.ba8f571e" helpContextId="access.api-keys.field.expiry" helpModuleId="access"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
<div className="form-field" data-help-context-id="access.api-keys.field.scopes" data-help-module-id="access"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" helpContextId="access.api-keys.action.inspect" helpModuleId="access" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <><dl className="admin-details-grid">
|
{viewing && <><DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}…</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}…</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_used.f1109d3d</>}>{formatDateTime(viewing.last_used_at)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.expires.a99be3da</>}>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.revoked.85f17ac0</>}>{formatDateTime(viewing.revoked_at)}</DescriptionItem>
|
||||||
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
</DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" helpContextId="access.api-keys.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.api-keys.secret" helpModuleId="access" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} helpContextId="access.api-keys.confirm-revoke" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ export default function CredentialEnvelopesPanel({
|
|||||||
return (
|
return (
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title={scopeTitle(scopeType)}
|
title={scopeTitle(scopeType)}
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
||||||
description={scopeDescription(scopeType)}
|
description={scopeDescription(scopeType)}
|
||||||
loading={loadingTargets}
|
loading={loadingTargets}
|
||||||
error={targetError}
|
error={targetError}
|
||||||
actions={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
|
||||||
>
|
>
|
||||||
<CredentialEnvelopeManager
|
<CredentialEnvelopeManager
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type ExternalFunctionRoleMappingItem,
|
type ExternalFunctionRoleMappingItem,
|
||||||
type RoleSummary
|
type RoleSummary
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button, FormGrid } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
@@ -271,13 +271,13 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
<>
|
<>
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
|
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />}
|
||||||
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
|
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
|
||||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||||
</>
|
</>
|
||||||
@@ -295,11 +295,11 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="large"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
||||||
onClose={() => !busy && closeEditor()}
|
onClose={() => !busy && closeEditor()}
|
||||||
className="admin-dialog"
|
className=""
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
|
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
|
||||||
@@ -309,7 +309,7 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="admin-form-grid">
|
<FormGrid columns={1} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
||||||
{functionPicker.renderPicker({
|
{functionPicker.renderPicker({
|
||||||
settings,
|
settings,
|
||||||
@@ -327,7 +327,7 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
@@ -145,32 +146,32 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
||||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid">
|
{viewing && <DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.group.171a0606</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.group.171a0606</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.management.63cecca6</dt><dd>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.management.63cecca6</>}>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.members.1cb449c1</dt><dd>{viewing.member_count}</dd></div><div><dt>i18n:govoplan-access.roles.47dcc27d</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.members.1cb449c1</>}>{viewing.member_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.roles.47dcc27d</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
|
||||||
</dl>}
|
</DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
@@ -134,29 +135,29 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||||
const scopes = items.map((permission) => permission.scope);
|
const scopes = items.map((permission) => permission.scope);
|
||||||
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||||
})}</div>
|
})}</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <><dl className="admin-details-grid">
|
{viewing && <><DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.role.c3f104d1</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.type.3deb7456</>}>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user_assignments.bc7cc801</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.group_assignments.e534bb56</>}>{viewing.group_assignments}</DescriptionItem>
|
||||||
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
</DescriptionList><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
ShieldOff,
|
ShieldOff,
|
||||||
Trash2
|
Trash2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { FormGrid, ActionToolbar,
|
||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
AdminPageLayout,
|
AdminPageLayout,
|
||||||
AdminSelectionList,
|
AdminSelectionList,
|
||||||
@@ -213,7 +214,7 @@ export default function ServiceAccountsPanel({
|
|||||||
resizable: false,
|
resizable: false,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => <TableActionGroup actions={[
|
render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, onClick: () => void openManager(row) }
|
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, helpContextId: "access.service-accounts.action.manage", helpModuleId: "access", onClick: () => void openManager(row) }
|
||||||
]} />
|
]} />
|
||||||
}
|
}
|
||||||
], []);
|
], []);
|
||||||
@@ -273,6 +274,8 @@ export default function ServiceAccountsPanel({
|
|||||||
id: "rotate",
|
id: "rotate",
|
||||||
label: `Rotate ${row.name}`,
|
label: `Rotate ${row.name}`,
|
||||||
icon: <RefreshCw />,
|
icon: <RefreshCw />,
|
||||||
|
helpContextId: "access.service-accounts.action.rotate-credential",
|
||||||
|
helpModuleId: "access",
|
||||||
applicable: !row.revoked_at,
|
applicable: !row.revoked_at,
|
||||||
disabled: !canWrite || !managing?.is_active,
|
disabled: !canWrite || !managing?.is_active,
|
||||||
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
|
||||||
@@ -283,6 +286,8 @@ export default function ServiceAccountsPanel({
|
|||||||
label: `Revoke ${row.name}`,
|
label: `Revoke ${row.name}`,
|
||||||
icon: <Trash2 />,
|
icon: <Trash2 />,
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
|
helpContextId: "access.service-accounts.action.revoke-credential",
|
||||||
|
helpModuleId: "access",
|
||||||
applicable: !row.revoked_at,
|
applicable: !row.revoked_at,
|
||||||
disabled: !canWrite,
|
disabled: !canWrite,
|
||||||
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
|
||||||
@@ -440,14 +445,16 @@ export default function ServiceAccountsPanel({
|
|||||||
<>
|
<>
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="Service accounts"
|
title="Service accounts"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||||
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
|
helpContextId="access.admin.service-accounts"
|
||||||
|
helpModuleId="access"
|
||||||
actions={<>
|
actions={<>
|
||||||
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
<Button helpContextId="access.service-accounts.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading}>Reload</Button>
|
||||||
<Button onClick={() => void load()} disabled={loading}>Reload</Button>
|
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" helpContextId="access.service-accounts.action.create" helpModuleId="access" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
||||||
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
@@ -455,46 +462,50 @@ export default function ServiceAccountsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={Boolean(accountEditor)}
|
open={Boolean(accountEditor)}
|
||||||
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
||||||
|
helpContextId="access.service-accounts.account-editor"
|
||||||
|
helpModuleId="access"
|
||||||
onClose={() => !busy && setAccountEditor(null)}
|
onClose={() => !busy && setAccountEditor(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save" helpModuleId="access" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
||||||
>
|
>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="Name"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
<FormField label="Name" helpContextId="access.service-accounts.field.name" helpModuleId="access"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="Description"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
<FormField label="Description" helpContextId="access.service-accounts.field.description" helpModuleId="access"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field">
|
<div className="form-field" data-help-context-id="access.service-accounts.field.scope-ceiling" data-help-module-id="access">
|
||||||
<span className="form-label">Scope ceiling</span>
|
<span className="form-label">Scope ceiling</span>
|
||||||
<AdminSelectionList options={grantablePermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." />
|
<AdminSelectionList options={grantablePermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." />
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={Boolean(managing)}
|
open={Boolean(managing)}
|
||||||
title={managing?.name ?? "Service account"}
|
title={managing?.name ?? "Service account"}
|
||||||
|
helpContextId="access.service-accounts.action.manage"
|
||||||
|
helpModuleId="access"
|
||||||
onClose={() => !busy && setManaging(null)}
|
onClose={() => !busy && setManaging(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
||||||
>
|
>
|
||||||
{managing && <>
|
{managing && <>
|
||||||
<div className="metric-grid compact">
|
<MetricGrid density="compact">
|
||||||
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
|
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
|
||||||
<MetricCard label="Active credentials" value={managing.active_credential_count} />
|
<MetricCard label="Active credentials" value={managing.active_credential_count} />
|
||||||
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
|
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
|
||||||
<MetricCard label="Revision" value={managing.revision} />
|
<MetricCard label="Revision" value={managing.revision} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<div className="admin-toolbar-row">
|
<ActionToolbar className="admin-toolbar-row">
|
||||||
<Button onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
<Button helpContextId="access.service-accounts.action.edit" helpModuleId="access" onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
||||||
<Button onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
|
<Button helpContextId="access.service-accounts.action.activation" helpModuleId="access" onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
|
||||||
<Button variant="danger" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
<Button variant="danger" helpContextId="access.service-accounts.action.retire" helpModuleId="access" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
||||||
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" helpContextId="access.service-accounts.action.create-credential" helpModuleId="access" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="admin-toolbar-row">
|
<ActionToolbar className="admin-toolbar-row">
|
||||||
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} onChange={setShowRevoked} />
|
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} helpContextId="access.service-accounts.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} />
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
|
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
|
||||||
</div>
|
</div>
|
||||||
@@ -502,30 +513,32 @@ export default function ServiceAccountsPanel({
|
|||||||
</>}
|
</>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={Boolean(credentialEditor)}
|
open={Boolean(credentialEditor)}
|
||||||
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
||||||
|
helpContextId="access.service-accounts.credential-editor"
|
||||||
|
helpModuleId="access"
|
||||||
onClose={() => !busy && setCredentialEditor(null)}
|
onClose={() => !busy && setCredentialEditor(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
|
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save-credential" helpModuleId="access" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
|
||||||
>
|
>
|
||||||
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
|
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="Name"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
<FormField label="Name" helpContextId="access.service-accounts.field.credential-name" helpModuleId="access"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="Expiry"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
<FormField label="Expiry" helpContextId="access.service-accounts.field.credential-expiry" helpModuleId="access"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field">
|
<div className="form-field" data-help-context-id="access.service-accounts.field.credential-scopes" data-help-module-id="access">
|
||||||
<span className="form-label">Credential scopes</span>
|
<span className="form-label">Credential scopes</span>
|
||||||
<AdminSelectionList options={credentialPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." />
|
<AdminSelectionList options={credentialPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." />
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(secret)} title="Service-account secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(secret)} title="Service-account secret" helpContextId="access.service-accounts.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.service-accounts.secret" helpModuleId="access" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||||
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
|
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
|
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-revoke-credential" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
|
||||||
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
|
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-retire" helpModuleId="access" onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
@@ -237,30 +238,31 @@ export default function SystemRolesPanel({
|
|||||||
<>
|
<>
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="i18n:govoplan-access.system_roles.a9461aa6"
|
title="i18n:govoplan-access.system_roles.a9461aa6"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||||
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
|
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
||||||
onClose={() => !busy && setEditing(null)}
|
onClose={() => !busy && setEditing(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||||
|
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
||||||
<AdminSelectionList
|
<AdminSelectionList
|
||||||
@@ -272,8 +274,8 @@ export default function SystemRolesPanel({
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.protected.28531336</>}>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.account_assignments.f5a91f2a</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{viewing.effective_permission_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assigned_scopes.c7b09b12</>}>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</DescriptionItem></DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Search, Pencil, Plus, Trash2, KeyRound } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
|
import { hasScope } from "@govoplan/core-webui";
|
||||||
|
import PasswordRecoveryIssueDialog from "../passwords/PasswordRecoveryIssueDialog";
|
||||||
|
import { usePasswordPolicy } from "../passwords/usePasswordPolicy";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -35,6 +39,7 @@ const emptyDraft = {
|
|||||||
|
|
||||||
export default function SystemUsersPanel({
|
export default function SystemUsersPanel({
|
||||||
settings,
|
settings,
|
||||||
|
auth,
|
||||||
canCreate,
|
canCreate,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canSuspend,
|
canSuspend,
|
||||||
@@ -49,7 +54,7 @@ export default function SystemUsersPanel({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||||
@@ -60,6 +65,11 @@ export default function SystemUsersPanel({
|
|||||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||||
|
const [recovering, setRecovering] = useState<SystemAccountItem | null>(null);
|
||||||
|
const { policy: passwordPolicy } = usePasswordPolicy(settings);
|
||||||
|
const canIssueRecovery = Boolean(passwordPolicy?.recovery_enabled
|
||||||
|
&& auth.principal?.auth_method === "session" && auth.user.local_password
|
||||||
|
&& hasScope(auth, "system:*") && !auth.user.required_auth_action);
|
||||||
const [draft, setDraft] = useState(emptyDraft);
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -215,31 +225,38 @@ export default function SystemUsersPanel({
|
|||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
|
...(canIssueRecovery && row.local_password === true && row.is_active
|
||||||
|
? [{ id: "recover-password", label: "i18n:govoplan-access.password.issue_title", icon: <KeyRound />, helpContextId: "access.password.issue-recovery", helpModuleId: "access", onClick: () => setRecovering(row) }]
|
||||||
|
: []),
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
[canAssignRoles, canManageMemberships, canSuspend, canUpdate, canIssueRecovery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{recovering && canIssueRecovery && <PasswordRecoveryIssueDialog key={recovering.account_id} settings={settings} account={recovering} onClose={() => setRecovering(null)} />}
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
|
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.system-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -248,20 +265,20 @@ export default function SystemUsersPanel({
|
|||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.display_name.c7874aaa</>}>{viewing.display_name || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.system_roles.a9461aa6</>}>{joinLabels(viewing.roles)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.tenants.1f7ae776</>}>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</DescriptionItem></DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
@@ -13,6 +14,11 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
|||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
fetchAdminUserSessions,
|
||||||
|
revokeAdminUserSession,
|
||||||
|
type AccountSession
|
||||||
|
} from "../../api/sessions";
|
||||||
import {
|
import {
|
||||||
ACCESS_INTERFACE_I18N,
|
ACCESS_INTERFACE_I18N,
|
||||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||||
@@ -29,7 +35,7 @@ const emptyDraft = {
|
|||||||
roleIds: [] as string[]
|
roleIds: [] as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh
|
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, canRevokeSessions, onAuthRefresh
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +44,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
|
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;canRevokeSessions: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||||
@@ -52,6 +58,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||||
|
const [sessionUser, setSessionUser] = useState<UserAdminItem | null>(null);
|
||||||
|
const [accountSessions, setAccountSessions] = useState<AccountSession[]>([]);
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
|
const [sessionError, setSessionError] = useState("");
|
||||||
|
const [revokingSession, setRevokingSession] = useState<AccountSession | null>(null);
|
||||||
|
const [reauthorizationPassword, setReauthorizationPassword] = useState("");
|
||||||
const [draft, setDraft] = useState(emptyDraft);
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
|
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
|
||||||
@@ -185,6 +197,67 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserSessions(user: UserAdminItem) {
|
||||||
|
setSessionsLoading(true);
|
||||||
|
setSessionError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminUserSessions(settings, user.id);
|
||||||
|
setAccountSessions(response.sessions);
|
||||||
|
} catch (err) {
|
||||||
|
setSessionError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSessionsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserSessions(user: UserAdminItem) {
|
||||||
|
setSessionUser(user);
|
||||||
|
setAccountSessions([]);
|
||||||
|
setRevokingSession(null);
|
||||||
|
setReauthorizationPassword("");
|
||||||
|
void loadUserSessions(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSelectedSession() {
|
||||||
|
if (!sessionUser || !revokingSession || !reauthorizationPassword) return;
|
||||||
|
setBusy(true);
|
||||||
|
setSessionError("");
|
||||||
|
try {
|
||||||
|
await revokeAdminUserSession(
|
||||||
|
settings,
|
||||||
|
sessionUser.id,
|
||||||
|
revokingSession.id,
|
||||||
|
reauthorizationPassword
|
||||||
|
);
|
||||||
|
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
|
||||||
|
setRevokingSession(null);
|
||||||
|
setReauthorizationPassword("");
|
||||||
|
await loadUserSessions(sessionUser);
|
||||||
|
} catch (err) {
|
||||||
|
setSessionError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = useMemo<DataGridColumn<AccountSession>[]>(() => [
|
||||||
|
{ id: "client", header: "i18n:govoplan-access.device_or_client.5e551002", width: "minmax(220px, 1fr)", fill: true, value: (row) => row.client || "", render: (row) => <div><strong>{row.current ? "i18n:govoplan-access.current_session.5e551003" : "i18n:govoplan-access.other_session.5e551004"}</strong><div className="muted small-note">{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}</div></div> },
|
||||||
|
{ id: "last_seen", header: "i18n:govoplan-access.last_seen.5e551006", width: 180, value: (row) => row.last_seen_at || "", render: (row) => formatDateTime(row.last_seen_at) },
|
||||||
|
{ id: "created", header: "i18n:govoplan-access.created.accf40c8", width: 180, value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||||
|
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, value: (row) => row.expires_at, render: (row) => formatDateTime(row.expires_at) },
|
||||||
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 96, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{
|
||||||
|
id: "revoke-session",
|
||||||
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.current,
|
||||||
|
disabled: busy || !canRevokeSessions,
|
||||||
|
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
|
||||||
|
onClick: () => { setRevokingSession(row); setReauthorizationPassword(""); setSessionError(""); }
|
||||||
|
}]} /> }
|
||||||
|
], [busy, canRevokeSessions]);
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||||
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||||
@@ -194,25 +267,28 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
|
{ id: "sessions", label: i18nMessage("i18n:govoplan-access.inspect_sessions_for_value.5e551017", { value0: row.email }), icon: <MonitorSmartphone />, onClick: () => openUserSessions(row) },
|
||||||
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
[canAssignRoles, canManageGroups, canRevokeSessions, canSuspend, canUpdate, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.tenant-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -221,36 +297,52 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} helpContextId="access.admin.tenant-users.require-password-change" helpModuleId="access" onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
|
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid">
|
{viewing && <DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>i18n:govoplan-access.email.84add5b2</dt><dd>{viewing.email}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{viewing.display_name || viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.email.84add5b2</>}>{viewing.email}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.membership.53bc9670</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.global_account.e1b00cf5</dt><dd>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.membership.53bc9670</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.global_account.e1b00cf5</>}>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.groups.ae9629f4</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>i18n:govoplan-access.direct_roles.c4db7156</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.groups.ae9629f4</>}>{joinLabels(viewing.groups)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.direct_roles.c4db7156</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem>
|
||||||
</dl>}
|
</DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(sessionUser && !revokingSession)} title="i18n:govoplan-access.user_sessions.5e551018" onClose={() => !busy && setSessionUser(null)} className="" footer={<><Button onClick={() => sessionUser && void loadUserSessions(sessionUser)} disabled={sessionsLoading || busy} disabledReason={sessionsLoading || busy ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><Button onClick={() => setSessionUser(null)} disabled={busy}>i18n:govoplan-access.close.bbfa773e</Button></>}>
|
||||||
|
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||||
|
{sessionUser && <>
|
||||||
|
<p className="muted small-note">{sessionUser.display_name || sessionUser.email} · {sessionUser.email}</p>
|
||||||
|
<div className="admin-table-surface"><DataGrid id="admin-user-sessions-v1" rows={accountSessions} columns={sessionColumns} initialFit="container" getRowKey={(row) => row.id} loading={sessionsLoading} emptyText="i18n:govoplan-access.no_active_sessions.5e551013" /></div>
|
||||||
|
</>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
|
||||||
|
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||||
|
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
|
||||||
|
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.sessions.field.current-password" helpModuleId="access">
|
||||||
|
<PasswordField helpContextId="access.sessions.field.current-password" helpModuleId="access" value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
|
||||||
|
</FormField>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide" open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||||
{accessExplanation && <>
|
{accessExplanation && <>
|
||||||
<dl className="admin-details-grid">
|
<DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{accessExplanation.user.display_name || accessExplanation.user.email}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{accessExplanation.user.account_id}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.role_sources.6f42a672</>}>{accessExplanation.role_sources.length}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.function_facts.848b32cc</>}>{accessExplanation.function_facts.length}</DescriptionItem>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
||||||
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid">
|
{accessExplanation.role_sources.length ? <ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
||||||
<strong>{source.role_name}</strong>
|
<strong>{source.role_name}</strong>
|
||||||
<div className="muted small-note">
|
<div className="muted small-note">
|
||||||
@@ -262,19 +354,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
||||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
</ContentGrid> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
||||||
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
||||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
{accessExplanation.function_facts.length ? <DescriptionList>
|
||||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
{accessExplanation.function_facts.map((fact) => <DescriptionItem key={fact.assignment_id} term={fact.function_name || fact.function_id}>
|
||||||
<dt>{fact.function_name || fact.function_id}</dt>
|
|
||||||
<dd>
|
|
||||||
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
||||||
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
||||||
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||||
</dd>
|
</DescriptionItem>)}
|
||||||
</div>)}
|
</DescriptionList> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
|
||||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||||
<div className="admin-scope-list">
|
<div className="admin-scope-list">
|
||||||
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
||||||
@@ -282,7 +371,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
</>}
|
</>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import {
|
||||||
|
Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage,
|
||||||
|
type ApiSettings, type AuthInfo, type AuthUpdate
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { changePassword, passwordErrorMessage } from "../../api/passwords";
|
||||||
|
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||||
|
|
||||||
|
export default function PasswordChangePanel({ settings, auth, onAuthChange }: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
|
}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const required = auth.user.required_auth_action === "change_password";
|
||||||
|
const localSession = auth.principal?.auth_method === "session" && auth.user.local_password === true;
|
||||||
|
const complete = Boolean(policy && currentPassword && Array.from(currentPassword).length <= 1024 && newPassword === confirmation
|
||||||
|
&& Array.from(newPassword).length >= policy.min_length
|
||||||
|
&& Array.from(newPassword).length <= policy.max_length);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !complete || !localSession) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
const response = await changePassword(settings, currentPassword, newPassword);
|
||||||
|
onAuthChange(response, "");
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(passwordErrorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmation("");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section>
|
||||||
|
<h1>{required ? "i18n:govoplan-access.password.required_title" : "i18n:govoplan-access.password.change_title"}</h1>
|
||||||
|
{required && <p>i18n:govoplan-access.password.required</p>}
|
||||||
|
{!localSession ? <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.local_only</DismissibleAlert> : <>
|
||||||
|
<p>i18n:govoplan-access.password.change_consequences</p>
|
||||||
|
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.change" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success">i18n:govoplan-access.password.changed</DismissibleAlert>}
|
||||||
|
<FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||||
|
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.change" helpModuleId="access">
|
||||||
|
<PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={currentPassword} onValueChange={setCurrentPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-access.password.new" help={policy ? i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length }) : undefined} helpContextId="access.password.change" helpModuleId="access">
|
||||||
|
<PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={newPassword} onValueChange={setNewPassword} autoComplete="new-password" minLength={policy?.min_length} maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} generator />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.change" helpModuleId="access">
|
||||||
|
<PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
{confirmation && newPassword !== confirmation && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||||
|
<Button type="submit" variant="primary" disabled={busy || !complete} helpContextId="access.password.change" helpModuleId="access"
|
||||||
|
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !complete ? "i18n:govoplan-access.password.complete_fields" : undefined}>
|
||||||
|
{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.change_title"}
|
||||||
|
</Button>
|
||||||
|
</FormLayout>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||||
|
|
||||||
|
export default function PasswordLoginHelp({ settings, onNavigate }: { settings: ApiSettings; onNavigate: () => void }) {
|
||||||
|
const { policy } = usePasswordPolicy(settings);
|
||||||
|
return policy?.recovery_enabled
|
||||||
|
? <p><Link to="/password-recovery" onClick={onNavigate} data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.forgot</Link></p>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useId, useState, type FormEvent } from "react";
|
||||||
|
import {
|
||||||
|
Button, Dialog, DismissibleAlert, FormField, FormLayout, PasswordField,
|
||||||
|
i18nMessage, usePlatformLanguage, type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { issuePasswordRecovery, passwordErrorMessage, type PasswordRecoveryCode } from "../../api/passwords";
|
||||||
|
|
||||||
|
export default function PasswordRecoveryIssueDialog({ settings, account, onClose }: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
account: { account_id: string; email: string };
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const formId = useId();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
const [result, setResult] = useState<PasswordRecoveryCode | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const ready = verified && Boolean(password) && Array.from(password).length <= 1024;
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !ready || result) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setResult(await issuePasswordRecovery(settings, account.account_id, password, true));
|
||||||
|
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||||
|
finally {
|
||||||
|
setPassword("");
|
||||||
|
setVerified(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dialog open variant="administration" size="large"
|
||||||
|
title="i18n:govoplan-access.password.issue_title" onClose={() => { if (!busy) onClose(); }}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={onClose} disabled={busy} helpContextId="access.password.issue-recovery" helpModuleId="access">{result ? "i18n:govoplan-access.close.bbfa773e" : "i18n:govoplan-access.cancel.77dfd213"}</Button>
|
||||||
|
{!result && <Button type="submit" form={formId} variant="primary" disabled={busy || !ready} helpContextId="access.password.issue-recovery" helpModuleId="access"
|
||||||
|
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.issue_requirements" : undefined}>
|
||||||
|
i18n:govoplan-access.password.issue_title
|
||||||
|
</Button>}
|
||||||
|
</>}>
|
||||||
|
<p>{i18nMessage("i18n:govoplan-access.password.issue_for", { value0: account.email })}</p>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{result ? <>
|
||||||
|
<p>i18n:govoplan-access.password.code_once</p>
|
||||||
|
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.issue-recovery" helpModuleId="access"><input value={result.recovery_code} readOnly autoComplete="off" /></FormField>
|
||||||
|
<p>{i18nMessage("i18n:govoplan-access.password.code_expires", { value0: new Date(result.expires_at).toLocaleString() })}</p>
|
||||||
|
<p>i18n:govoplan-access.password.code_delivery</p>
|
||||||
|
</> : <FormLayout columns={1} collapseAt="standard" id={formId} onSubmit={submit}>
|
||||||
|
<p>i18n:govoplan-access.password.issue_consequences</p>
|
||||||
|
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.issue-recovery" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={password} onValueChange={setPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} /></FormField>
|
||||||
|
<label className="checkbox-field" data-help-context-id="access.password.issue-recovery" data-help-module-id="access"><input type="checkbox" checked={verified} onChange={(event) => setVerified(event.target.checked)} disabled={busy} required /> <span>i18n:govoplan-access.password.identity_verified</span></label>
|
||||||
|
</FormLayout>}
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { passwordErrorMessage, recoverPassword } from "../../api/passwords";
|
||||||
|
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||||
|
|
||||||
|
export default function PasswordRecoveryPage({ settings }: { settings: ApiSettings }) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [complete, setComplete] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const ready = Boolean(policy?.recovery_enabled && email.trim() && code.trim()
|
||||||
|
&& password === confirmation && Array.from(password).length >= policy.min_length
|
||||||
|
&& Array.from(password).length <= policy.max_length);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !ready) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await recoverPassword(settings, email.trim(), code.trim(), password);
|
||||||
|
setComplete(true);
|
||||||
|
setEmail("");
|
||||||
|
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||||
|
finally {
|
||||||
|
setCode("");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmation("");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="public-landing auth-action-page"><section className="public-card">
|
||||||
|
<h1>i18n:govoplan-access.password.recover_title</h1>
|
||||||
|
{complete ? <DismissibleAlert tone="success" dismissible={false}>i18n:govoplan-access.password.recovered</DismissibleAlert> : <>
|
||||||
|
<p>i18n:govoplan-access.password.recovery_instructions</p>
|
||||||
|
<p>i18n:govoplan-access.password.recovery_consequences</p>
|
||||||
|
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.recover" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||||
|
{policy && !policy.recovery_enabled && <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.recovery_disabled</DismissibleAlert>}
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{policy?.recovery_enabled && <FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||||
|
<FormField label="i18n:govoplan-access.email.84add5b2" helpContextId="access.password.recover" helpModuleId="access"><input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required maxLength={320} disabled={busy} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.recovery_code")} value={code} onValueChange={setCode} autoComplete="off" maxLength={256} required disabled={busy} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-access.password.new" help={i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length })} helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={password} onValueChange={setPassword} autoComplete="new-password" minLength={policy.min_length} maxLength={2 * policy.max_length} required disabled={busy} generator /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * policy.max_length} required disabled={busy} /></FormField>
|
||||||
|
{confirmation && confirmation !== password && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||||
|
<Button type="submit" variant="primary" helpContextId="access.password.recover" helpModuleId="access" disabled={busy || !ready} disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.complete_fields" : undefined}>{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.recover_title"}</Button>
|
||||||
|
</FormLayout>}
|
||||||
|
</>}
|
||||||
|
<p><Link to="/" data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.return_sign_in</Link></p>
|
||||||
|
</section></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { fetchPasswordPolicy, passwordErrorMessage, type PasswordPolicy } from "../../api/passwords";
|
||||||
|
|
||||||
|
export function usePasswordPolicy(settings: ApiSettings) {
|
||||||
|
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [revision, reload] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let current = true;
|
||||||
|
setPolicy(null);
|
||||||
|
setError("");
|
||||||
|
fetchPasswordPolicy(settings).then((value) => {
|
||||||
|
if (current) setPolicy(value);
|
||||||
|
}).catch((reason) => {
|
||||||
|
if (current) setError(passwordErrorMessage(reason));
|
||||||
|
});
|
||||||
|
return () => { current = false; };
|
||||||
|
}, [settings.apiBaseUrl, revision]);
|
||||||
|
return { policy, error, reload: () => reload((value) => value + 1) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
ContentGrid,
|
||||||
|
DataGrid,
|
||||||
|
DismissibleAlert,
|
||||||
|
PageActionBar,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
formatAdminDateTime,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchAccountSessions,
|
||||||
|
revokeAccountSession,
|
||||||
|
revokeOtherAccountSessions,
|
||||||
|
type AccountSession
|
||||||
|
} from "../../api/sessions";
|
||||||
|
|
||||||
|
export default function SessionSettingsPanel({
|
||||||
|
settings,
|
||||||
|
auth
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
}) {
|
||||||
|
const [sessions, setSessions] = useState<AccountSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [revoking, setRevoking] = useState<AccountSession | null>(null);
|
||||||
|
const [revokingOthers, setRevokingOthers] = useState(false);
|
||||||
|
const interactive = auth.principal?.auth_method === "session";
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!interactive) {
|
||||||
|
setSessions([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchAccountSessions(settings);
|
||||||
|
setSessions(response.sessions);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [
|
||||||
|
auth.principal?.session_id,
|
||||||
|
settings.accessToken,
|
||||||
|
settings.apiBaseUrl,
|
||||||
|
settings.apiKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<AccountSession>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
header: "i18n:govoplan-access.device_or_client.5e551002",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
fill: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.client || "",
|
||||||
|
render: (row) => (
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{row.current
|
||||||
|
? "i18n:govoplan-access.current_session.5e551003"
|
||||||
|
: "i18n:govoplan-access.other_session.5e551004"}
|
||||||
|
</strong>
|
||||||
|
<div className="muted small-note">
|
||||||
|
{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "i18n:govoplan-access.status.bae7d5be",
|
||||||
|
width: 120,
|
||||||
|
value: (row) => row.status,
|
||||||
|
render: (row) => <StatusBadge status={row.status} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "last_seen",
|
||||||
|
header: "i18n:govoplan-access.last_seen.5e551006",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.last_seen_at || "",
|
||||||
|
render: (row) => formatAdminDateTime(row.last_seen_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
header: "i18n:govoplan-access.created.accf40c8",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.created_at,
|
||||||
|
render: (row) => formatAdminDateTime(row.created_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expires",
|
||||||
|
header: "i18n:govoplan-access.expires.a99be3da",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.expires_at,
|
||||||
|
render: (row) => formatAdminDateTime(row.expires_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-access.actions.c3cd636a",
|
||||||
|
width: 96,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<TableActionGroup
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "revoke",
|
||||||
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.current,
|
||||||
|
disabled: busy,
|
||||||
|
disabledReason: busy
|
||||||
|
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||||
|
: undefined,
|
||||||
|
onClick: () => setRevoking(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[busy]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function revokeOne() {
|
||||||
|
if (!revoking) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await revokeAccountSession(settings, revoking.id);
|
||||||
|
setRevoking(null);
|
||||||
|
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeOthers() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await revokeOtherAccountSessions(settings);
|
||||||
|
setRevokingOthers(false);
|
||||||
|
setSuccess(
|
||||||
|
response.revoked_count
|
||||||
|
? "i18n:govoplan-access.other_sessions_revoked.5e551009"
|
||||||
|
: "i18n:govoplan-access.no_other_active_sessions.5e551010"
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interactive) {
|
||||||
|
return (
|
||||||
|
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||||
|
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||||
|
<p>i18n:govoplan-access.browser_session_required.5e551011</p>
|
||||||
|
</Card>
|
||||||
|
</ContentGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||||
|
<PageActionBar
|
||||||
|
variant="detail"
|
||||||
|
actionScope="workspace"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{
|
||||||
|
onReload: () => void load(),
|
||||||
|
loading,
|
||||||
|
disabledReason: loading
|
||||||
|
? "i18n:govoplan-access.administration_data_is_loading.4af2c001"
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
destructiveActions={
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
|
disabled={busy || sessions.filter((item) => !item.current).length === 0}
|
||||||
|
disabledReason={
|
||||||
|
busy
|
||||||
|
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||||
|
: sessions.filter((item) => !item.current).length === 0
|
||||||
|
? "i18n:govoplan-access.no_other_active_sessions.5e551010"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => setRevokingOthers(true)}
|
||||||
|
>
|
||||||
|
i18n:govoplan-access.revoke_all_other_sessions.5e551012
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||||
|
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid
|
||||||
|
id="personal-sessions-v1"
|
||||||
|
rows={sessions}
|
||||||
|
columns={columns}
|
||||||
|
initialFit="container"
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
emptyText="i18n:govoplan-access.no_active_sessions.5e551013"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(revoking)}
|
||||||
|
title="i18n:govoplan-access.revoke_session.5e551007"
|
||||||
|
message="i18n:govoplan-access.revoke_session_confirmation.5e551014"
|
||||||
|
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke"
|
||||||
|
helpModuleId="access"
|
||||||
|
onCancel={() => setRevoking(null)}
|
||||||
|
onConfirm={() => void revokeOne()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={revokingOthers}
|
||||||
|
title="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||||
|
message="i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015"
|
||||||
|
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
|
onCancel={() => setRevokingOthers(false)}
|
||||||
|
onConfirm={() => void revokeOthers()}
|
||||||
|
/>
|
||||||
|
</ContentGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,27 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"en": {
|
||||||
|
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sessions and devices",
|
||||||
|
"i18n:govoplan-access.device_or_client.5e551002": "Device or client",
|
||||||
|
"i18n:govoplan-access.current_session.5e551003": "Current session",
|
||||||
|
"i18n:govoplan-access.other_session.5e551004": "Other session",
|
||||||
|
"i18n:govoplan-access.client_details_unavailable.5e551005": "Client details unavailable",
|
||||||
|
"i18n:govoplan-access.last_seen.5e551006": "Last seen",
|
||||||
|
"i18n:govoplan-access.revoke_session.5e551007": "Revoke session",
|
||||||
|
"i18n:govoplan-access.session_revoked.5e551008": "Session revoked.",
|
||||||
|
"i18n:govoplan-access.other_sessions_revoked.5e551009": "All other active sessions were revoked.",
|
||||||
|
"i18n:govoplan-access.no_other_active_sessions.5e551010": "There are no other active sessions.",
|
||||||
|
"i18n:govoplan-access.browser_session_required.5e551011": "Session management is available only from an interactive browser session.",
|
||||||
|
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Revoke all other sessions",
|
||||||
|
"i18n:govoplan-access.no_active_sessions.5e551013": "No active sessions were found.",
|
||||||
|
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "This device or client will lose access on its next authenticated request. The current session remains active.",
|
||||||
|
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Revoke every other active session for this account? This current session remains active.",
|
||||||
|
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Membership update permission is required to revoke sessions.",
|
||||||
|
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Inspect sessions for {value0}",
|
||||||
|
"i18n:govoplan-access.user_sessions.5e551018": "User sessions",
|
||||||
|
"i18n:govoplan-access.current_password_required.5e551019": "Enter your current password to continue.",
|
||||||
|
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Re-authorize this administrative action with your current password. The selected session will lose access on its next authenticated request.",
|
||||||
|
"i18n:govoplan-access.current_password.5e551021": "Current password",
|
||||||
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.",
|
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.",
|
||||||
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.",
|
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.",
|
||||||
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.",
|
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.",
|
||||||
@@ -286,7 +307,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||||
"i18n:govoplan-access.source_module.62b7241c": "Source module",
|
"i18n:govoplan-access.source_module.62b7241c": "Source module",
|
||||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. GovOPlaN retains only the displayed prefix and a verification hash.",
|
||||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||||
@@ -382,6 +403,27 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sitzungen und Geräte",
|
||||||
|
"i18n:govoplan-access.device_or_client.5e551002": "Gerät oder Client",
|
||||||
|
"i18n:govoplan-access.current_session.5e551003": "Aktuelle Sitzung",
|
||||||
|
"i18n:govoplan-access.other_session.5e551004": "Andere Sitzung",
|
||||||
|
"i18n:govoplan-access.client_details_unavailable.5e551005": "Keine Clientdetails verfügbar",
|
||||||
|
"i18n:govoplan-access.last_seen.5e551006": "Zuletzt aktiv",
|
||||||
|
"i18n:govoplan-access.revoke_session.5e551007": "Sitzung widerrufen",
|
||||||
|
"i18n:govoplan-access.session_revoked.5e551008": "Sitzung wurde widerrufen.",
|
||||||
|
"i18n:govoplan-access.other_sessions_revoked.5e551009": "Alle anderen aktiven Sitzungen wurden widerrufen.",
|
||||||
|
"i18n:govoplan-access.no_other_active_sessions.5e551010": "Es gibt keine anderen aktiven Sitzungen.",
|
||||||
|
"i18n:govoplan-access.browser_session_required.5e551011": "Die Sitzungsverwaltung ist nur in einer interaktiven Browsersitzung verfügbar.",
|
||||||
|
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Alle anderen Sitzungen widerrufen",
|
||||||
|
"i18n:govoplan-access.no_active_sessions.5e551013": "Es wurden keine aktiven Sitzungen gefunden.",
|
||||||
|
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "Dieses Gerät oder dieser Client verliert beim nächsten authentifizierten Aufruf den Zugriff. Die aktuelle Sitzung bleibt aktiv.",
|
||||||
|
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Alle anderen aktiven Sitzungen dieses Kontos widerrufen? Diese aktuelle Sitzung bleibt aktiv.",
|
||||||
|
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Zum Widerrufen von Sitzungen ist die Berechtigung zum Ändern von Mitgliedschaften erforderlich.",
|
||||||
|
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Sitzungen von {value0} prüfen",
|
||||||
|
"i18n:govoplan-access.user_sessions.5e551018": "Benutzersitzungen",
|
||||||
|
"i18n:govoplan-access.current_password_required.5e551019": "Geben Sie Ihr aktuelles Passwort ein, um fortzufahren.",
|
||||||
|
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Autorisieren Sie diese administrative Aktion erneut mit Ihrem aktuellen Passwort. Die ausgewählte Sitzung verliert beim nächsten authentifizierten Aufruf den Zugriff.",
|
||||||
|
"i18n:govoplan-access.current_password.5e551021": "Aktuelles Passwort",
|
||||||
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.",
|
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.",
|
||||||
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.",
|
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.",
|
||||||
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.",
|
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.",
|
||||||
@@ -666,7 +708,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||||
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
||||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Speichern Sie ihn in einem Secret-Manager. GovOPlaN speichert nur das angezeigte Präfix und einen Prüfhash.",
|
||||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const passwordTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"i18n:govoplan-access.password.change_title": "Change password",
|
||||||
|
"i18n:govoplan-access.password.required_title": "Change your initial password",
|
||||||
|
"i18n:govoplan-access.password.required": "Set a new password before opening your workspace. Enter the current or initial password you used to sign in.",
|
||||||
|
"i18n:govoplan-access.password.local_only": "Password changes require an interactive session for a local account. External accounts must use their identity provider.",
|
||||||
|
"i18n:govoplan-access.password.change_consequences": "Changing your password ends all other browser sessions and revokes all account API keys. This browser receives a new session. Update integrations with newly issued API keys afterwards. Unused recovery codes for this account and codes it issued for other people also become invalid.",
|
||||||
|
"i18n:govoplan-access.password.changed": "Your password was changed. Other sessions ended and account API keys were revoked.",
|
||||||
|
"i18n:govoplan-access.password.new": "New password",
|
||||||
|
"i18n:govoplan-access.password.confirm": "Confirm new password",
|
||||||
|
"i18n:govoplan-access.password.length": "Use between {value0} and {value1} characters.",
|
||||||
|
"i18n:govoplan-access.password.mismatch": "The new passwords do not match.",
|
||||||
|
"i18n:govoplan-access.password.complete_fields": "Complete the required fields and enter matching new passwords of the required length.",
|
||||||
|
"i18n:govoplan-access.password.saving": "Updating password…",
|
||||||
|
"i18n:govoplan-access.password.current_invalid": "Your current password was not accepted. Enter it again to authorize this action.",
|
||||||
|
"i18n:govoplan-access.password.invalid_new": "Use a new password between 10 and 1024 characters.",
|
||||||
|
"i18n:govoplan-access.password.unchanged": "Choose a password different from your current password.",
|
||||||
|
"i18n:govoplan-access.password.recovery_disabled": "Administrator-assisted password recovery is not enabled. Contact your administrator for help.",
|
||||||
|
"i18n:govoplan-access.password.rate_limited": "Too many attempts. Wait before trying again.",
|
||||||
|
"i18n:govoplan-access.password.recovery_invalid": "This recovery code is invalid, expired, already used, or no longer authorized. Ask your System owner for a new code.",
|
||||||
|
"i18n:govoplan-access.password.issuer_required": "Only a current System owner can issue a recovery code.",
|
||||||
|
"i18n:govoplan-access.password.membership_required": "This account needs an active tenant membership before password recovery is available.",
|
||||||
|
"i18n:govoplan-access.password.changed_concurrently": "The account password changed during this operation. Sign in again before continuing.",
|
||||||
|
"i18n:govoplan-access.password.session_expired": "Your session is no longer valid. Sign in again to continue.",
|
||||||
|
"i18n:govoplan-access.password.not_allowed": "This password operation is not permitted for the current account or session.",
|
||||||
|
"i18n:govoplan-access.password.invalid_fields": "Check the required fields and password length, then enter your credentials again.",
|
||||||
|
"i18n:govoplan-access.password.request_failed": "The password service could not complete the request. Check your connection and try again.",
|
||||||
|
"i18n:govoplan-access.password.forgot": "Forgot your password?",
|
||||||
|
"i18n:govoplan-access.password.recover_title": "Recover local password",
|
||||||
|
"i18n:govoplan-access.password.recovery_instructions": "Contact a System owner to verify your identity independently and receive a recovery code. Enter your account email and that code below. GovOPlaN does not send a recovery email.",
|
||||||
|
"i18n:govoplan-access.password.recovery_consequences": "Successful recovery ends all browser sessions and revokes all account API keys. Unused recovery codes for this account and codes it issued for other people also become invalid. You must then sign in with the new password.",
|
||||||
|
"i18n:govoplan-access.password.recovered": "Your password was replaced and existing sessions and API keys were revoked. Sign in with your new password.",
|
||||||
|
"i18n:govoplan-access.password.recovery_code": "Recovery code",
|
||||||
|
"i18n:govoplan-access.password.return_sign_in": "Return to sign in",
|
||||||
|
"i18n:govoplan-access.password.issue_title": "Issue recovery code",
|
||||||
|
"i18n:govoplan-access.password.issue_for": "Recover access for {value0}.",
|
||||||
|
"i18n:govoplan-access.password.issue_requirements": "Enter your current password and confirm that you independently verified this person's identity.",
|
||||||
|
"i18n:govoplan-access.password.identity_verified": "I independently verified this person's identity outside GovOPlaN.",
|
||||||
|
"i18n:govoplan-access.password.issue_consequences": "Issuing a code replaces earlier unused recovery codes. When used, it replaces the account password, ends every browser session, and revokes all account API keys. Codes the target account issued for other people also become invalid. Verify the account holder before proceeding.",
|
||||||
|
"i18n:govoplan-access.password.code_once": "This code is shown once. It can be used once before its expiry.",
|
||||||
|
"i18n:govoplan-access.password.code_expires": "Expires: {value0}",
|
||||||
|
"i18n:govoplan-access.password.code_delivery": "Give the code only to the verified account holder through your agreed confidential channel. Direct them to Password recovery from the sign-in screen. Closing this dialog clears the code from this screen."
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"i18n:govoplan-access.password.change_title": "Passwort ändern",
|
||||||
|
"i18n:govoplan-access.password.required_title": "Initiales Passwort ändern",
|
||||||
|
"i18n:govoplan-access.password.required": "Legen Sie ein neues Passwort fest, bevor Sie den Arbeitsbereich öffnen. Geben Sie das aktuelle oder initiale Passwort ein, mit dem Sie sich angemeldet haben.",
|
||||||
|
"i18n:govoplan-access.password.local_only": "Passwortänderungen benötigen eine interaktive Sitzung für ein lokales Konto. Externe Konten verwenden ihren Identitätsanbieter.",
|
||||||
|
"i18n:govoplan-access.password.change_consequences": "Die Passwortänderung beendet alle anderen Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Dieser Browser erhält eine neue Sitzung. Aktualisieren Sie anschließend Integrationen mit neu ausgestellten API-Schlüsseln. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig.",
|
||||||
|
"i18n:govoplan-access.password.changed": "Ihr Passwort wurde geändert. Andere Sitzungen wurden beendet und die API-Schlüssel des Kontos widerrufen.",
|
||||||
|
"i18n:govoplan-access.password.new": "Neues Passwort",
|
||||||
|
"i18n:govoplan-access.password.confirm": "Neues Passwort bestätigen",
|
||||||
|
"i18n:govoplan-access.password.length": "Verwenden Sie zwischen {value0} und {value1} Zeichen.",
|
||||||
|
"i18n:govoplan-access.password.mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||||
|
"i18n:govoplan-access.password.complete_fields": "Füllen Sie die Pflichtfelder aus und geben Sie übereinstimmende neue Passwörter der erforderlichen Länge ein.",
|
||||||
|
"i18n:govoplan-access.password.saving": "Passwort wird aktualisiert…",
|
||||||
|
"i18n:govoplan-access.password.current_invalid": "Ihr aktuelles Passwort wurde nicht akzeptiert. Geben Sie es erneut ein, um diese Aktion zu autorisieren.",
|
||||||
|
"i18n:govoplan-access.password.invalid_new": "Verwenden Sie ein neues Passwort mit 10 bis 1024 Zeichen.",
|
||||||
|
"i18n:govoplan-access.password.unchanged": "Wählen Sie ein anderes Passwort als Ihr aktuelles Passwort.",
|
||||||
|
"i18n:govoplan-access.password.recovery_disabled": "Die administrativ unterstützte Passwortwiederherstellung ist nicht aktiviert. Wenden Sie sich an Ihre Administration.",
|
||||||
|
"i18n:govoplan-access.password.rate_limited": "Zu viele Versuche. Warten Sie, bevor Sie es erneut versuchen.",
|
||||||
|
"i18n:govoplan-access.password.recovery_invalid": "Dieser Wiederherstellungscode ist ungültig, abgelaufen, bereits verwendet oder nicht mehr autorisiert. Bitten Sie den Systemverantwortlichen um einen neuen Code.",
|
||||||
|
"i18n:govoplan-access.password.issuer_required": "Nur ein aktueller Systemverantwortlicher darf einen Wiederherstellungscode ausstellen.",
|
||||||
|
"i18n:govoplan-access.password.membership_required": "Das Konto benötigt vor einer Passwortwiederherstellung eine aktive Mandantenmitgliedschaft.",
|
||||||
|
"i18n:govoplan-access.password.changed_concurrently": "Das Kontopasswort wurde während dieses Vorgangs geändert. Melden Sie sich erneut an, bevor Sie fortfahren.",
|
||||||
|
"i18n:govoplan-access.password.session_expired": "Ihre Sitzung ist nicht mehr gültig. Melden Sie sich erneut an, um fortzufahren.",
|
||||||
|
"i18n:govoplan-access.password.not_allowed": "Dieser Passwortvorgang ist für das aktuelle Konto oder die aktuelle Sitzung nicht erlaubt.",
|
||||||
|
"i18n:govoplan-access.password.invalid_fields": "Prüfen Sie Pflichtfelder und Passwortlänge und geben Sie Ihre Zugangsdaten erneut ein.",
|
||||||
|
"i18n:govoplan-access.password.request_failed": "Der Passwortdienst konnte die Anfrage nicht abschließen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||||
|
"i18n:govoplan-access.password.forgot": "Passwort vergessen?",
|
||||||
|
"i18n:govoplan-access.password.recover_title": "Lokales Passwort wiederherstellen",
|
||||||
|
"i18n:govoplan-access.password.recovery_instructions": "Wenden Sie sich an einen Systemverantwortlichen, um Ihre Identität unabhängig prüfen zu lassen und einen Wiederherstellungscode zu erhalten. Geben Sie unten Ihre Konto-E-Mail-Adresse und diesen Code ein. GovOPlaN versendet keine Wiederherstellungs-E-Mail.",
|
||||||
|
"i18n:govoplan-access.password.recovery_consequences": "Eine erfolgreiche Wiederherstellung beendet alle Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig. Melden Sie sich anschließend mit dem neuen Passwort an.",
|
||||||
|
"i18n:govoplan-access.password.recovered": "Ihr Passwort wurde ersetzt. Bestehende Sitzungen und API-Schlüssel wurden widerrufen. Melden Sie sich mit Ihrem neuen Passwort an.",
|
||||||
|
"i18n:govoplan-access.password.recovery_code": "Wiederherstellungscode",
|
||||||
|
"i18n:govoplan-access.password.return_sign_in": "Zurück zur Anmeldung",
|
||||||
|
"i18n:govoplan-access.password.issue_title": "Wiederherstellungscode ausstellen",
|
||||||
|
"i18n:govoplan-access.password.issue_for": "Zugriff für {value0} wiederherstellen.",
|
||||||
|
"i18n:govoplan-access.password.issue_requirements": "Geben Sie Ihr aktuelles Passwort ein und bestätigen Sie die unabhängige Prüfung der Identität dieser Person.",
|
||||||
|
"i18n:govoplan-access.password.identity_verified": "Ich habe die Identität dieser Person unabhängig außerhalb von GovOPlaN geprüft.",
|
||||||
|
"i18n:govoplan-access.password.issue_consequences": "Ein neuer Code ersetzt frühere unbenutzte Wiederherstellungscodes. Seine Verwendung ersetzt das Kontopasswort, beendet jede Browsersitzung und widerruft sämtliche API-Schlüssel des Kontos. Vom Zielkonto für andere Personen ausgestellte Codes werden ebenfalls ungültig. Prüfen Sie vorab die Identität des Kontoinhabers.",
|
||||||
|
"i18n:govoplan-access.password.code_once": "Dieser Code wird einmal angezeigt. Er kann vor seinem Ablauf einmal verwendet werden.",
|
||||||
|
"i18n:govoplan-access.password.code_expires": "Gültig bis: {value0}",
|
||||||
|
"i18n:govoplan-access.password.code_delivery": "Übermitteln Sie den Code ausschließlich dem verifizierten Kontoinhaber über den vereinbarten vertraulichen Kanal. Verweisen Sie auf die Passwortwiederherstellung im Anmeldebildschirm. Beim Schließen dieses Dialogs wird der Code aus dieser Ansicht entfernt."
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { default } from "./module";
|
export { default } from "./module";
|
||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/admin";
|
export * from "./api/admin";
|
||||||
|
export * from "./api/sessions";
|
||||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||||
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
||||||
|
|||||||
+40
-5
@@ -1,14 +1,19 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
import type { ActingContextRuntimeUiCapability, AuthActionUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||||
import { adminReadScopes } from "@govoplan/core-webui";
|
import { adminReadScopes } from "@govoplan/core-webui";
|
||||||
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import { passwordTranslations } from "./i18n/passwordTranslations";
|
||||||
|
|
||||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||||
|
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||||
|
const PasswordChangePanel = lazy(() => import("./features/passwords/PasswordChangePanel"));
|
||||||
|
const PasswordRecoveryPage = lazy(() => import("./features/passwords/PasswordRecoveryPage"));
|
||||||
|
const PasswordLoginHelp = lazy(() => import("./features/passwords/PasswordLoginHelp"));
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: { ...generatedTranslations.en, ...passwordTranslations.en },
|
||||||
de: generatedTranslations.de
|
de: { ...generatedTranslations.de, ...passwordTranslations.de }
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessAdminSurfaces = [
|
const accessAdminSurfaces = [
|
||||||
@@ -24,9 +29,34 @@ const accessAdminSurfaces = [
|
|||||||
{ id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 },
|
{ id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 },
|
||||||
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
||||||
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
||||||
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 }
|
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
|
||||||
|
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 },
|
||||||
|
{ id: "access.settings.password", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.password.change_title", order: 21 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const accessSettingsSections: SettingsSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "password",
|
||||||
|
surfaceId: "access.settings.password",
|
||||||
|
label: "i18n:govoplan-access.password.change_title",
|
||||||
|
group: "account",
|
||||||
|
order: 21,
|
||||||
|
render: ({ settings, auth, onAuthChange }) => onAuthChange
|
||||||
|
? createElement(PasswordChangePanel, { settings, auth, onAuthChange }) : null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sessions",
|
||||||
|
surfaceId: "access.settings.sessions",
|
||||||
|
label: "i18n:govoplan-access.sessions_and_devices.5e551001",
|
||||||
|
group: "account",
|
||||||
|
order: 20,
|
||||||
|
allOf: ["access:session:manage_own"],
|
||||||
|
render: ({ settings, auth }) => createElement(SessionSettingsPanel, { settings, auth })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
|
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
|
||||||
if (!onAuthChange) {
|
if (!onAuthChange) {
|
||||||
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
|
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
|
||||||
@@ -45,8 +75,13 @@ export const accessModule: PlatformWebModule = {
|
|||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
||||||
|
publicRoutes: [
|
||||||
|
{ path: "/password-recovery", render: ({ settings }) => createElement(PasswordRecoveryPage, { settings }) }
|
||||||
|
],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability
|
"auth.actions": { actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp } satisfies AuthActionUiCapability,
|
||||||
|
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
|
||||||
|
"settings.sections": accessSettingsSections
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user