feat(assets): manual decommission — the state "Show decommissioned" filtered for

The "Show decommissioned" checkbox sent include_inactive=true and the backend
filter honoured it correctly, but no code path ever wrote status=DECOMMISSIONED:
the syncs only set INACTIVE/ACTIVE, the edit modal had no status field, and
Delete hard-removes the asset with its findings (CASCADE) instead of retiring
it. The filter had no state to filter for, so the checkbox looked dead.

The PUT endpoint did accept `status`, but wrote it with a bare setattr — the
transition reached the audit log as a generic ASSET_UPDATED instead of the
ASSET_DEACTIVATED / ASSET_REACTIVATED entry every sync-driven transition gets.

- Lifecycle Status select in the asset edit modal (edit only, editor+)
- PUT /assets/{id} routes status through apply_status() so the trail names the
  transition, with force=True: the operator-final guard is there to stop a sync
  from reviving a retired asset, not the operator who retired it
- tests/test_asset_decommission.py pins audit event, default filter and undo

No UI button for the lifecycle reconcile — it stays the nightly 04:15 job plus
POST /assets/reconcile-lifecycle (editor+). The tester text listing a
"Lifecycle abgleichen" button described a control that never existed.
This commit is contained in:
2026-08-27 14:36:15 +02:00
parent 643fee40a6
commit 0a61b33770
4 changed files with 146 additions and 3 deletions
+18
View File
@@ -832,7 +832,15 @@ async def update_asset(
# Update nur gesetzte Felder
import json as _json
# Lifecycle transitions do not go through setattr: apply_status() writes the
# dedicated ASSET_DEACTIVATED / ASSET_REACTIVATED audit entry, so a manual
# decommission reads the same way in the trail as a sync-driven one.
status_change: Optional[AssetStatus] = None
for field, value in update_data.model_dump(exclude_unset=True).items():
if field == "status":
if value is not None:
status_change = value if isinstance(value, AssetStatus) else AssetStatus(value)
continue
if field == "compliance_frameworks":
# Stored as TEXT JSON list
value = _json.dumps(value or []) if value is not None else None
@@ -842,6 +850,16 @@ async def update_asset(
raise HTTPException(400, "criticality must be one of: low, normal, high, critical")
setattr(asset, field, value)
if status_change is not None:
from app.services.asset_lifecycle import apply_status
# force=True: the operator-final guard exists to stop a sync from
# reviving a retired asset, not the operator who retired it.
apply_status(
db, asset, status_change,
f"manual status change by {current_user.username}",
force=True,
)
db.commit()
db.refresh(asset)
+11 -3
View File
@@ -164,7 +164,13 @@ def _liveness_stamp(asset: Asset) -> Optional[datetime]:
return max(stamps) if stamps else None
def apply_status(db: Session, asset: Asset, new: AssetStatus, reason: str) -> bool:
def apply_status(
db: Session,
asset: Asset,
new: AssetStatus,
reason: str,
force: bool = False,
) -> bool:
"""Set `asset.status` and audit-log the transition. No-op if unchanged.
Every sync that writes a status must go through here. Writing
@@ -174,10 +180,12 @@ def apply_status(db: Session, asset: Asset, new: AssetStatus, reason: str) -> bo
trail must not do (field report 2026-08-18).
DECOMMISSIONED is operator-final: a sync never revives it, mirroring both
reconcile functions in this module.
reconcile functions in this module. `force=True` lifts that guard for the
one caller it was never meant to stop — the human who retired the asset
and now wants it back (PUT /assets/{id}). Syncs must never pass it.
"""
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
if old == AssetStatus.DECOMMISSIONED.value:
if old == AssetStatus.DECOMMISSIONED.value and not force:
return False
if old == new.value:
return False