fix(ndo): tenant-delete guard also scans tenant policy templates (F5b) (v0.26.0)

The in-use guard added in 0.22.0 (F5) only scanned SCHEMA templates'
tenantId, so a tenant referenced only by a tenant policy template
(tenant_policy_templates[*].tenantPolicyTemplate.template.tenantId — the
same field path _get_template_objects/_backfill_policy_uuids already
traverse) could be deleted, leaving a dangling reference. Real NDO refuses
to delete an in-use tenant regardless of which template type holds the
reference.

DELETE /mso/api/v1/tenants/{id} (and the bare /api/v1 twin) now scans
both. A policy-template-only reference returns 400 "Tenant '<id>' is
referenced by tenant policy template(s): <names> — delete those templates
first"; the wording is a format-mirrored approximation of the
hardware-grounded schema-template message (no real-NDO capture of this
specific rejection text exists yet — the code comment says so). The
schema-template message is byte-for-byte unchanged. Deleting the
referencing template still releases the guard. Malformed policy-template
entries default-allow (fail-safe, consistent with the sim's philosophy).

Verification: full pytest 1147 passed (1143 baseline + 4 new TDD tests,
red-phase confirmed pre-fix; all 8 pre-existing F5 tests intact);
independent opus review APPROVE (over-rejection trace: different-tenant
policy template still deletes clean; release-test proven non-vacuous);
live golden gate on restarted sim — create tenant -> create referencing
policy template -> DELETE 400 golden -> delete template -> DELETE 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
This commit is contained in:
dtzp555-max
2026-07-11 06:20:01 +10:00
co-authored by Claude Opus 4.8
parent c2959ab2cb
commit 4ca201085b
4 changed files with 241 additions and 9 deletions
+49 -8
View File
@@ -180,6 +180,21 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
# GET /mso/api/v1/tenants ↔ GET /api/v1/tenants dual-shape pair above
# (`delegate_to: localhost` cisco.mso tasks skip the /mso prefix —
# see the PR-13 templates-store comment below).
#
# F5b: the F5 guard above only ever scanned SCHEMA templates — a
# tenant referenced ONLY by a tenant policy template
# (`tenant_policy_templates[*].tenantPolicyTemplate.template.
# tenantId` — same field path `_get_template_objects`/
# `_backfill_policy_uuids` already traverse above) deleted cleanly,
# fails-open vs. real NDO which refuses regardless of which template
# type holds the reference. Extend the scan to cover both. The
# policy-template branch's exact wording is an approximation mirrored
# from the F5 schema-template message's format/shape — no real-NDO
# hardware capture of THIS specific rejection text exists yet, unlike
# the schema-template wording above (see F5's commit evidence). A
# malformed policy-template entry (non-dict `tenantPolicyTemplate`/
# `template`) default-allows rather than crashing the guard, matching
# this sim's fail-safe philosophy elsewhere.
@app.delete("/mso/api/v1/tenants/{tenant_id}")
@app.delete("/api/v1/tenants/{tenant_id}")
async def delete_tenant(tenant_id: str):
@@ -188,7 +203,7 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
raise HTTPException(
status_code=404, detail=f"Tenant '{tenant_id}' not found"
)
referencing = sorted(
referencing_schemas = sorted(
{
detail.get("displayName") or detail.get("name") or detail["id"]
for detail in state.schema_details.values()
@@ -196,14 +211,40 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
if isinstance(tmpl, dict) and tmpl.get("tenantId") == tenant_id
}
)
if referencing:
raise HTTPException(
status_code=400,
detail=(
f"Tenant '{tenant_id}' is referenced by schema(s): "
f"{', '.join(referencing)} — delete those schemas first"
),
referencing_policy_templates = sorted(
{
doc.get("displayName") or key
for key, doc in state.tenant_policy_templates.items()
if isinstance(doc, dict)
and isinstance(doc.get("tenantPolicyTemplate"), dict)
and isinstance(
doc["tenantPolicyTemplate"].get("template"), dict
)
and doc["tenantPolicyTemplate"]["template"].get("tenantId")
== tenant_id
}
)
if referencing_schemas and referencing_policy_templates:
detail_msg = (
f"Tenant '{tenant_id}' is referenced by schema(s): "
f"{', '.join(referencing_schemas)} — delete those schemas "
f"first; and tenant policy template(s): "
f"{', '.join(referencing_policy_templates)} — delete those "
f"templates first"
)
elif referencing_schemas:
detail_msg = (
f"Tenant '{tenant_id}' is referenced by schema(s): "
f"{', '.join(referencing_schemas)} — delete those schemas first"
)
elif referencing_policy_templates:
detail_msg = (
f"Tenant '{tenant_id}' is referenced by tenant policy "
f"template(s): {', '.join(referencing_policy_templates)}"
f"delete those templates first"
)
if referencing_schemas or referencing_policy_templates:
raise HTTPException(status_code=400, detail=detail_msg)
state.tenants.remove(tenant)
return {}