Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8072b4ae | ||
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 | ||
|
|
206873b62a |
@@ -66,6 +66,9 @@ This module will own:
|
|||||||
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
||||||
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
||||||
- access-owned migrations
|
- access-owned migrations
|
||||||
|
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
|
||||||
|
credentials and authorization projections while preserving shared global
|
||||||
|
accounts and identities
|
||||||
|
|
||||||
The governance-template routes under `/admin/system/governance-templates` are
|
The governance-template routes under `/admin/system/governance-templates` are
|
||||||
contributed by `govoplan-admin`; access must not register those routes.
|
contributed by `govoplan-admin`; access must not register those routes.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ contracts.
|
|||||||
- tenant owner provisioning and default access bootstrap
|
- tenant owner provisioning and default access bootstrap
|
||||||
- materializing governance templates into access-owned groups and roles
|
- materializing governance templates into access-owned groups and roles
|
||||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||||
|
- the `tenancy.erasure_provider.access` contribution, which previews and
|
||||||
|
idempotently removes only target-tenant credentials and authorization rows
|
||||||
|
while retaining global accounts and identities shared with other tenants
|
||||||
|
|
||||||
The active access tables use the `access_*` namespace while the model classes
|
The active access tables use the `access_*` namespace while the model classes
|
||||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||||
@@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the
|
|||||||
core-owned `core_scopes` table as the scope table, but it must not import
|
core-owned `core_scopes` table as the scope table, but it must not import
|
||||||
`govoplan_tenancy` or require the tenancy package to start.
|
`govoplan_tenancy` or require the tenancy package to start.
|
||||||
|
|
||||||
|
## Tenant-Erasure Boundary
|
||||||
|
|
||||||
|
Access implements the Core tenant-erasure provider contract without importing
|
||||||
|
Tenancy. Its preview counts every Access table with a tenant boundary. The
|
||||||
|
first destructive step removes target-tenant sessions and API keys; the second
|
||||||
|
removes service accounts, memberships, groups, tenant roles, organization
|
||||||
|
units, functions, assignments, and delegations in dependency-safe order.
|
||||||
|
Both steps are database-transactional and idempotent, so reconciliation can
|
||||||
|
repeat them after an interrupted response.
|
||||||
|
|
||||||
|
Global accounts, system-role assignments, identities, and identity-account
|
||||||
|
links are intentionally retained: they are installation-wide facts and may be
|
||||||
|
used by another tenant. Provider previews and receipts contain counts and
|
||||||
|
stable references only, never password hashes, session tokens, API-key hashes,
|
||||||
|
email addresses, or other credential material.
|
||||||
|
|
||||||
## Core-Only Startup Contract
|
## Core-Only Startup Contract
|
||||||
|
|
||||||
A core-only installation must be able to start far enough to expose process
|
A core-only installation must be able to start far enough to expose process
|
||||||
|
|||||||
@@ -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
|
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||||
next request, including when a principal summary was previously cached.
|
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 administrators may list sessions only for a membership in their governed
|
||||||
tenant and may revoke only a session belonging to that membership and tenant.
|
tenant and may revoke only a session belonging to that membership and tenant.
|
||||||
The mutation requires both the central membership-update permission and an
|
The mutation requires both the central membership-update permission and an
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.19"
|
version = "0.1.25"
|
||||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.45",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN access platform module."""
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
__version__ = "0.1.19"
|
__version__ = "0.1.25"
|
||||||
|
|||||||
@@ -850,6 +850,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
|
|||||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
|
rollback: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationPackageExportRequest(BaseModel):
|
class ConfigurationPackageExportRequest(BaseModel):
|
||||||
@@ -865,6 +866,7 @@ class ConfigurationPackageExportResponse(BaseModel):
|
|||||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsItem(BaseModel):
|
class SystemSettingsItem(BaseModel):
|
||||||
|
|||||||
@@ -1036,6 +1036,7 @@ def configuration_package_apply_endpoint(
|
|||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
created_refs=dict(result.created_refs),
|
created_refs=dict(result.created_refs),
|
||||||
updated_refs=dict(result.updated_refs),
|
updated_refs=dict(result.updated_refs),
|
||||||
|
rollback=result.rollback.to_dict() if result.rollback is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1056,6 +1057,7 @@ def configuration_package_export_endpoint(
|
|||||||
fragments=[item.to_dict() for item in result.fragments],
|
fragments=[item.to_dict() for item in result.fragments],
|
||||||
data_requirements=[item.to_dict() for item in result.data_requirements],
|
data_requirements=[item.to_dict() for item in result.data_requirements],
|
||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
|
provenance=result.provenance.to_dict() if result.provenance is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,12 @@ def _rehydrate_cached_principal(
|
|||||||
source: str,
|
source: str,
|
||||||
principal: PrincipalRef,
|
principal: PrincipalRef,
|
||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
# A cache hit must retain the same credential-source rules as a cold read.
|
||||||
|
# API keys are explicit-header credentials, never browser session cookies.
|
||||||
|
if principal.auth_method not in {"session", "api_key"} or (
|
||||||
|
principal.auth_method == "api_key" and source == "cookie"
|
||||||
|
):
|
||||||
|
return None
|
||||||
account = session.get(Account, principal.account_id)
|
account = session.get(Account, principal.account_id)
|
||||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||||
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||||
@@ -327,6 +333,10 @@ def _rehydrate_cached_principal(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if principal.auth_method == "api_key":
|
if principal.auth_method == "api_key":
|
||||||
|
# Service accounts have a separate current scope ceiling and lifecycle.
|
||||||
|
# Do not reuse an ordinary API-key summary for their backing identities.
|
||||||
|
if account.auth_provider == "service_account" or user.auth_provider == "service_account":
|
||||||
|
return None
|
||||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||||
if (
|
if (
|
||||||
api_key is None
|
api_key is None
|
||||||
@@ -408,7 +418,10 @@ def _cache_resolved_principal_context(
|
|||||||
identity_directory: IdentityDirectory | None,
|
identity_directory: IdentityDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
) -> ResolvedPrincipalContext:
|
) -> ResolvedPrincipalContext:
|
||||||
if not settings.auth_principal_cache_enabled:
|
if not settings.auth_principal_cache_enabled or context.principal.auth_method not in {"session", "api_key"}:
|
||||||
|
# In particular, service-account credentials must keep their dedicated
|
||||||
|
# provenance and be intersected with the current service-account ceiling
|
||||||
|
# on every request, not recomputed from interactive membership roles.
|
||||||
return context
|
return context
|
||||||
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
refreshed = _refresh_principal_context(
|
refreshed = _refresh_principal_context(
|
||||||
|
|||||||
@@ -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.'}}
|
||||||
+1074
-127
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(
|
allowed.update(
|
||||||
scope
|
scope
|
||||||
for scope in user_raw.intersection(key_raw)
|
for scope in user_raw.intersection(key_raw)
|
||||||
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
|
if _is_concrete_tenant_credential_scope(scope, catalog)
|
||||||
)
|
)
|
||||||
return sorted(allowed)
|
return sorted(allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_concrete_tenant_credential_scope(
|
||||||
|
scope: str,
|
||||||
|
catalog: Mapping[str, PermissionDefinition],
|
||||||
|
) -> bool:
|
||||||
|
# Wildcards are expanded against the tenant catalogue above. Returning the
|
||||||
|
# wildcard itself could grant system permissions sharing the module prefix,
|
||||||
|
# or permissions outside the currently known tenant catalogue.
|
||||||
|
if scope == "*" or scope.endswith(":*"):
|
||||||
|
return False
|
||||||
|
# System permissions can use module-native names (e.g. access:tenant:create),
|
||||||
|
# so excluding only the historical system: prefix is not sufficient.
|
||||||
|
return all(
|
||||||
|
not alias.startswith("system:")
|
||||||
|
and (alias not in catalog or catalog[alias].level == "tenant")
|
||||||
|
for alias in compatible_required_scopes(scope)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
||||||
registry = _registry()
|
registry = _registry()
|
||||||
if registry is not None and hasattr(registry, "permissions"):
|
if registry is not None and hasattr(registry, "permissions"):
|
||||||
|
|||||||
@@ -68,8 +68,17 @@ def list_account_sessions(
|
|||||||
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||||
if tenant_id is not None:
|
if tenant_id is not None:
|
||||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||||
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
|
if not include_inactive:
|
||||||
summaries = tuple(
|
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(
|
session_summary(
|
||||||
item,
|
item,
|
||||||
current_session_id=current_session_id,
|
current_session_id=current_session_id,
|
||||||
@@ -77,9 +86,6 @@ def list_account_sessions(
|
|||||||
)
|
)
|
||||||
for item in rows
|
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(
|
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()
|
||||||
@@ -5,6 +5,10 @@ from types import SimpleNamespace
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
ConfigurationPackageApplyResponse,
|
||||||
|
ConfigurationPackageExportResponse,
|
||||||
|
)
|
||||||
from govoplan_access.backend.api.v1.routes import _configuration_context
|
from govoplan_access.backend.api.v1.routes import _configuration_context
|
||||||
from govoplan_core.core.infrastructure_capabilities import (
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
InfrastructureCapabilityReceiptError,
|
InfrastructureCapabilityReceiptError,
|
||||||
@@ -16,6 +20,39 @@ from govoplan_core.core.provider_governance import (
|
|||||||
|
|
||||||
|
|
||||||
class ConfigurationPackageContextTests(unittest.TestCase):
|
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||||
|
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
|
||||||
|
applied = ConfigurationPackageApplyResponse(
|
||||||
|
rollback={
|
||||||
|
"status": "database_restore_required",
|
||||||
|
"summary": "Snapshot is the generic rollback boundary.",
|
||||||
|
"recovery_action": "Retain the snapshot.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exported = ConfigurationPackageExportResponse(
|
||||||
|
provenance={
|
||||||
|
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||||
|
"source_core_version": "0.1.35",
|
||||||
|
"module_versions": {"forms": "0.1.20"},
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"exporter_id": "user-1",
|
||||||
|
"selection": {
|
||||||
|
"scopes": ["tenant"],
|
||||||
|
"module_ids": ["forms"],
|
||||||
|
"object_refs": [],
|
||||||
|
},
|
||||||
|
"redacted_secret_keys": ["credential_ref"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"database_restore_required",
|
||||||
|
applied.model_dump()["rollback"]["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["credential_ref"],
|
||||||
|
exported.model_dump()["provenance"]["redacted_secret_keys"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||||
receipt = SimpleNamespace(installation_id="deployment-1")
|
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||||
principal = SimpleNamespace(
|
principal = SimpleNamespace(
|
||||||
|
|||||||
@@ -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):
|
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:
|
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||||
topics = {topic.id: topic for topic in manifest.documentation}
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
|
||||||
@@ -112,9 +130,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_access_admin_surfaces_remain_declared(self) -> None:
|
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||||
surface_ids = {
|
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||||
surface.id for surface in manifest.frontend.view_surfaces
|
|
||||||
}
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
{
|
{
|
||||||
"access.admin.system-roles",
|
"access.admin.system-roles",
|
||||||
|
|||||||
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
|
|||||||
self.assertIn("files:file:read", scopes)
|
self.assertIn("files:file:read", scopes)
|
||||||
self.assertIn("files:read", scopes)
|
self.assertIn("files:read", scopes)
|
||||||
|
|
||||||
|
def test_api_key_intersection_excludes_canonical_and_legacy_system_scopes(self) -> None:
|
||||||
|
for scope in ("access:system_credential:write", "access:tenant:create", "system:tenants:create"):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
self.assertEqual([], access_catalog.intersect_api_key_scopes([scope], [scope]))
|
||||||
|
|
||||||
|
def test_api_key_module_wildcards_expand_only_to_concrete_tenant_scopes(self) -> None:
|
||||||
|
scopes = access_catalog.intersect_api_key_scopes(["access:*"], ["access:*"])
|
||||||
|
self.assertIn("access:membership:read", scopes)
|
||||||
|
self.assertNotIn("access:*", scopes)
|
||||||
|
self.assertFalse(access_catalog.scopes_grant(scopes, "access:system_credential:write"))
|
||||||
|
catalog = access_catalog.permission_map()
|
||||||
|
self.assertTrue(all(catalog[scope].level == "tenant" for scope in scopes if scope in catalog))
|
||||||
|
|
||||||
|
def test_api_key_intersection_preserves_unknown_concrete_module_grants(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
["optional-module:record:read"],
|
||||||
|
access_catalog.intersect_api_key_scopes(["optional-module:record:read"], ["optional-module:record:read"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_key_intersection_preserves_concrete_tenant_compatibility_aliases(self) -> None:
|
||||||
|
scopes = access_catalog.intersect_api_key_scopes(["files:read"], ["files:file:read"])
|
||||||
|
self.assertIn("files:read", scopes)
|
||||||
|
self.assertIn("files:file:read", scopes)
|
||||||
|
self.assertTrue(access_catalog.scopes_grant(scopes, "files:file:read"))
|
||||||
|
|
||||||
|
def test_api_key_intersection_does_not_retain_unknown_wildcards(self) -> None:
|
||||||
|
self.assertEqual([], access_catalog.intersect_api_key_scopes(["optional-module:*"], ["optional-module:*"]))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_access.backend.db.base import AccessBase
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
@@ -182,6 +182,36 @@ class SessionManagementTests(unittest.TestCase):
|
|||||||
self.assertIsNone(hidden)
|
self.assertIsNone(hidden)
|
||||||
self.assertFalse(changed)
|
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:
|
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "current session"):
|
with self.assertRaisesRegex(ValueError, "current session"):
|
||||||
revoke_account_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",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Plus, Search, Trash2 } from "lucide-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 { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-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 {
|
import {
|
||||||
createExternalFunctionRoleMapping,
|
createExternalFunctionRoleMapping,
|
||||||
deleteExternalFunctionRoleMapping,
|
deleteExternalFunctionRoleMapping,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type ExternalFunctionRoleMappingItem,
|
type ExternalFunctionRoleMappingItem,
|
||||||
type RoleSummary
|
type RoleSummary
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button, FormGrid } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-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 { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-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 { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-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 {
|
import {
|
||||||
createSystemRole,
|
createSystemRole,
|
||||||
deleteSystemRole,
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search, Pencil, Plus, Trash2 } from "lucide-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 { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -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.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.system-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-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 { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -249,6 +249,8 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
id: "revoke-session",
|
id: "revoke-session",
|
||||||
label: "i18n:govoplan-access.revoke_session.5e551007",
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
applicable: !row.current,
|
applicable: !row.current,
|
||||||
disabled: busy || !canRevokeSessions,
|
disabled: busy || !canRevokeSessions,
|
||||||
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
|
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.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.tenant-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -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>
|
<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>
|
</FormGrid>
|
||||||
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} helpContextId="access.admin.tenant-users.require-password-change" helpModuleId="access" onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||||
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||||
@@ -320,11 +324,11 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
</>}
|
</>}
|
||||||
</Dialog>
|
</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>}
|
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||||
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
|
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
|
||||||
<FormField label="i18n:govoplan-access.current_password.5e551021">
|
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.sessions.field.current-password" helpModuleId="access">
|
||||||
<PasswordField value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
|
<PasswordField helpContextId="access.sessions.field.current-password" helpModuleId="access" value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export default function SessionSettingsPanel({
|
|||||||
id: "revoke",
|
id: "revoke",
|
||||||
label: "i18n:govoplan-access.revoke_session.5e551007",
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
applicable: !row.current,
|
applicable: !row.current,
|
||||||
disabled: busy,
|
disabled: busy,
|
||||||
disabledReason: busy
|
disabledReason: busy
|
||||||
@@ -208,6 +210,8 @@ export default function SessionSettingsPanel({
|
|||||||
destructiveActions={
|
destructiveActions={
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
disabled={busy || sessions.filter((item) => !item.current).length === 0}
|
disabled={busy || sessions.filter((item) => !item.current).length === 0}
|
||||||
disabledReason={
|
disabledReason={
|
||||||
busy
|
busy
|
||||||
@@ -243,6 +247,8 @@ export default function SessionSettingsPanel({
|
|||||||
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
|
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke"
|
||||||
|
helpModuleId="access"
|
||||||
onCancel={() => setRevoking(null)}
|
onCancel={() => setRevoking(null)}
|
||||||
onConfirm={() => void revokeOne()}
|
onConfirm={() => void revokeOne()}
|
||||||
/>
|
/>
|
||||||
@@ -253,6 +259,8 @@ export default function SessionSettingsPanel({
|
|||||||
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
onCancel={() => setRevokingOthers(false)}
|
onCancel={() => setRevokingOthers(false)}
|
||||||
onConfirm={() => void revokeOthers()}
|
onConfirm={() => void revokeOthers()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user