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
+17
View File
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
## [0.26.0] - 2026-07-11
### Fixed
- **NDO tenant-delete guard now covers tenant policy templates (F5b)** — the
in-use guard added in 0.22.0 only scanned SCHEMA templates' `tenantId`, so a
tenant referenced only by a tenant policy template
(`tenant_policy_templates[*].tenantPolicyTemplate.template.tenantId`) could
be deleted, leaving a dangling reference (fails-open). `DELETE
/mso/api/v1/tenants/{id}` now scans both: a tenant referenced by a policy
template returns `400 "Tenant '<id>' is referenced by tenant policy
template(s): <names> — delete those templates first"` (wording is a
format-mirrored approximation of the hardware-grounded schema-template
message — no real-NDO capture of this specific rejection text exists yet,
and 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). +4 tests.
## [0.25.0] - 2026-07-10
### Changed
+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 {}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aci-sim"
version = "0.25.0"
version = "0.26.0"
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
readme = "README.md"
license = "PolyForm-Noncommercial-1.0.0"
@@ -0,0 +1,174 @@
"""F5b — NDO tenant-delete in-use guard: tenant policy templates.
F5 (`ffaf996`, v0.22.0) added the tenant-delete in-use guard, but it only
scanned SCHEMA templates' `tenantId` for references to the tenant being
deleted (`state.schema_details[*].templates[*].tenantId`). It never
scanned tenant policy templates
(`state.tenant_policy_templates[*].tenantPolicyTemplate.template.
tenantId` — confirmed as the real field path against the sim's own
`_get_template_objects`/`_backfill_policy_uuids` traversal of the same
structure in aci_sim/ndo/app.py). So a tenant referenced ONLY by a tenant
policy template deleted cleanly — fails-open. Real NDO refuses to delete
an in-use tenant regardless of which template type holds the reference;
this fixes the gap so the same 400 in-use rejection applies.
Semantics under test:
- Tenant referenced ONLY by a tenant policy template -> 400 (the fix).
- Tenant referenced by nothing -> still 200 (guard doesn't over-reject).
- Tenant referenced by a schema template -> still 400 (F5 guard intact).
- Referencing policy template deleted, then tenant delete -> 200 (guard
releases once dereferenced).
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import NdoState
POLICY_TENANT_ID = "tenant-f5b-policy-1"
POLICY_TENANT_NAME = "ANS-POLICY-TN1"
POLICY_TEMPLATE_ID = "template-f5b-policy-1"
POLICY_TEMPLATE_NAME = "POLICY-TN1-template"
SCHEMA_TENANT_ID = "tenant-f5b-schema-1"
SCHEMA_TENANT_NAME = "ANS-SCHEMA-TN1"
SCHEMA_ID = "schema-f5b-1"
SCHEMA_NAME = "F5B-SCHEMA-TN1-LAB1"
SCHEMA_TEMPLATE_NAME = "F5B-SCHEMA-TN1-template"
SCRATCH_TENANT_ID = "tenant-f5b-scratch-1"
SCRATCH_TENANT_NAME = "ANS-SCRATCH-TN1"
def _build_state() -> NdoState:
"""Three tenants, exercising the three reference sources independently:
- POLICY_TENANT_ID: referenced ONLY by a tenant policy template.
- SCHEMA_TENANT_ID: referenced ONLY by a schema template (F5 baseline).
- SCRATCH_TENANT_ID: referenced by nothing.
"""
policy_doc = {
"templateId": POLICY_TEMPLATE_ID,
"displayName": POLICY_TEMPLATE_NAME,
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {
"tenantId": POLICY_TENANT_ID,
"dhcpRelayPolicies": [],
"dhcpOptionPolicies": [],
},
"sites": [],
},
}
schema_template = {
"name": SCHEMA_TEMPLATE_NAME,
"tenantId": SCHEMA_TENANT_ID,
"vrfs": [],
"bds": [],
"anps": [],
"contracts": [],
}
schema_detail = {
"id": SCHEMA_ID,
"displayName": SCHEMA_NAME,
"name": SCHEMA_NAME,
"templates": [schema_template],
"sites": [],
}
schema_summary = {
"id": SCHEMA_ID,
"displayName": SCHEMA_NAME,
"name": SCHEMA_NAME,
"templates": [{"name": SCHEMA_TEMPLATE_NAME}],
}
return NdoState(
sites=[],
tenants=[
{
"id": POLICY_TENANT_ID,
"name": POLICY_TENANT_NAME,
"displayName": POLICY_TENANT_NAME,
},
{
"id": SCHEMA_TENANT_ID,
"name": SCHEMA_TENANT_NAME,
"displayName": SCHEMA_TENANT_NAME,
},
{
"id": SCRATCH_TENANT_ID,
"name": SCRATCH_TENANT_NAME,
"displayName": SCRATCH_TENANT_NAME,
},
],
schemas=[schema_summary],
schema_details={SCHEMA_ID: schema_detail},
template_summaries=[],
tenant_policy_templates={POLICY_TEMPLATE_ID: policy_doc},
fabric_connectivity={},
policy_states={},
audit_records=[],
)
@pytest.fixture()
def state():
return _build_state()
@pytest.fixture()
def client(state):
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
def test_delete_tenant_referenced_only_by_policy_template_400(client):
"""The fix: a tenant referenced ONLY by a tenant policy template's
`tenantPolicyTemplate.template.tenantId` must be refused, matching the
F5 schema-template guard's semantics (previously fails-open)."""
resp = client.delete(f"/mso/api/v1/tenants/{POLICY_TENANT_ID}")
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "tenant policy template(s)" in detail
assert POLICY_TEMPLATE_NAME in detail
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert any(t["id"] == POLICY_TENANT_ID for t in tenants)
def test_delete_tenant_no_references_200(client):
"""Guard must not over-reject: a tenant referenced by neither a schema
template nor a tenant policy template deletes cleanly."""
resp = client.delete(f"/mso/api/v1/tenants/{SCRATCH_TENANT_ID}")
assert resp.status_code == 200
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert all(t["id"] != SCRATCH_TENANT_ID for t in tenants)
def test_delete_tenant_referenced_by_schema_still_400(client):
"""F5 guard intact: schema-template reference still refuses deletion."""
resp = client.delete(f"/mso/api/v1/tenants/{SCHEMA_TENANT_ID}")
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "schema(s)" in detail
assert SCHEMA_NAME in detail
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert any(t["id"] == SCHEMA_TENANT_ID for t in tenants)
def test_delete_tenant_after_policy_template_removed_200(client):
"""Guard releases once dereferenced: delete the referencing tenant
policy template first, then the tenant delete succeeds."""
assert (
client.delete(f"/mso/api/v1/templates/{POLICY_TEMPLATE_ID}").status_code
== 200
)
resp = client.delete(f"/mso/api/v1/tenants/{POLICY_TENANT_ID}")
assert resp.status_code == 200
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert all(t["id"] != POLICY_TENANT_ID for t in tenants)