Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8072b4ae | ||
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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,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.'}}
|
||||
+1019
-129
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
|
||||
+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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user