mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c2959ab2cb
commit
4ca201085b
@@ -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)
|
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).
|
(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
|
## [0.25.0] - 2026-07-10
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+49
-8
@@ -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
|
# GET /mso/api/v1/tenants ↔ GET /api/v1/tenants dual-shape pair above
|
||||||
# (`delegate_to: localhost` cisco.mso tasks skip the /mso prefix —
|
# (`delegate_to: localhost` cisco.mso tasks skip the /mso prefix —
|
||||||
# see the PR-13 templates-store comment below).
|
# 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("/mso/api/v1/tenants/{tenant_id}")
|
||||||
@app.delete("/api/v1/tenants/{tenant_id}")
|
@app.delete("/api/v1/tenants/{tenant_id}")
|
||||||
async def delete_tenant(tenant_id: str):
|
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(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Tenant '{tenant_id}' not found"
|
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"]
|
detail.get("displayName") or detail.get("name") or detail["id"]
|
||||||
for detail in state.schema_details.values()
|
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 isinstance(tmpl, dict) and tmpl.get("tenantId") == tenant_id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if referencing:
|
referencing_policy_templates = sorted(
|
||||||
raise HTTPException(
|
{
|
||||||
status_code=400,
|
doc.get("displayName") or key
|
||||||
detail=(
|
for key, doc in state.tenant_policy_templates.items()
|
||||||
f"Tenant '{tenant_id}' is referenced by schema(s): "
|
if isinstance(doc, dict)
|
||||||
f"{', '.join(referencing)} — delete those schemas first"
|
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)
|
state.tenants.remove(tenant)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "aci-sim"
|
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)"
|
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "PolyForm-Noncommercial-1.0.0"
|
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)
|
||||||
Reference in New Issue
Block a user