5 Commits
Author SHA1 Message Date
zemion 0f8a05f8b9 feat: contribute tenant erasure for access data
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 15:57:20 +02:00
zemion e55434f406 feat(access): document consequential credential controls
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:30 +02:00
zemion a889071b71 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:30 +02:00
zemion 8a43b9b676 docs(access): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:11:45 +02:00
zemion 206873b62a Release Access v0.1.20 configuration package context
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 18:04:33 +02:00
17 changed files with 1782 additions and 135 deletions
+3
View File
@@ -66,6 +66,9 @@ This module will own:
capabilities, including the bounded `access.governanceProjection.v1` bulk
reconciliation contract used by Admin for idempotent per-assignment outcomes
- access-owned migrations
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
credentials and authorization projections while preserving shared global
accounts and identities
The governance-template routes under `/admin/system/governance-templates` are
contributed by `govoplan-admin`; access must not register those routes.
+19
View File
@@ -25,6 +25,9 @@ contracts.
- tenant owner provisioning and default access bootstrap
- materializing governance templates into access-owned groups and roles
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
- the `tenancy.erasure_provider.access` contribution, which previews and
idempotently removes only target-tenant credentials and authorization rows
while retaining global accounts and identities shared with other tenants
The active access tables use the `access_*` namespace while the model classes
live in this module: `access_accounts`, `access_users`, `access_groups`,
@@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the
core-owned `core_scopes` table as the scope table, but it must not import
`govoplan_tenancy` or require the tenancy package to start.
## Tenant-Erasure Boundary
Access implements the Core tenant-erasure provider contract without importing
Tenancy. Its preview counts every Access table with a tenant boundary. The
first destructive step removes target-tenant sessions and API keys; the second
removes service accounts, memberships, groups, tenant roles, organization
units, functions, assignments, and delegations in dependency-safe order.
Both steps are database-transactional and idempotent, so reconciliation can
repeat them after an interrupted response.
Global accounts, system-role assignments, identities, and identity-account
links are intentionally retained: they are installation-wide facts and may be
used by another tenant. Provider previews and receipts contain counts and
stable references only, never password hashes, session tokens, API-key hashes,
email addresses, or other credential material.
## Core-Only Startup Contract
A core-only installation must be able to start far enough to expose process
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.19",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-access"
version = "0.1.19"
version = "0.1.24"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.43",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.19"
__version__ = "0.1.24"
@@ -850,6 +850,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
created_refs: dict[str, str] = Field(default_factory=dict)
updated_refs: dict[str, str] = Field(default_factory=dict)
rollback: dict[str, Any] | None = None
class ConfigurationPackageExportRequest(BaseModel):
@@ -865,6 +866,7 @@ class ConfigurationPackageExportResponse(BaseModel):
fragments: list[dict[str, Any]] = Field(default_factory=list)
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
provenance: dict[str, Any] | None = None
class SystemSettingsItem(BaseModel):
@@ -1036,6 +1036,7 @@ def configuration_package_apply_endpoint(
diagnostics=[item.to_dict() for item in result.diagnostics],
created_refs=dict(result.created_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],
data_requirements=[item.to_dict() for item in result.data_requirements],
diagnostics=[item.to_dict() for item in result.diagnostics],
provenance=result.provenance.to_dict() if result.provenance is not None else None,
)
@@ -0,0 +1,326 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'access.reference.admin-access-fields': {'fields': [{'admin_description': 'Auf dem Konto und den '
'Mitglieder-Payloads '
'gespeichert. Es muss '
'normalisiert und '
'eindeutig für das '
'entsprechende '
'Login-Konto sein.',
'api_field': 'email',
'api_path': '/api/v1/admin/users',
'field_id': 'access.user.email',
'label': 'E-Mail',
'permission_scope': 'access:membership:create',
'provenance': 'Mandantenmitgliedschaft oder '
'Kontosuche.',
'user_description': 'Die Adresse, die '
'verwendet wird, um die '
'Person zu '
'identifizieren, wenn '
'sie sich anmelden.',
'validation': 'Muss eine gültige '
'E-Mail-Adresse sein.'},
{'admin_description': 'Wird, sofern verfügbar, '
'in Benutzer- und '
'Kontoantworten als '
'display_name ausgegeben.',
'api_field': 'display_name',
'api_path': '/api/v1/admin/users',
'field_id': 'access.user.display_name',
'label': 'Anzeigename',
'permission_scope': 'access:membership:update',
'provenance': 'Profil der '
'Mandantenmitgliedschaft.',
'user_description': 'Der lesbare Name, der '
'in Benutzerlisten und '
'Bewertungsbildschirmen '
'angezeigt wird.',
'validation': 'Menschenlesbarer Text; Halten '
'Sie ihn für Administratoren '
'erkennbar.'},
{'admin_description': 'Wird beim Aktualisieren '
'einer Benutzer- oder '
'Gruppenmitgliedschaft '
'als group_ids übertragen.',
'api_field': 'group_ids',
'api_path': '/api/v1/admin/users/{user_id}',
'field_id': 'access.user.groups',
'label': 'Gruppen',
'permission_scope': 'access:group:manage_members',
'provenance': 'Benutzergruppenmitgliedschaftszeilen.',
'user_description': 'Gemeinsame Zugriffsbündel, '
'die Rollen für viele '
'Personen gleichzeitig '
'hinzufügen können.',
'validation': 'Gruppen müssen zum gleichen '
'Mandant gehören.'},
{'admin_description': 'Wird bei '
'Aktualisierungsanforderungen '
'für Benutzer- und '
'Gruppenrollen als role_ids '
'übertragen.',
'api_field': 'role_ids',
'api_path': '/api/v1/admin/users/{user_id}',
'field_id': 'access.user.roles',
'label': 'Rollen',
'permission_scope': 'access:role:assign',
'provenance': 'Direkte Benutzerrollen plus '
'Gruppenrollenvererbung.',
'user_description': 'Direktzugangszuschüsse, '
'die einer Person '
'zugewiesen oder von '
'Gruppen geerbt wurden.',
'validation': 'Rollen müssen zuordenbar sein '
'und dürfen das '
'Delegationslimit der '
'handelnden Person nicht '
'überschreiten.'},
{'admin_description': 'Bildet Scopes beim '
'Erstellen eines '
'API-Schlüssel zu und '
'wird mit den aktuellen '
'Berechtigungen des '
'Besitzers geschnitten.',
'api_field': 'scopes',
'api_path': '/api/v1/admin/api-keys',
'field_id': 'access.api_key.scopes',
'label': 'Anwendungsbereiche',
'permission_scope': 'access:api_key:create',
'provenance': 'API-Schlüssel Grant plus '
'Eigentümerdelegation.',
'user_description': 'Die Aktionen, die ein '
'API-Schlüssel ausführen '
'kann.',
'validation': 'Verwenden Sie möglichst enge '
'Berechtigungsbereiche.'}]},
'access.reference.personal-navigation': {'outcome': 'Die Seitenschiene des Benutzers spiegelt die '
'persönlichen Präferenzen wider, während '
'verschlossene und unzugängliche Einträge '
'durch übergeordnete Richtlinien geregelt '
'bleiben.'},
'access.workflow.configuration-packages': {'limitations': ['Die Paketübernahme installiert keine '
'fehlenden Module.',
'Die anbieterübergreifende Übernahme ist keine atomar '
'verteilte Transaktion.',
'Generisches Rollback hängt von einem '
'beibehaltenen '
'vor der Übernahme erstellten Datenbank-Snapshot ab.'],
'operational_consequences': ['Ein abgestandener oder '
'blockierter Preflight '
'muss vor der Anwendung '
'erneut durchgeführt '
'werden.',
'Eine teilweise Anwendung '
'erfordert eine '
'Wiederherstellung, bevor '
'das Paket erneut '
'getestet wird.',
'Geheimwerte bleiben '
'außerhalb tragbarer '
'Fragmente und '
'Herkunft.']},
'access.workflow.data-subject-request': {'limitations': ['Module ohne DSAR-Anbieter werden als '
'Deckungslücken gemeldet.',
'Globale Konten und Identitäten werden '
'nicht automatisch gelöscht.']},
'access.workflow.grant-user-access': {'outcome': 'Eine Person kann sich beim Mandant anmelden und '
'erhält den beabsichtigten Zugang durch Gruppen '
'und Rollen.',
'prerequisites': ['Sie können Admin öffnen.',
'Sie können Benutzer, Gruppen und Rollen '
'lesen.',
'Schreib- oder Zuweisungsaktionen '
'erfordern übereinstimmende '
'Verwaltungsberechtigungen.'],
'result': 'Die Mitgliedschaft hat die beabsichtigten '
'effektiven Berechtigungen und keine breiteren '
'Rollen als nötig.',
'steps': ['Öffnen Sie Admin und gehen Sie zu Benutzern.',
'Finden Sie die bestehende Person oder erstellen '
'Sie eine Mitgliedschaft mit ihrer E-Mail-Adresse '
'und dem Anzeigenamen.',
'Überprüfen Sie aktuelle Gruppen und direkte '
'Rollen, bevor Sie etwas ändern.',
'Fügen Sie die Person der kleinsten Gruppe hinzu, '
'die den erforderlichen gemeinsamen Zugriff '
'gewährt.',
'Weisen Sie direkte Rollen nur zu, wenn eine '
'Gruppe nicht mit dem Fall übereinstimmt.',
'Speichern und überprüfen Sie eine '
'Blockernachricht, bevor Sie einen System- oder '
'Mandantbesitzer um Hilfe bitten.'],
'verification': 'Öffnen Sie den Benutzer erneut und '
'vergleichen Sie Gruppen, direkte Rollen '
'und effektive Berechtigungen mit der '
'Anforderung.'},
'access.workflow.manage-api-keys': {'consequences': ['Der Widerruf lehnt nachfolgende Anfragen, '
'die mit dem Schlüssel gestellt wurden, '
'sofort ab.',
'Durch das Entfernen von Berechtigungen vom '
'Besitzer wird der effektive '
'Schlüsselzugriff sofort eingeschränkt.'],
'limitations': ['Ein einmaliges Geheimnis kann nach dem '
'Schließen des Erstellungsdialogs nicht '
'angezeigt oder wiederhergestellt werden.',
'Ändern des Besitzers, Ablauf oder Scopes '
'erfordert einen Ersatzschlüssel.',
'Der Widerruf aktualisiert keine externen '
'Clients; die Betreiber müssen bei Bedarf '
'einen Ersatz installieren.'],
'outcome': 'Der Automatisierungsclient verfügt über einen '
'zeitlich begrenzten Berechtigungsnachweis, dessen '
'effektiver Zugriff weder seine gespeicherten '
'Berechtigungsbereiche noch die aktuellen '
'Berechtigungen seines Besitzers überschreiten '
'kann.',
'prerequisites': ['Der Mandant erlaubt '
'API-Anmeldeinformationen.',
'Die handelnde Person kann API-Schlüssel '
'erstellen oder widerrufen und jeden '
'ausgewählten Bereich delegieren.',
'Ein zugelassener externer Geheimmanager '
'und rechenschaftspflichtiger Eigentümer '
'sind bekannt.'],
'steps': ['Wählen Sie den verantwortlichen Eigentümer und die '
'engsten erforderlichen Berechtigungsbereiche.',
'Legen Sie den kürzesten praktischen Ablauf fest, '
'bevor Sie den Schlüssel erstellen.',
'Übertragen Sie das einmalige Geheimnis direkt in '
'den genehmigten Geheimmanager.',
'Widerrufen Sie den Schlüssel, wenn sein Client, '
'Eigentümer oder Zweck nicht mehr gültig ist.'],
'verification': 'Laden Sie das Schlüsselverzeichnis neu, '
'überprüfen Sie Eigentümer, Präfix, '
'Berechtigungsumfang, Ablauf und Status und '
'testen Sie dann den beabsichtigten Client, '
'ohne geheimes Material in Nachweise zu '
'kopieren.'},
'access.workflow.manage-reusable-credentials': {'limitations': ['GovOPlaN kann ein konfiguriertes '
'Geheimnis nicht anzeigen oder '
'wiederherstellen.',
'Eine leere Modul- oder '
'Serverbeschränkung bedeutet '
'jeden Wert, der nach '
'Berechtigungsumfang zulässig '
'ist.',
'Das Löschen oder Leeren eines '
'Geheimnisses schreibt keine '
'abhängigen Verbindungsreferenzen '
'neu.'],
'outcome': 'Die Zugangsdaten bleiben '
'schreibgeschützt und sind nur '
'innerhalb seines aktiven '
'Berechtigungsumfangs, Moduls, Servers '
'und Autorisierungsgrenzen verwendbar.',
'prerequisites': ['Der beabsichtigte '
'Berechtigungsinhaber wird '
'ausgewählt.',
'Die handelnde Person kann '
'Anmeldeinformationen lesen und '
'hat Schreibautorität für '
'Mutationen.',
'Der externe '
'Secret-Manager-Eigentümer und '
'abhängige Verbindungen sind '
'bekannt.'],
'steps': ['Wählen Sie den engsten Besitzumfang '
'und Anmeldetyp.',
'Beschränken Sie Module und Server '
'explizit, wenn eine breite Nutzung '
'nicht beabsichtigt ist.',
'Speichern Sie ein neues oder '
'Ersatzgeheimnis, ohne zu erwarten, '
'dass es erneut angezeigt wird.',
'Überprüfen Sie abhängige Verbindungen '
'vor der Deaktivierung, geheimen '
'Löschung oder Löschung.'],
'verification': 'Laden Sie die Liste der '
'Zugangsdaten neu, bestätigen Sie '
'deren Berechtigungsumfang und '
'Verfügbarkeit und testen Sie '
'dann jede beabsichtigte '
'abhängige Verbindung, ohne das '
'Geheimnis zu enthüllen.'},
'access.workflow.manage-service-account-credentials': {'consequences': ['Rotation widerruft den '
'vorherigen Nachweis in '
'der gleichen '
'Transaktion, die seinen '
'Ersatz schafft.',
'Der Widerruf, die '
'Deaktivierung des Kontos '
'und der Ruhestand lehnen '
'betroffene '
'Kundenanfragen sofort '
'ab.',
'Eine veraltete Revision '
'wird abgelehnt, so dass '
'ein gleichzeitiger '
'Verwaltungswechsel nicht '
'überschrieben wird.'],
'limitations': ['Einmalige '
'Anmeldegeheimnisse können '
'nach dem Schließen des '
'Erstellungsdialogs nicht '
'angezeigt oder '
'wiederhergestellt werden.',
'Deaktivierung und eine '
'reduzierte '
'Berechtigungsumfangsobergrenze '
'betreffen Clients sofort, '
'schreiben ihre externe '
'Konfiguration jedoch '
'nicht neu.',
'Der Ruhestand widerruft '
'alle aktiven '
'Anmeldeinformationen und '
'erfordert ein neues '
'Servicekonto für die '
'spätere '
'Wiederverwendung.'],
'outcome': 'Der Automatisierungsprinzipal '
'bleibt nicht interaktiv und '
'kann sich nur durch einen '
'aktiven Berechtigungsnachweis '
'authentifizieren, dessen '
'Gewährung innerhalb der '
'aktuellen '
'Berechtigungsumfangsobergrenze '
'des Kontos liegt.',
'prerequisites': ['Der Mandant erlaubt '
'API-Anmeldeinformationen.',
'Sie haben eine '
'Service-Account-Schreibberechtigung '
'und können jeden '
'ausgewählten Bereich '
'delegieren.'],
'steps': ['Erstellen Sie ein Servicekonto '
'und definieren Sie die engste '
'Nutzumfangsobergrenze.',
'Öffnen Sie das Konto und '
'erstellen Sie einen '
'Berechtigungsnachweis mit einem '
'gleichen oder engeren '
'Berechtigungsumfang.',
'Notieren Sie das einmalige '
'Geheimnis in einem externen '
'Geheimmanager.',
'Anmeldeinformationen vor Ablauf '
'drehen und Anmeldeinformationen '
'widerrufen, die nicht mehr '
'verwendet werden.'],
'verification': 'Die Verwaltungstabelle '
'zeigt die erwartete '
'Anzahl der aktiven '
'Anmeldeinformationen, den '
'Zeitstempel für die '
'letzte Verwendung, die '
'Revision und die '
'Audit-Ereignisse, ohne '
'geheimes Material '
'preiszugeben.'}}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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",
]
@@ -5,6 +5,10 @@ from types import SimpleNamespace
import unittest
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_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceiptError,
@@ -16,6 +20,39 @@ from govoplan_core.core.provider_governance import (
class ConfigurationPackageContextTests(unittest.TestCase):
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
applied = ConfigurationPackageApplyResponse(
rollback={
"status": "database_restore_required",
"summary": "Snapshot is the generic rollback boundary.",
"recovery_action": "Retain the snapshot.",
}
)
exported = ConfigurationPackageExportResponse(
provenance={
"exported_at": "2026-08-22T12:00:00+00:00",
"source_core_version": "0.1.35",
"module_versions": {"forms": "0.1.20"},
"tenant_id": "tenant-1",
"exporter_id": "user-1",
"selection": {
"scopes": ["tenant"],
"module_ids": ["forms"],
"object_refs": [],
},
"redacted_secret_keys": ["credential_ref"],
}
)
self.assertEqual(
"database_restore_required",
applied.model_dump()["rollback"]["status"],
)
self.assertEqual(
["credential_ref"],
exported.model_dump()["provenance"]["redacted_secret_keys"],
)
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
receipt = SimpleNamespace(installation_id="deployment-1")
principal = SimpleNamespace(
+13 -3
View File
@@ -6,6 +6,18 @@ from govoplan_access.backend.manifest import manifest
class InterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual(
{"title", "summary", "body"},
set(german),
topic.id,
)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
@@ -112,9 +124,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
)
def test_access_admin_surfaces_remain_declared(self) -> None:
surface_ids = {
surface.id for surface in manifest.frontend.view_surfaces
}
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
self.assertTrue(
{
"access.admin.system-roles",
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
Group,
Role,
User,
)
from govoplan_access.backend.tenant_erasure_provider import (
AccessTenantErasureProvider,
)
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
AccessBase.metadata.create_all(engine)
now = datetime.now(UTC)
with Session(engine) as session:
account = Account(
email="shared@example.test",
normalized_email="shared@example.test",
is_active=True,
auth_provider="local",
)
session.add(account)
session.flush()
first = User(
tenant_id="tenant-1",
account_id=account.id,
email="shared@example.test",
is_active=True,
is_tenant_admin=True,
auth_provider="local",
)
second = User(
tenant_id="tenant-2",
account_id=account.id,
email="shared@example.test",
is_active=True,
is_tenant_admin=False,
auth_provider="local",
)
session.add_all(
[
first,
second,
Group(tenant_id="tenant-1", slug="group", name="Group"),
Group(tenant_id="tenant-2", slug="group", name="Group"),
Role(tenant_id="tenant-1", slug="role", name="Role"),
Role(tenant_id="tenant-2", slug="role", name="Role"),
]
)
session.flush()
session.add_all(
[
ApiKey(
tenant_id="tenant-1",
user_id=first.id,
name="key",
prefix="prefix",
key_hash="hash",
scopes=[],
),
AuthSession(
tenant_id="tenant-1",
user_id=first.id,
account_id=account.id,
token_hash="token-hash",
expires_at=now + timedelta(hours=1),
),
]
)
session.commit()
provider = AccessTenantErasureProvider()
preview = provider.preview_tenant_erasure(session, "tenant-1")
assert preview.allowed
assert [step.step_id for step in preview.steps] == [
"revoke-tenant-credentials",
"erase-tenant-access",
]
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
revoked = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"revoke-tenant-credentials",
"operation:access:credentials",
)
erased = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
session.commit()
assert revoked.state == "completed"
assert erased.state == "completed"
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
def test_access_erasure_replay_is_idempotent() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
AccessBase.metadata.create_all(engine)
provider = AccessTenantErasureProvider()
with Session(engine) as session:
first = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
second = provider.reconcile_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
assert first.metrics == {"deleted": 0}
assert second.metrics == {"deleted": 0}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.19",
"version": "0.1.24",
"private": true,
"type": "module",
"scripts": {
@@ -239,8 +239,10 @@ export default function SystemUsersPanel({
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.system-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
+9 -5
View File
@@ -249,6 +249,8 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
id: "revoke-session",
label: "i18n:govoplan-access.revoke_session.5e551007",
variant: "danger",
helpContextId: "access.sessions.action.revoke",
helpModuleId: "access",
applicable: !row.current,
disabled: busy || !canRevokeSessions,
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
@@ -283,8 +285,10 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.tenant-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
@@ -294,7 +298,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
}
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
</FormGrid>
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} helpContextId="access.admin.tenant-users.require-password-change" helpModuleId="access" onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
<ContentGrid columns={2} spacing="block" collapseAt="wide">
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
@@ -320,11 +324,11 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
</>}
</Dialog>
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
<FormField label="i18n:govoplan-access.current_password.5e551021">
<PasswordField value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.sessions.field.current-password" helpModuleId="access">
<PasswordField helpContextId="access.sessions.field.current-password" helpModuleId="access" value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
</FormField>
</Dialog>
@@ -132,6 +132,8 @@ export default function SessionSettingsPanel({
id: "revoke",
label: "i18n:govoplan-access.revoke_session.5e551007",
variant: "danger",
helpContextId: "access.sessions.action.revoke",
helpModuleId: "access",
applicable: !row.current,
disabled: busy,
disabledReason: busy
@@ -208,6 +210,8 @@ export default function SessionSettingsPanel({
destructiveActions={
<Button
variant="danger"
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
disabled={busy || sessions.filter((item) => !item.current).length === 0}
disabledReason={
busy
@@ -243,6 +247,8 @@ export default function SessionSettingsPanel({
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke"
helpModuleId="access"
onCancel={() => setRevoking(null)}
onConfirm={() => void revokeOne()}
/>
@@ -253,6 +259,8 @@ export default function SessionSettingsPanel({
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
onCancel={() => setRevokingOthers(false)}
onConfirm={() => void revokeOthers()}
/>