Add governed organization template upgrades

This commit is contained in:
2026-08-04 01:46:36 +02:00
parent 97acfcba7d
commit fcfd67b0b5
17 changed files with 2357 additions and 6 deletions
@@ -22,6 +22,7 @@ from govoplan_core.core.organizations import (
organization_lifecycle_event_type,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
@@ -34,6 +35,7 @@ from govoplan_organizations.backend.db.models import (
OrganizationModelInstantiation,
OrganizationModelTemplate,
OrganizationModelTemplateVersion,
OrganizationModelUpgrade,
OrganizationRelation,
OrganizationRelationType,
OrganizationTenantSettings,
@@ -48,6 +50,12 @@ from .schemas import (
FunctionTypeUpdateRequest,
FunctionUpdateRequest,
OrganizationModelInstantiationItem,
OrganizationModelUpgradeApplyRequest,
OrganizationModelUpgradeApplyResponse,
OrganizationModelUpgradeCancelRequest,
OrganizationModelUpgradeItem,
OrganizationModelUpgradeListResponse,
OrganizationModelUpgradePreviewRequest,
OrganizationFunctionItem,
OrganizationFunctionTypeItem,
OrganizationModelResponse,
@@ -80,6 +88,14 @@ from govoplan_organizations.backend.templates import (
canonical_template_definition,
instantiate_template_version,
)
from govoplan_organizations.backend.upgrades import (
OrganizationUpgradeError,
apply_model_upgrade,
cancel_model_upgrade,
create_model_upgrade_preview,
current_model_instantiation,
list_model_upgrades,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
@@ -475,6 +491,74 @@ def _instantiation_item(
)
def _upgrade_item(item: OrganizationModelUpgrade) -> OrganizationModelUpgradeItem:
return OrganizationModelUpgradeItem(
id=item.id,
tenant_id=item.tenant_id,
template_id=item.template_id,
source_instantiation_id=item.source_instantiation_id,
source_template_version_id=item.source_template_version_id,
target_template_version_id=item.target_template_version_id,
status=item.status,
revision=item.revision,
base_definition_sha256=item.base_definition_sha256,
local_definition_sha256=item.local_definition_sha256,
target_definition_sha256=item.target_definition_sha256,
preview=item.preview,
decisions=item.decisions,
requested_by_account_id=item.requested_by_account_id,
applied_by_account_id=item.applied_by_account_id,
cancelled_by_account_id=item.cancelled_by_account_id,
applied_at=item.applied_at,
cancelled_at=item.cancelled_at,
provenance=dict(item.provenance or {}),
created_at=item.created_at,
updated_at=item.updated_at,
)
def _emit_model_upgrade_event(
session: Session,
principal: ApiPrincipal,
item: OrganizationModelUpgrade,
action: str,
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=f"organizations.model_upgrade.{action}.v1",
module_id="organizations",
tenant=EventTenantRef(id=item.tenant_id),
actor=EventActorRef(type="account", id=principal.account_id),
subject=EventObjectRef(type="organization_model_upgrade", id=item.id),
resource=EventObjectRef(
type="organization_model_template_version",
id=item.target_template_version_id,
),
payload={
"schema_version": "1",
"tenant_id": item.tenant_id,
"upgrade_id": item.id,
"status": item.status,
"revision": item.revision,
"source_template_version_id": item.source_template_version_id,
"target_template_version_id": item.target_template_version_id,
"requires_decisions": int(
dict(item.preview or {}).get("requires_decisions", 0)
),
"blocking_invalid_references": int(
dict(item.preview or {}).get(
"blocking_invalid_references", 0
)
),
"decision_count": len(dict(item.decisions or {})),
"silent_mutation": False,
},
classification="internal",
),
)
def _template_version(
session: Session,
template_id: str,
@@ -742,6 +826,172 @@ def instantiate_organization_model_template(
return _instantiation_item(instantiation)
@router.get(
"/model-upgrades",
response_model=OrganizationModelUpgradeListResponse,
)
def list_organization_model_upgrades(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
) -> OrganizationModelUpgradeListResponse:
current = current_model_instantiation(
session,
tenant_id=principal.tenant_id,
)
return OrganizationModelUpgradeListResponse(
current_instantiation=(
_instantiation_item(current) if current is not None else None
),
upgrades=[
_upgrade_item(item)
for item in list_model_upgrades(
session,
tenant_id=principal.tenant_id,
)
],
)
@router.post(
"/model-upgrades/preview",
response_model=OrganizationModelUpgradeItem,
status_code=status.HTTP_201_CREATED,
)
def preview_organization_model_upgrade(
payload: OrganizationModelUpgradePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
),
) -> OrganizationModelUpgradeItem:
target = session.get(
OrganizationModelTemplateVersion,
payload.target_template_version_id,
)
if target is None:
raise _not_found("Organization model template version")
try:
item = create_model_upgrade_preview(
session,
tenant_id=principal.tenant_id,
target_version=target,
actor_account_id=principal.account_id,
idempotency_key=payload.idempotency_key,
)
_emit_model_upgrade_event(session, principal, item, "previewed")
session.commit()
session.refresh(item)
except OrganizationUpgradeError as exc:
session.rollback()
raise _conflict(str(exc)) from exc
return _upgrade_item(item)
@router.post(
"/model-upgrades/{upgrade_id}/cancel",
response_model=OrganizationModelUpgradeItem,
)
def cancel_organization_model_upgrade(
upgrade_id: str,
payload: OrganizationModelUpgradeCancelRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
),
) -> OrganizationModelUpgradeItem:
try:
item = cancel_model_upgrade(
session,
tenant_id=principal.tenant_id,
upgrade_id=upgrade_id,
expected_revision=payload.expected_revision,
actor_account_id=principal.account_id,
)
_emit_model_upgrade_event(session, principal, item, "cancelled")
session.commit()
session.refresh(item)
except OrganizationUpgradeError as exc:
session.rollback()
raise _conflict(str(exc)) from exc
return _upgrade_item(item)
@router.post(
"/model-upgrades/{upgrade_id}/apply",
response_model=OrganizationModelUpgradeApplyResponse,
)
def apply_organization_model_upgrade(
upgrade_id: str,
payload: OrganizationModelUpgradeApplyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
),
) -> OrganizationModelUpgradeApplyResponse:
approval, control_target, _value = _ensure_organization_change_allowed(
session,
principal,
tenant_id=principal.tenant_id,
resource_type="model_upgrade",
operation="applied",
payload=payload,
resource_id=upgrade_id,
)
before = _upgrade_item(
_get_tenant_row(
session,
OrganizationModelUpgrade,
upgrade_id,
principal.tenant_id,
"Organization model upgrade",
)
).model_dump(mode="json")
try:
item, instantiation = apply_model_upgrade(
session,
tenant_id=principal.tenant_id,
upgrade_id=upgrade_id,
expected_revision=payload.expected_revision,
decisions={
key: value.model_dump(exclude_none=True)
for key, value in payload.decisions.items()
},
actor_account_id=principal.account_id,
)
invalidate_auth_principals(
session,
tenant_id=principal.tenant_id,
source_module="organizations",
resource_type="organization_model_upgrade",
resource_id=item.id,
)
_emit_model_upgrade_event(session, principal, item, "applied")
response = OrganizationModelUpgradeApplyResponse(
upgrade=_upgrade_item(item),
instantiation=_instantiation_item(instantiation),
)
if approval is None:
session.commit()
else:
_record_organization_change_applied(
session,
principal,
approval=approval,
target=control_target,
before=before,
after=response.model_dump(mode="json"),
)
session.refresh(item)
session.refresh(instantiation)
except OrganizationUpgradeError as exc:
session.rollback()
raise _conflict(str(exc)) from exc
return OrganizationModelUpgradeApplyResponse(
upgrade=_upgrade_item(item),
instantiation=_instantiation_item(instantiation),
)
@router.get("/model", response_model=OrganizationModelResponse)
def get_organization_model(
session: Session = Depends(get_session),
@@ -180,6 +180,102 @@ class OrganizationModelInstantiationItem(BaseModel):
updated_at: datetime
UpgradeClassification = Literal[
"unchanged",
"compatible_addition",
"compatible_change",
"destructive_remapping",
"local_divergence",
"invalid_reference",
]
UpgradeDecisionAction = Literal["keep_local", "use_target", "map_to"]
class OrganizationModelUpgradePreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_template_version_id: str = Field(min_length=1, max_length=36)
idempotency_key: str = Field(min_length=1, max_length=255)
class OrganizationModelUpgradeDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
action: UpgradeDecisionAction
target_key: str | None = Field(default=None, max_length=500)
class OrganizationModelUpgradeApplyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(
default_factory=dict,
max_length=5000,
)
change_request_id: str | None = Field(default=None, max_length=255)
class OrganizationModelUpgradeCancelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class OrganizationModelUpgradeDiffEntry(BaseModel):
id: str
collection: str
key: str
classification: UpgradeClassification
requires_decision: bool
base: dict[str, Any] | None = None
local: dict[str, Any] | None = None
target: dict[str, Any] | None = None
allowed_actions: list[UpgradeDecisionAction] = Field(default_factory=list)
class OrganizationModelUpgradePreview(BaseModel):
entries: list[OrganizationModelUpgradeDiffEntry] = Field(default_factory=list)
counts: dict[str, int] = Field(default_factory=dict)
requires_decisions: int = 0
blocking_invalid_references: int = 0
silent_mutation: bool = False
class OrganizationModelUpgradeItem(BaseModel):
id: str
tenant_id: str
template_id: str
source_instantiation_id: str
source_template_version_id: str
target_template_version_id: str
status: str
revision: int
base_definition_sha256: str
local_definition_sha256: str
target_definition_sha256: str
preview: OrganizationModelUpgradePreview
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(default_factory=dict)
requested_by_account_id: str | None = None
applied_by_account_id: str | None = None
cancelled_by_account_id: str | None = None
applied_at: datetime | None = None
cancelled_at: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class OrganizationModelUpgradeListResponse(BaseModel):
current_instantiation: OrganizationModelInstantiationItem | None = None
upgrades: list[OrganizationModelUpgradeItem] = Field(default_factory=list)
class OrganizationModelUpgradeApplyResponse(BaseModel):
upgrade: OrganizationModelUpgradeItem
instantiation: OrganizationModelInstantiationItem
class OrganizationSettingsItem(BaseModel):
id: str | None = None
tenant_id: str
@@ -156,6 +156,77 @@ class OrganizationModelInstantiation(Base, TimestampMixin):
)
class OrganizationModelUpgrade(Base, TimestampMixin):
__tablename__ = "organizations_model_upgrades"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_organizations_model_upgrade_idempotency",
),
Index(
"ix_organizations_model_upgrades_tenant_status",
"tenant_id",
"status",
"updated_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
template_id: Mapped[str] = mapped_column(
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source_instantiation_id: Mapped[str] = mapped_column(
ForeignKey("organizations_model_instantiations.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source_template_version_id: Mapped[str] = mapped_column(
ForeignKey(
"organizations_model_template_versions.id",
ondelete="RESTRICT",
),
nullable=False,
index=True,
)
target_template_version_id: Mapped[str] = mapped_column(
ForeignKey(
"organizations_model_template_versions.id",
ondelete="RESTRICT",
),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(30), default="previewed", nullable=False, index=True
)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
base_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
local_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
target_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
decisions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
requested_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
applied_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
cancelled_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
class OrganizationStructure(Base, TimestampMixin):
__tablename__ = "organizations_structures"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
@@ -257,6 +328,7 @@ __all__ = [
"OrganizationModelInstantiation",
"OrganizationModelTemplate",
"OrganizationModelTemplateVersion",
"OrganizationModelUpgrade",
"OrganizationRelation",
"OrganizationRelationType",
"OrganizationStructure",
+59 -1
View File
@@ -189,6 +189,14 @@ manifest = ModuleManifest(
label="Organizations administration",
order=85,
),
ViewSurface(
id="organizations.admin.template-upgrades",
module_id="organizations",
kind="section",
label="Organization template upgrades",
parent_id="organizations.admin.tenant",
order=86,
),
),
),
migration_spec=MigrationSpec(
@@ -203,6 +211,7 @@ manifest = ModuleManifest(
organization_models.OrganizationModelTemplate,
organization_models.OrganizationModelTemplateVersion,
organization_models.OrganizationModelInstantiation,
organization_models.OrganizationModelUpgrade,
organization_models.OrganizationStructure,
organization_models.OrganizationRelationType,
organization_models.OrganizationRelation,
@@ -217,6 +226,53 @@ manifest = ModuleManifest(
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
},
documentation=(
DocumentationTopic(
id="organizations.template-upgrades",
title="Upgrade a tenant organization model",
summary=(
"Compare an immutable system template version with the current "
"tenant-owned model before explicitly applying an upgrade."
),
body=(
"Open Organizations administration and create a preview for a newer "
"published version of the template used by the tenant. The persisted "
"three-way comparison separates compatible additions, compatible "
"changes, tenant-only divergence, destructive remapping, and invalid "
"references. Tenant-only changes are preserved. Conflicting or "
"destructive entries require an explicit keep, replace, or bounded "
"mapping decision. Applying the reviewed preview creates a new "
"tenant-owned instantiation and supersedes the previous provenance; "
"it never creates live inheritance. A stale preview is rejected if the "
"tenant model or either template version changed. Cancelling retains "
"the review record without modifying organization data."
),
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "operator"),
related_modules=("policy", "audit", "idm", "access"),
links=(
DocumentationLink(
label="Organizations administration",
href="/admin?section=tenant-organization-settings",
kind="runtime",
),
DocumentationLink(
label="Organization upgrade API",
href="/api/v1/organizations/model-upgrades",
kind="api",
),
),
metadata={
"kind": "guide",
"help_contexts": [
"organizations.admin.template-upgrades",
"organizations.template-upgrade.preview",
"organizations.template-upgrade.decision",
"organizations.template-upgrade.apply",
],
},
order=27,
),
DocumentationTopic(
id="organizations.model",
title="Organization model",
@@ -320,7 +376,9 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/ORGANIZATION_MODEL.md",
test_ref="tests/test_model_templates.py",
known_limits=("Cross-tenant template lifecycle and target upgrade evidence are not reference-ready.",),
known_limits=(
"Downstream modules must reconcile organization-reference changes from the emitted upgrade event; cross-module records are not rewritten directly.",
),
owned_concepts=("organization unit", "organization structure", "organization relation", "organization function"),
non_owned_concepts=("function incumbency", "identity", "application role", "mandate"),
recovery_docs=("docs/ORGANIZATION_MODEL.md",),
@@ -0,0 +1,10 @@
"""Development mirror for the organization template upgrade ledger."""
from govoplan_organizations.backend.migrations.versions.a61e4d9c72b8_organization_template_upgrades import ( # noqa: F401
branch_labels,
depends_on,
downgrade,
down_revision,
revision,
upgrade,
)
@@ -0,0 +1,102 @@
"""organization template upgrade ledger
Revision ID: a61e4d9c72b8
Revises: 7e8f9a0b1c2d
Create Date: 2026-08-04 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a61e4d9c72b8"
down_revision = "7e8f9a0b1c2d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"organizations_model_upgrades",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("source_instantiation_id", sa.String(length=36), nullable=False),
sa.Column("source_template_version_id", sa.String(length=36), nullable=False),
sa.Column("target_template_version_id", sa.String(length=36), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("base_definition_sha256", sa.String(length=64), nullable=False),
sa.Column("local_definition_sha256", sa.String(length=64), nullable=False),
sa.Column("target_definition_sha256", sa.String(length=64), nullable=False),
sa.Column("preview", sa.JSON(), nullable=False),
sa.Column("decisions", sa.JSON(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_digest", sa.String(length=64), nullable=False),
sa.Column("requested_by_account_id", sa.String(length=36), nullable=True),
sa.Column("applied_by_account_id", sa.String(length=36), nullable=True),
sa.Column("cancelled_by_account_id", sa.String(length=36), nullable=True),
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", 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(
["template_id"],
["organizations_model_templates.id"],
name=op.f("fk_organizations_model_upgrades_template_id_organizations_model_templates"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_instantiation_id"],
["organizations_model_instantiations.id"],
name=op.f("fk_organizations_model_upgrades_source_instantiation_id_organizations_model_instantiations"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_template_version_id"],
["organizations_model_template_versions.id"],
name=op.f("fk_organizations_model_upgrades_source_template_version_id_organizations_model_template_versions"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["target_template_version_id"],
["organizations_model_template_versions.id"],
name=op.f("fk_organizations_model_upgrades_target_template_version_id_organizations_model_template_versions"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_model_upgrades")),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_organizations_model_upgrade_idempotency",
),
)
for column in (
"tenant_id",
"template_id",
"source_instantiation_id",
"source_template_version_id",
"target_template_version_id",
"status",
"requested_by_account_id",
"applied_by_account_id",
"cancelled_by_account_id",
):
op.create_index(
op.f(f"ix_organizations_model_upgrades_{column}"),
"organizations_model_upgrades",
[column],
unique=False,
)
op.create_index(
"ix_organizations_model_upgrades_tenant_status",
"organizations_model_upgrades",
["tenant_id", "status", "updated_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("organizations_model_upgrades")
File diff suppressed because it is too large Load Diff