5 Commits
Author SHA1 Message Date
zemion 8f8072b4ae Release govoplan-access v0.1.25: harden authentication and repair identity mappings
Module Package Release / publish-packages (push) Successful in 15s
2026-09-08 01:32:20 +02:00
zemion 0f8a05f8b9 feat: contribute tenant erasure for access data
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 15:57:20 +02:00
zemion e55434f406 feat(access): document consequential credential controls
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:30 +02:00
zemion a889071b71 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:30 +02:00
zemion 8a43b9b676 docs(access): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:11:45 +02:00
31 changed files with 2420 additions and 167 deletions
+3
View File
@@ -66,6 +66,9 @@ This module will own:
capabilities, including the bounded `access.governanceProjection.v1` bulk
reconciliation contract used by Admin for idempotent per-assignment outcomes
- 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
contributed by `govoplan-admin`; access must not register those routes.
+19
View File
@@ -25,6 +25,9 @@ contracts.
- tenant owner provisioning and default access bootstrap
- materializing governance templates into access-owned groups and roles
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
- the `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
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
`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
A core-only installation must be able to start far enough to expose process
+64
View File
@@ -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.
+15
View File
@@ -11,6 +11,21 @@ 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.20",
"version": "0.1.25",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-access"
version = "0.1.20"
version = "0.1.25"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.45",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.20"
__version__ = "0.1.25"
@@ -311,6 +311,12 @@ def _rehydrate_cached_principal(
source: str,
principal: PrincipalRef,
) -> 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)
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
@@ -327,6 +333,10 @@ def _rehydrate_cached_principal(
return None
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
if (
api_key is None
@@ -408,7 +418,10 @@ def _cache_resolved_principal_context(
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> 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
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
refreshed = _refresh_principal_context(
@@ -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.'}}
File diff suppressed because it is too large Load Diff
@@ -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,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
@@ -212,11 +212,29 @@ def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[st
allowed.update(
scope
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)
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, ...]:
registry = _registry()
if registry is not None and hasattr(registry, "permissions"):
@@ -68,8 +68,17 @@ def list_account_sessions(
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
if tenant_id is not None:
query = query.filter(AuthSession.tenant_id == tenant_id)
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
summaries = tuple(
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,
@@ -77,9 +86,6 @@ def list_account_sessions(
)
for item in rows
)
if not include_inactive:
summaries = tuple(item for item in summaries if item.status == "active")
return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))]
def revoke_account_session(
@@ -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",
]
+120
View File
@@ -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()
@@ -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()
+19 -3
View File
@@ -6,6 +6,24 @@ from govoplan_access.backend.manifest import manifest
class InterfaceDocumentationContractTests(unittest.TestCase):
def test_password_change_flag_is_documented_as_unenforced(self) -> None:
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
self.assertIn("currently advisory metadata", topic.body)
self.assertIn("server-side enforcement are not implemented", topic.body)
self.assertIn("serverseitige Durchsetzung sind noch nicht umgesetzt", topic.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:
topics = {topic.id: topic for topic in manifest.documentation}
@@ -112,9 +130,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
)
def test_access_admin_surfaces_remain_declared(self) -> None:
surface_ids = {
surface.id for surface in manifest.frontend.view_surfaces
}
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
self.assertTrue(
{
"access.admin.system-roles",
+28
View File
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
self.assertIn("files:file: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__":
unittest.main()
+31 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase
@@ -182,6 +182,36 @@ class SessionManagementTests(unittest.TestCase):
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(
+135
View File
@@ -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}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.20",
"version": "0.1.25",
"private": true,
"type": "module",
"scripts": {
@@ -16,7 +16,7 @@
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import {
createExternalFunctionRoleMapping,
deleteExternalFunctionRoleMapping,
@@ -10,7 +10,7 @@ import {
type ExternalFunctionRoleMappingItem,
type RoleSummary
} 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 { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
+2 -2
View File
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
+2 -2
View File
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "@govoplan/core-webui";
import {
createSystemRole,
deleteSystemRole,
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -239,8 +239,10 @@ export default function SystemUsersPanel({
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.system-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
+11 -7
View File
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, 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 { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -249,6 +249,8 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
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,
@@ -283,8 +285,10 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.tenant-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
@@ -294,7 +298,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
}
<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>
</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>}
<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>
@@ -320,11 +324,11 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
</>}
</Dialog>
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" 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></>}>
<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">
<PasswordField value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
<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>
@@ -132,6 +132,8 @@ export default function SessionSettingsPanel({
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
@@ -208,6 +210,8 @@ export default function SessionSettingsPanel({
destructiveActions={
<Button
variant="danger"
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
disabled={busy || sessions.filter((item) => !item.current).length === 0}
disabledReason={
busy
@@ -243,6 +247,8 @@ export default function SessionSettingsPanel({
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke"
helpModuleId="access"
onCancel={() => setRevoking(null)}
onConfirm={() => void revokeOne()}
/>
@@ -253,6 +259,8 @@ export default function SessionSettingsPanel({
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()}
/>