From 4ca201085bc9c0ea2bf0fcdc56902eb2710b0d1a Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Sat, 11 Jul 2026 06:20:01 +1000 Subject: [PATCH] fix(ndo): tenant-delete guard also scans tenant policy templates (F5b) (v0.26.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 '' is referenced by tenant policy template(s): — 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 Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1 --- CHANGELOG.md | 17 ++ aci_sim/ndo/app.py | 57 +++++- pyproject.toml | 2 +- ...f5b_tenant_delete_policy_template_guard.py | 174 ++++++++++++++++++ 4 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 tests/test_f5b_tenant_delete_policy_template_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed1678..1bdbcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 '' is referenced by tenant policy + template(s): — 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 diff --git a/aci_sim/ndo/app.py b/aci_sim/ndo/app.py index aaad231..717d3dc 100644 --- a/aci_sim/ndo/app.py +++ b/aci_sim/ndo/app.py @@ -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 {} diff --git a/pyproject.toml b/pyproject.toml index 5089c65..a4af1d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_f5b_tenant_delete_policy_template_guard.py b/tests/test_f5b_tenant_delete_policy_template_guard.py new file mode 100644 index 0000000..e20f5c6 --- /dev/null +++ b/tests/test_f5b_tenant_delete_policy_template_guard.py @@ -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)