feat: contribute tenant erasure for access data
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.23"
|
||||
__version__ = "0.1.24"
|
||||
|
||||
@@ -9,6 +9,10 @@ from govoplan_access.backend.configuration_provider import (
|
||||
ACCESS_CONFIGURATION_CAPABILITY,
|
||||
)
|
||||
from govoplan_access.backend.dsar_provider import ACCESS_DSAR_CAPABILITY
|
||||
from govoplan_access.backend.tenant_erasure_provider import (
|
||||
ACCESS_TENANT_ERASURE_CAPABILITY,
|
||||
AccessTenantErasureProvider,
|
||||
)
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
from govoplan_core.core.access import (
|
||||
@@ -75,6 +79,10 @@ def _permission(
|
||||
)
|
||||
|
||||
|
||||
def _tenant_erasure_provider(_context: ModuleContext) -> AccessTenantErasureProvider:
|
||||
return AccessTenantErasureProvider()
|
||||
|
||||
|
||||
ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission(
|
||||
"access:tenant:read",
|
||||
@@ -104,6 +112,13 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
"Access",
|
||||
"system",
|
||||
),
|
||||
_permission(
|
||||
"access:tenant:erase",
|
||||
"Erase tenants",
|
||||
"Preview, approve, execute, and reconcile governed destructive tenant erasure.",
|
||||
"Access",
|
||||
"system",
|
||||
),
|
||||
_permission(
|
||||
"access:account:read",
|
||||
"View accounts",
|
||||
@@ -440,6 +455,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:tenant:create",
|
||||
"access:tenant:update",
|
||||
"access:tenant:suspend",
|
||||
"access:tenant:erase",
|
||||
"access:account:read",
|
||||
"access:account:create",
|
||||
"access:account:update",
|
||||
@@ -1755,6 +1771,62 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.tenant-erasure-boundary",
|
||||
title="Understand Access behavior during tenant erasure",
|
||||
summary=(
|
||||
"Access contributes a bounded, idempotent erasure plan for tenant-scoped "
|
||||
"credentials and authorization records without deleting shared identities."
|
||||
),
|
||||
body=(
|
||||
"The tenant-erasure preview counts sessions, API keys, memberships, service "
|
||||
"accounts, groups, roles, organization units, functions, assignments, and "
|
||||
"delegations owned by the target tenant. Execution first revokes tenant "
|
||||
"credentials and then removes the remaining tenant-scoped authorization "
|
||||
"projection. Global accounts, system roles, identities, and identity links are "
|
||||
"retained because the same login identity may serve another tenant. Each step "
|
||||
"uses the orchestration idempotency key and can be reconciled safely after a "
|
||||
"database rollback or interrupted response. No credential secret is included "
|
||||
"in previews, receipts, checkpoints, or audit evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "operator", "security_reviewer"),
|
||||
order=36,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "tenancy"),
|
||||
any_scopes=("system:tenants:erase",),
|
||||
),
|
||||
),
|
||||
related_modules=("audit", "tenancy"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Access-Grenze bei der Mandantenlöschung verstehen",
|
||||
"summary": (
|
||||
"Access trägt einen begrenzten, idempotenten Löschplan für "
|
||||
"mandantenbezogene Anmeldedaten und Berechtigungsdatensätze bei, "
|
||||
"ohne gemeinsam verwendete Identitäten zu löschen."
|
||||
),
|
||||
"body": (
|
||||
"Die Vorschau der Mandantenlöschung zählt Sitzungen, API-Schlüssel, "
|
||||
"Mitgliedschaften, Servicekonten, Gruppen, Rollen, Organisationseinheiten, "
|
||||
"Funktionen, Zuweisungen und Delegationen des Zielmandanten. Die Ausführung "
|
||||
"widerruft zuerst mandantenbezogene Anmeldedaten und entfernt danach die "
|
||||
"übrige Berechtigungsprojektion. Globale Konten, Systemrollen, Identitäten "
|
||||
"und Identitätsverknüpfungen bleiben erhalten, weil dieselbe Login-Identität "
|
||||
"einem weiteren Mandanten dienen kann. Jeder Schritt nutzt den "
|
||||
"Idempotenzschlüssel der Orchestrierung und kann nach einem Datenbank-Rollback "
|
||||
"oder einer unterbrochenen Antwort sicher abgeglichen werden. Vorschauen, "
|
||||
"Belege, Prüfpunkte und Auditnachweise enthalten keine geheimen Anmeldedaten."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["tenancy.admin.tenant-erasure"],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1960,7 +2032,7 @@ def _people_search(context: ModuleContext) -> object:
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.23",
|
||||
version="0.1.24",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||
@@ -2156,6 +2228,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
ACCESS_DSAR_CAPABILITY: _dsar_provider,
|
||||
ACCESS_TENANT_ERASURE_CAPABILITY: _tenant_erasure_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
@@ -2166,6 +2239,15 @@ manifest = ModuleManifest(
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
ACCESS_TENANT_ERASURE_CAPABILITY: CapabilityDocumentation(
|
||||
label="Access tenant-erasure provider",
|
||||
summary=(
|
||||
"Previews and idempotently removes tenant-scoped sessions, keys, "
|
||||
"memberships, roles, groups, functions, and assignments while "
|
||||
"retaining shared global accounts and identities."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=ACCESS_DOCUMENTATION,
|
||||
architecture=declared_module_architecture(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user