feat: contribute tenant erasure for access data
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.23",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.23"
|
version = "0.1.24"
|
||||||
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.43",
|
||||||
"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.23"
|
__version__ = "0.1.24"
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from govoplan_access.backend.configuration_provider import (
|
|||||||
ACCESS_CONFIGURATION_CAPABILITY,
|
ACCESS_CONFIGURATION_CAPABILITY,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.dsar_provider import ACCESS_DSAR_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.base import AccessBase
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||||
from govoplan_core.core.access import (
|
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, ...] = (
|
ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||||
_permission(
|
_permission(
|
||||||
"access:tenant:read",
|
"access:tenant:read",
|
||||||
@@ -104,6 +112,13 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
|||||||
"Access",
|
"Access",
|
||||||
"system",
|
"system",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"access:tenant:erase",
|
||||||
|
"Erase tenants",
|
||||||
|
"Preview, approve, execute, and reconcile governed destructive tenant erasure.",
|
||||||
|
"Access",
|
||||||
|
"system",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"access:account:read",
|
"access:account:read",
|
||||||
"View accounts",
|
"View accounts",
|
||||||
@@ -440,6 +455,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
|||||||
"access:tenant:create",
|
"access:tenant:create",
|
||||||
"access:tenant:update",
|
"access:tenant:update",
|
||||||
"access:tenant:suspend",
|
"access:tenant:suspend",
|
||||||
|
"access:tenant:erase",
|
||||||
"access:account:read",
|
"access:account:read",
|
||||||
"access:account:create",
|
"access:account:create",
|
||||||
"access:account:update",
|
"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(
|
manifest = ModuleManifest(
|
||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="0.1.23",
|
version="0.1.24",
|
||||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
@@ -2156,6 +2228,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
ACCESS_DSAR_CAPABILITY: _dsar_provider,
|
ACCESS_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
ACCESS_TENANT_ERASURE_CAPABILITY: _tenant_erasure_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
|
ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
@@ -2166,6 +2239,15 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
contract_version="0.1.0",
|
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,
|
documentation=ACCESS_DOCUMENTATION,
|
||||||
architecture=declared_module_architecture(
|
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",
|
||||||
|
]
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.23",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user