panorama-super-cli 0.2.6__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/CHANGELOG.md +24 -0
  2. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/getting-started/concepts.md +10 -6
  4. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/duplicates-and-merging.md +5 -1
  5. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/references-and-audit.md +11 -5
  6. panorama_super_cli-0.3.0/docs/superpowers/specs/2026-06-04-where-used-all-rulebases-design.md +227 -0
  7. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/_version.py +1 -1
  8. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/apply_live.py +6 -6
  9. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/apply_xml.py +9 -10
  10. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/changeset.py +17 -10
  11. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/dedup.py +29 -4
  12. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/models.py +60 -0
  13. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/parse.py +37 -0
  14. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/refs.py +64 -5
  15. panorama_super_cli-0.3.0/psc/core/rulebases.py +55 -0
  16. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/setcmd.py +9 -8
  17. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/pyproject.toml +1 -1
  18. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/skills/panorama-super-cli/SKILL.md +6 -1
  19. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/conftest.py +16 -0
  20. panorama_super_cli-0.3.0/tests/fixtures/all-rulebases.xml +153 -0
  21. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_apply_xml.py +16 -0
  22. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_dedup.py +32 -0
  23. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_live_apply.py +42 -0
  24. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_naming.py +61 -0
  25. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_parse.py +47 -1
  26. panorama_super_cli-0.3.0/tests/test_refs.py +100 -0
  27. panorama_super_cli-0.3.0/tests/test_rulebases.py +35 -0
  28. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_setcmd.py +36 -1
  29. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/uv.lock +577 -577
  30. panorama_super_cli-0.2.6/tests/test_refs.py +0 -42
  31. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/CODEOWNERS +0 -0
  32. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  33. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  34. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/dependabot.yml +0 -0
  36. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/workflows/docs.yml +0 -0
  37. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/workflows/lint.yml +0 -0
  38. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/workflows/release.yml +0 -0
  39. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.github/workflows/test.yml +0 -0
  40. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.gitignore +0 -0
  41. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.pre-commit-config.yaml +0 -0
  42. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/.python-version +0 -0
  43. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/AGENTS.md +0 -0
  44. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/CLAUDE.md +0 -0
  45. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/LICENSE +0 -0
  46. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/README.md +0 -0
  47. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/contributing/branching.md +0 -0
  48. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/contributing/development.md +0 -0
  49. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/contributing/release-process.md +0 -0
  50. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/getting-started/first-run.md +0 -0
  51. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/getting-started/install.md +0 -0
  52. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/finding-objects.md +0 -0
  53. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/live-vs-offline.md +0 -0
  54. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/naming.md +0 -0
  55. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/output-formats.md +0 -0
  56. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/safety.md +0 -0
  57. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/guides/using-with-ai-agents.md +0 -0
  58. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/index.md +0 -0
  59. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/reference/cli.md +0 -0
  60. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/reference/config.md +0 -0
  61. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/docs/reference/exit-codes.md +0 -0
  62. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/justfile +0 -0
  63. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/mkdocs.yml +0 -0
  64. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/__init__.py +0 -0
  65. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/__main__.py +0 -0
  66. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/__init__.py +0 -0
  67. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/_plan.py +0 -0
  68. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/app.py +0 -0
  69. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/auth_cmds.py +0 -0
  70. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/dedup_cmds.py +0 -0
  71. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/find_cmds.py +0 -0
  72. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/name_cmds.py +0 -0
  73. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/profile_cmds.py +0 -0
  74. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/refs_cmds.py +0 -0
  75. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/runtime.py +0 -0
  76. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/cli/version_cmds.py +0 -0
  77. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/config/__init__.py +0 -0
  78. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/config/loader.py +0 -0
  79. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/config/models.py +0 -0
  80. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/__init__.py +0 -0
  81. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/naming.py +0 -0
  82. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/normalize.py +0 -0
  83. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/resolve.py +0 -0
  84. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/source.py +0 -0
  85. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/core/version_check.py +0 -0
  86. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/output/__init__.py +0 -0
  87. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/output/errors.py +0 -0
  88. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/psc/output/format.py +0 -0
  89. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/scripts/sync_agents_md.py +0 -0
  90. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/__init__.py +0 -0
  91. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/fixtures/nested-device-groups.xml +0 -0
  92. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/fixtures/panorama-config.xml +0 -0
  93. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_auth.py +0 -0
  94. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_changeset.py +0 -0
  95. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_cli.py +0 -0
  96. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_cli_auth.py +0 -0
  97. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_cli_profile.py +0 -0
  98. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_hardening.py +0 -0
  99. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_nested_dg.py +0 -0
  100. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_normalize.py +0 -0
  101. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_output.py +0 -0
  102. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_resolve.py +0 -0
  103. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_source.py +0 -0
  104. {panorama_super_cli-0.2.6 → panorama_super_cli-0.3.0}/tests/test_version_check.py +0 -0
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.3.0 — 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - **Where-used across every object-referencing rulebase** (#2). The reference
15
+ graph previously covered only `security` and `nat`; it now also scans PBF,
16
+ decryption, authentication, QoS, application-override, DoS, SD-WAN,
17
+ tunnel-inspect, and network-packet-broker. `refs used`/`unused`/`dangling`
18
+ and the merge/rename repointing all account for them, so an object referenced
19
+ only by, say, a QoS or PBF rule is no longer reported unused (and can no
20
+ longer be deleted out from under a live rule). A PBF forwarding next-hop that
21
+ names an address object is shown in where-used and **blocks** a merge/rename
22
+ that would strand it (it has no flat member list to rewrite — edit it by hand,
23
+ then re-run). The `referrer_kind` in the output (`qos-rule`, `pbf-rule`, …)
24
+ names the exact rulebase.
25
+
26
+ ### Fixed
27
+
28
+ - **Tag rename/merge no longer wipes a rule's other tags** (#2). `field_members`
29
+ read the wrong attribute name for a rule's `tag` field, so renaming or merging
30
+ a tag referenced by a security rule rewrote the rule's tag list to an empty (or
31
+ single-entry) value, destroying its co-tags. All rule kinds now resolve the
32
+ field uniformly.
33
+
10
34
  ## v0.2.6 — 2026-06-04
11
35
 
12
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.6
3
+ Version: 0.3.0
4
4
  Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
@@ -5,9 +5,11 @@ A small mental model goes a long way.
5
5
  ## Snapshot
6
6
 
7
7
  `psc` reads a config into an immutable **snapshot**: every address, address
8
- group, service, service group, tag, security rule, and NAT rule it understands,
9
- across `shared` and every device-group. Read commands query the snapshot; they
10
- never mutate it.
8
+ group, service, service group, tag, and rule it understands — security, NAT,
9
+ and the nine other rulebases that reference objects (PBF, decryption,
10
+ authentication, QoS, application-override, DoS, SD-WAN, tunnel-inspect,
11
+ network-packet-broker) — across `shared` and every device-group. Read commands
12
+ query the snapshot; they never mutate it.
11
13
 
12
14
  ## Locations and inheritance
13
15
 
@@ -38,9 +40,11 @@ it. A change-set with **blockers** is unsafe and is refused — even with
38
40
 
39
41
  ## Reference graph
40
42
 
41
- The **reference graph** answers "who points at this object?" across groups,
42
- security rules, and NAT (match *and* translation fields). It powers where-used,
43
- unused detection, and the safe repointing that merge and rename rely on.
43
+ The **reference graph** answers "who points at this object?" across groups and
44
+ every object-referencing rulebase — security, NAT (match *and* translation
45
+ fields), and PBF, decryption, authentication, QoS, application-override, DoS,
46
+ SD-WAN, tunnel-inspect, and network-packet-broker. It powers where-used, unused
47
+ detection, and the safe repointing that merge and rename rely on.
44
48
 
45
49
  ## Sources
46
50
 
@@ -58,7 +58,11 @@ Every reference the [reference graph](references-and-audit.md) knows about:
58
58
 
59
59
  - static address-group membership,
60
60
  - security rule `source` / `destination`,
61
- - NAT rule `source` / `destination` (translation fields are flagged for review).
61
+ - NAT rule `source` / `destination` (translation fields are flagged for review),
62
+ - every other rulebase's `source` / `destination` / `service` / `tag` (PBF,
63
+ decryption, authentication, QoS, application-override, DoS, SD-WAN,
64
+ tunnel-inspect, network-packet-broker). A PBF next-hop object has no flat
65
+ member list, so a merge that would strand it is **blocked** for manual review.
62
66
 
63
67
  References are rewritten **before** the object is deleted, and duplicate members
64
68
  are collapsed (a group that listed both names ends up with just the survivor).
@@ -13,7 +13,10 @@ psc -c panorama.xml refs used h-web1
13
13
  ```
14
14
 
15
15
  Lists every reference that *resolves* to that object — group memberships,
16
- security rule `source`/`destination`, NAT `source`/`destination`/translation.
16
+ security rule `source`/`destination`, NAT `source`/`destination`/translation,
17
+ and the `source`/`destination`/`service`/`tag` fields of every other rulebase
18
+ (PBF, decryption, authentication, QoS, application-override, DoS, SD-WAN,
19
+ tunnel-inspect, network-packet-broker), plus a PBF forwarding next-hop object.
17
20
 
18
21
  If a name is ambiguous (exists in multiple kinds/locations), pass `--kind` and
19
22
  `--location`:
@@ -59,7 +62,10 @@ if `refs dangling` finds anything).
59
62
  psc -c panorama.xml --strict refs dangling || echo "config has dangling refs"
60
63
  ```
61
64
 
62
- !!! note "v0.2"
63
- The reference graph currently covers address-groups, service-groups,
64
- security rules, and NAT. Other rulebases (PBF, decryption, authentication,
65
- QoS, …) are planned.
65
+ !!! note "Rulebase coverage"
66
+ The reference graph covers address-groups, service-groups, and **every**
67
+ object-referencing rulebase: security, NAT, PBF, decryption, authentication,
68
+ QoS, application-override, DoS, SD-WAN, tunnel-inspect, and
69
+ network-packet-broker. A PBF forwarding next-hop that names an address
70
+ object is shown in where-used and blocks a merge/rename that would strand it
71
+ (it has no flat member list to rewrite — edit it by hand, then re-run).
@@ -0,0 +1,227 @@
1
+ # where-used across ALL rulebases — design (issue #2)
2
+
3
+ ## Problem
4
+
5
+ `psc`'s reference graph only scans two rulebases: `security` and `nat`. PAN-OS
6
+ carries object references in **nine more** rulebases — PBF, decryption,
7
+ authentication, QoS, application-override, DoS, SD-WAN, tunnel-inspect, and
8
+ network-packet-broker. Until these are scanned:
9
+
10
+ - **`unused` lies.** An address referenced *only* by a QoS or PBF rule is
11
+ reported as unused — and could then be deleted. `reachable_targets()` seeds
12
+ only from `security-rule`/`nat-rule` references, so every other rulebase is
13
+ invisible to reachability.
14
+ - **`merge`/`rename`/`delete` are unsafe.** They repoint *known* references
15
+ before removing an object. A reference in an unscanned rulebase is silently
16
+ left dangling — the exact failure repoint-before-delete exists to prevent.
17
+ - **`where-used` is incomplete** as a delete/rename pre-flight.
18
+
19
+ Scope decisions (confirmed with the user):
20
+
21
+ 1. **All nine** object-referencing rulebases, not just the seven in the issue
22
+ (tunnel-inspect + network-packet-broker added — the registry makes them
23
+ nearly free, and omitting them leaves the same safety hole the issue exists
24
+ to close).
25
+ 2. **PBF nexthop** address-object references are modelled as a *review-gated*
26
+ reference: shown in `where-used`, but a hard **blocker** on merge/rename
27
+ (the appliers can't rewrite a nested single-value field), exactly like NAT
28
+ translation fields.
29
+ 3. **Additive** model: `SecurityRule`/`NatRule` are left untouched (zero
30
+ regression risk on the safety-critical paths). One new generic `PolicyRule`
31
+ + a tiny rulebase registry covers the nine new rulebases.
32
+ 4. **Reference surface** per rulebase: address objects in `source`/`destination`,
33
+ service objects in `service`, and rule `tag`s. Applications, zones, profiles,
34
+ URL categories, and schedules are out of scope (none are psc-managed object
35
+ kinds).
36
+
37
+ ## Key insight
38
+
39
+ `referrer_kind` is always `"{rule_type}-rule"`, and the XML container tag, the
40
+ `set` keyword, and the live xpath segment are all just `rule_type`:
41
+ `security-rule → security`, `pbf-rule → pbf`, even `nat-rule → nat`. So a single
42
+ helper — `rule_container(referrer_kind) -> str | None` — collapses the four
43
+ hardcoded switch sites (`setcmd`, `apply_xml`, `apply_live`,
44
+ `changeset.reference_edit_is_mappable`) into table-driven code. Adding the
45
+ twelfth rulebase later becomes a one-line registry entry.
46
+
47
+ ## Architecture
48
+
49
+ A new `psc/core/rulebases.py` is the single source of truth for *which*
50
+ rulebases carry references and *which* of their fields are flat (repointable)
51
+ vs nested (review-gated). Everything else consults it.
52
+
53
+ ```
54
+ parse.py ──► PolicyRule (new model) ──► Snapshot.policy_rules
55
+
56
+ refs.py._walk ──────────────────────────────►│ emits References (addr/svc/tag + pbf nexthop)
57
+ refs.py._rule_seeded_targets ────────────────►│ seeds reachability from ALL rulebases ← unused fix
58
+
59
+ dedup.field_members ─────────────────────────►│ resolves current member list for any *-rule
60
+ changeset.reference_edit_is_mappable ─────────►│ uses rule_container() + FLAT_RULE_FIELDS
61
+ setcmd / apply_xml / apply_live ─────────────►│ build path from rule_container()
62
+ ```
63
+
64
+ ### New module: `psc/core/rulebases.py`
65
+
66
+ ```python
67
+ # rule_type value == XML container tag == `set` keyword == xpath segment.
68
+ POLICY_RULE_TYPES = (
69
+ "pbf", "decryption", "authentication", "qos", "application-override",
70
+ "dos", "sdwan", "tunnel-inspect", "network-packet-broker",
71
+ )
72
+ # Every referrer_kind that maps to a `{rb}-rulebase/{container}/rules` entry.
73
+ _RULE_CONTAINERS = {"security", "nat", *POLICY_RULE_TYPES}
74
+ # Flat <member> fields an applier can rewrite in place.
75
+ FLAT_RULE_FIELDS = frozenset({"source", "destination", "service", "tag"})
76
+
77
+ def rule_container(referrer_kind: str) -> str | None:
78
+ """'pbf-rule' -> 'pbf'; None if not a rulebase referrer (group/object)."""
79
+ ```
80
+
81
+ `FLAT_RULE_FIELDS` deliberately excludes `application`/`source-user` (never
82
+ emitted as object reference-edits) and PBF `nexthop` (nested → review-gated).
83
+ NAT keeps its existing `field in ("source","destination")` narrowing in the
84
+ appliers, since its translation fields are nested.
85
+
86
+ ### New model: `psc/core/models.py`
87
+
88
+ ```python
89
+ class RuleType(str, Enum):
90
+ PBF = "pbf"; DECRYPTION = "decryption"; AUTHENTICATION = "authentication"
91
+ QOS = "qos"; APPLICATION_OVERRIDE = "application-override"; DOS = "dos"
92
+ SDWAN = "sdwan"; TUNNEL_INSPECT = "tunnel-inspect"
93
+ NETWORK_PACKET_BROKER = "network-packet-broker"
94
+
95
+ class PolicyRule(BaseModel):
96
+ """Reference surface of a 'security-shaped' rulebase (no NAT translation).
97
+
98
+ One model for nine rulebases: they share source/destination (address),
99
+ an optional service list, rule tags, and — for PBF only — a forwarding
100
+ nexthop address object. Mirrors SecurityRule's 'only the reference surface'
101
+ philosophy; application/source-user are omitted because they name no
102
+ psc-managed object.
103
+ """
104
+ name: str
105
+ location: Location = SHARED
106
+ rulebase: Rulebase = Rulebase.PRE
107
+ rule_type: RuleType
108
+ source: list[str] = Field(default_factory=lambda: ["any"])
109
+ destination: list[str] = Field(default_factory=lambda: ["any"])
110
+ service: list[str] = Field(default_factory=list) # empty for app-override
111
+ nexthop: str | None = None # PBF forwarding object
112
+ disabled: bool = False
113
+ tags: list[str] = Field(default_factory=list)
114
+
115
+ @property
116
+ def referrer_kind(self) -> str: return f"{self.rule_type.value}-rule"
117
+ @property
118
+ def key(self) -> tuple[str, str, str, str]:
119
+ return (self.location.name, self.rule_type.value, self.rulebase.value, self.name)
120
+ ```
121
+
122
+ `Snapshot` gains `policy_rules: list[PolicyRule] = Field(default_factory=list)`.
123
+
124
+ ### Parsing: `psc/core/parse.py`
125
+
126
+ `_collect` already loops `(pre-rulebase, post-rulebase)`. Add one call per
127
+ rulebase tag:
128
+
129
+ ```python
130
+ for rt in RuleType: # drives off the registry
131
+ snap.policy_rules.extend(_parse_policy_rules(rb_el, loc, rt))
132
+ ```
133
+
134
+ `_parse_policy_rules` finds `./{rt.value}/rules/entry` and reads `source`,
135
+ `destination`, `service`, `tag` with the existing `_members` helper (absent →
136
+ empty, so app-override naturally has no service). For PBF it additionally reads
137
+ `action/forward/nexthop/fqdn` (the object-capable nexthop variant; a literal
138
+ `ip-address` nexthop is *not* an object and is ignored).
139
+
140
+ ### Reference walk: `psc/core/refs.py`
141
+
142
+ - `_walk` gains one loop over `snap.policy_rules` emitting `source`/`destination`
143
+ (address), `service` (service), `tag` (tag), and — when `rule_type is PBF and
144
+ nexthop` — a `nexthop` (address) reference.
145
+ - `_rule_seeded_targets`: replace the `referrer_kind in ("security-rule",
146
+ "nat-rule")` test with `rule_container(referrer_kind) is not None`. **This is
147
+ the `unused` safety fix** — reachability now seeds from every rulebase.
148
+ - `dangling()`: exclude `field == "nexthop"` unresolved refs. A PBF nexthop is
149
+ often a literal FQDN/IP, not an object; flagging those as dangling would be
150
+ noise. (When it *does* resolve to an object, it appears in `where_used` and is
151
+ gated on merge/rename — the safety win is preserved.)
152
+
153
+ ### Repoint resolution: `psc/core/dedup.py::field_members`
154
+
155
+ Add an `elif rule_container(ref.referrer_kind) ...` branch that looks the rule
156
+ up in `snap.policy_rules` by `(rule_type, name, location, rulebase)` and returns
157
+ `getattr(rule, field)`. The existing security/nat branches stay.
158
+
159
+ ### Safety gate + appliers (the four switch sites)
160
+
161
+ All four now compute the container via `rule_container()`:
162
+
163
+ - **`changeset.reference_edit_is_mappable`**: `address-group`/`service-group` →
164
+ True; else `container = rule_container(kind)`; None or no `rulebase` → False;
165
+ `nat` → `field in (source,destination)`; otherwise `field in FLAT_RULE_FIELDS`.
166
+ PBF `nexthop` (not in the set) → False → `gate_unmappable_reference_edits`
167
+ promotes it to a blocker when a delete/rename is present. **No new gating code
168
+ is needed — the existing gate already does the right thing** once
169
+ `reference_edit_is_mappable` returns False for nexthop.
170
+ - **`apply_xml._referrer_field_element`**, **`apply_live._referrer_field_xpath`**,
171
+ **`setcmd._referrer_path`**: groups unchanged; rules build
172
+ `{rb}-rulebase/{container}/rules/entry/{field}` (XML / xpath) or
173
+ `{scope} {rb}-rulebase {container} rules {name} {field}` (set) from
174
+ `rule_container()`. NAT translation + PBF nexthop fall through to the existing
175
+ `None`/`# REVIEW` path.
176
+
177
+ The security and NAT behaviours are byte-for-byte identical (container values
178
+ `security`/`nat`), so the existing applier/setcmd/round-trip tests are the
179
+ regression guard.
180
+
181
+ ### CLI / output
182
+
183
+ No changes. `refs_cmds` passes `referrer_kind` through as a string, so the new
184
+ kinds (`pbf-rule`, `qos-rule`, …) surface in `where-used`/`dangling` tables
185
+ automatically with distinct, self-describing labels.
186
+
187
+ ## Testing (TDD — failing test first)
188
+
189
+ New fixture `tests/fixtures/all-rulebases.xml`: one rule per new rulebase across
190
+ `shared` + a device-group, pre and post, referencing shared and DG-local
191
+ addresses/services/tags, plus a PBF rule whose nexthop names an address object.
192
+
193
+ - **test_parse**: all nine rule_types parsed; fields correct; app-override has
194
+ empty service; PBF nexthop captured.
195
+ - **test_refs**: `where_used` on an address used only by a QoS rule finds it;
196
+ `unused` does **not** report an address used only by a DoS/PBF/SD-WAN rule
197
+ (seeding fix); a bad service name in a decryption rule shows in `dangling`;
198
+ a PBF nexthop object shows in `where_used`; an unresolved nexthop does **not**
199
+ show in `dangling`.
200
+ - **test_dedup**: merging an address referenced by SD-WAN + authentication rules
201
+ repoints both before delete; merging an address that is a PBF nexthop →
202
+ **blocker** (review-gated), zero ops.
203
+ - **test_naming**: renaming an address referenced by a tunnel-inspect rule
204
+ repoints it.
205
+ - **test_apply_xml**: reference edit into a decryption/QoS rule rewrites the
206
+ member list and round-trips.
207
+ - **test_live_apply**: xpath for a network-packet-broker rule field is correct;
208
+ a PBF nexthop edit is skipped (`None`), not mis-addressed.
209
+ - **test_setcmd**: `set`/`delete` lines for an SD-WAN reference edit; PBF nexthop
210
+ edit renders `# REVIEW`.
211
+
212
+ ## Docs to update
213
+
214
+ - `docs/getting-started/concepts.md` (reference-graph coverage sentence).
215
+ - `docs/guides/references-and-audit.md` (lines 16, 63-64 — drop the "other
216
+ rulebases not yet covered" note).
217
+ - `docs/guides/duplicates-and-merging.md` (the repointed-fields list).
218
+ - `skills/panorama-super-cli/SKILL.md` (refs coverage).
219
+ - `refs.py` / `models.py` module docstrings.
220
+
221
+ ## Out of scope / follow-ups
222
+
223
+ - Applications, zones, profiles, URL categories, schedules as reference kinds
224
+ (would need new object models).
225
+ - Live read-modify-write for in-place updates (pre-existing limitation).
226
+ - Literal-IP PBF nexthop (only the object-capable `fqdn` variant is modelled).
227
+ ```
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.6"
7
+ __version__ = "0.3.0"
@@ -23,7 +23,8 @@ from typing import Literal
23
23
 
24
24
  from pydantic import BaseModel
25
25
 
26
- from psc.core.changeset import ChangeSet, ObjectUpsert, ReferenceEdit
26
+ from psc.core.changeset import ChangeSet, ObjectUpsert, ReferenceEdit, reference_edit_is_mappable
27
+ from psc.core.rulebases import rule_container
27
28
  from psc.output.errors import ErrorType, PscError
28
29
 
29
30
  # The device entry under which Panorama keeps its device-groups. Fixed on
@@ -84,11 +85,10 @@ def _referrer_field_xpath(edit: ReferenceEdit) -> tuple[str, str] | None:
84
85
  return f"{base}/address-group/entry[@name='{name}']/static", "static"
85
86
  if edit.referrer_kind == "service-group":
86
87
  return f"{base}/service-group/entry[@name='{name}']/members", "members"
87
- if edit.referrer_kind == "security-rule" and rb:
88
- path = f"{base}/{rb}-rulebase/security/rules/entry[@name='{name}']/{edit.field}"
89
- return path, edit.field
90
- if edit.referrer_kind == "nat-rule" and rb and edit.field in ("source", "destination"):
91
- path = f"{base}/{rb}-rulebase/nat/rules/entry[@name='{name}']/{edit.field}"
88
+ if reference_edit_is_mappable(edit):
89
+ # Any rulebase flat member field: container derives from referrer_kind.
90
+ container = rule_container(edit.referrer_kind)
91
+ path = f"{base}/{rb}-rulebase/{container}/rules/entry[@name='{name}']/{edit.field}"
92
92
  return path, edit.field
93
93
  return None
94
94
 
@@ -17,7 +17,8 @@ import xml.etree.ElementTree as ET
17
17
 
18
18
  from defusedxml.ElementTree import fromstring as _safe_fromstring
19
19
 
20
- from psc.core.changeset import ChangeSet, ObjectUpsert, ReferenceEdit
20
+ from psc.core.changeset import ChangeSet, ObjectUpsert, ReferenceEdit, reference_edit_is_mappable
21
+ from psc.core.rulebases import rule_container
21
22
  from psc.output.errors import ErrorType, PscError
22
23
 
23
24
 
@@ -68,15 +69,13 @@ def _referrer_field_element(scope: ET.Element, edit: ReferenceEdit) -> ET.Elemen
68
69
  elif edit.referrer_kind == "service-group":
69
70
  entry = _find_named(scope.find("service-group"), "entry", name)
70
71
  leaf = "members"
71
- elif edit.referrer_kind == "security-rule" and edit.rulebase:
72
- entry = _find_named(scope.find(f"./{edit.rulebase}-rulebase/security/rules"), "entry", name)
73
- leaf = edit.field
74
- elif (
75
- edit.referrer_kind == "nat-rule"
76
- and edit.rulebase
77
- and edit.field in ("source", "destination")
78
- ):
79
- entry = _find_named(scope.find(f"./{edit.rulebase}-rulebase/nat/rules"), "entry", name)
72
+ elif reference_edit_is_mappable(edit):
73
+ # Any rulebase whose field is a flat member list: container == the
74
+ # tag derived from referrer_kind (security/nat/pbf/qos/…).
75
+ container = rule_container(edit.referrer_kind)
76
+ entry = _find_named(
77
+ scope.find(f"./{edit.rulebase}-rulebase/{container}/rules"), "entry", name
78
+ )
80
79
  leaf = edit.field
81
80
  else:
82
81
  return None
@@ -17,6 +17,8 @@ from enum import Enum
17
17
 
18
18
  from pydantic import BaseModel, Field
19
19
 
20
+ from psc.core.rulebases import FLAT_RULE_FIELDS, rule_container
21
+
20
22
 
21
23
  class ObjectKind(str, Enum):
22
24
  ADDRESS = "address"
@@ -132,20 +134,25 @@ def reference_edit_is_mappable(edit: ReferenceEdit) -> bool:
132
134
 
133
135
  The single source of truth behind `apply_xml._referrer_field_element` and
134
136
  `apply_live._referrer_field_xpath`: a member field they know how to address
135
- (a group's `static`/`members`, a security-rule field, a NAT src/dst list).
136
- A NAT *translation* field is nested with no flat list, and a rule edit with
137
- no `rulebase` can't be located both are unmappable, and an applier silently
138
- skips them. The planner uses this to refuse such a skip when the same plan
139
- tears the target down, instead of leaving a dangling reference (#28).
137
+ (a group's `static`/`members`, a security-rule field, a NAT src/dst list, or
138
+ any other rulebase's source/destination/service/tag see
139
+ `psc.core.rulebases`). A NAT *translation* field and a PBF `nexthop` are
140
+ nested with no flat list, and a rule edit with no `rulebase` can't be
141
+ located all unmappable, and an applier silently skips them. The planner
142
+ uses this to refuse such a skip when the same plan tears the target down,
143
+ instead of leaving a dangling reference (#28).
140
144
  """
141
145
  kind = edit.referrer_kind
142
146
  if kind in ("address-group", "service-group"):
143
147
  return True
144
- if kind == "security-rule":
145
- return edit.rulebase is not None
146
- if kind == "nat-rule":
147
- return edit.rulebase is not None and edit.field in ("source", "destination")
148
- return False
148
+ container = rule_container(kind)
149
+ if container is None or edit.rulebase is None:
150
+ return False
151
+ if container == "nat":
152
+ # NAT keeps only flat match fields; its translation fields are nested.
153
+ return edit.field in ("source", "destination")
154
+ # security + every policy rulebase: the shared flat member fields.
155
+ return edit.field in FLAT_RULE_FIELDS
149
156
 
150
157
 
151
158
  def gate_unmappable_reference_edits(cs: ChangeSet) -> None:
@@ -24,6 +24,7 @@ from psc.core.changeset import (
24
24
  from psc.core.models import Location, Snapshot
25
25
  from psc.core.normalize import normalize_address, service_key
26
26
  from psc.core.refs import Reference, ReferenceGraph
27
+ from psc.core.rulebases import rule_container
27
28
 
28
29
 
29
30
  class ObjectRef(BaseModel):
@@ -188,6 +189,23 @@ def plan_merge(
188
189
  return cs
189
190
 
190
191
 
192
+ def _field_attr(field: str) -> str:
193
+ """The model attribute holding a reference field's member list.
194
+
195
+ The reference `field` is the PAN-OS *element* name (`tag`, `source`,
196
+ `destination-translation`); the model attribute is its Python form. The one
197
+ irregular case is `tag` → `tags` — getting this wrong returns an empty list,
198
+ so a tag rename/merge would wipe the field instead of rewriting one member.
199
+ """
200
+ return "tags" if field == "tag" else field.replace("-", "_")
201
+
202
+
203
+ def _attr_as_members(obj: object, field: str) -> list[str]:
204
+ """Read a rule's reference field as a member list (a scalar wraps to one)."""
205
+ val = getattr(obj, _field_attr(field), [])
206
+ return list(val) if isinstance(val, list) else [val]
207
+
208
+
191
209
  def field_members(snapshot: Snapshot, ref: Reference) -> list[str]:
192
210
  """Current member list of the field a reference points at."""
193
211
  loc = ref.referrer_location
@@ -202,7 +220,7 @@ def field_members(snapshot: Snapshot, ref: Reference) -> list[str]:
202
220
  and r.location == loc
203
221
  and (ref.rulebase is None or r.rulebase == ref.rulebase)
204
222
  ):
205
- return list(getattr(r, ref.field.replace("-", "_"), []))
223
+ return _attr_as_members(r, ref.field)
206
224
  elif ref.referrer_kind == "nat-rule":
207
225
  for n in snapshot.nat_rules:
208
226
  if (
@@ -210,7 +228,14 @@ def field_members(snapshot: Snapshot, ref: Reference) -> list[str]:
210
228
  and n.location == loc
211
229
  and (ref.rulebase is None or n.rulebase == ref.rulebase)
212
230
  ):
213
- attr = ref.field.replace("-", "_")
214
- val = getattr(n, attr, [])
215
- return list(val) if isinstance(val, list) else [val]
231
+ return _attr_as_members(n, ref.field)
232
+ elif rule_container(ref.referrer_kind) is not None:
233
+ for p in snapshot.policy_rules:
234
+ if (
235
+ p.referrer_kind == ref.referrer_kind
236
+ and p.name == ref.referrer_name
237
+ and p.location == loc
238
+ and (ref.rulebase is None or p.rulebase == ref.rulebase)
239
+ ):
240
+ return _attr_as_members(p, ref.field)
216
241
  return [ref.target_name]
@@ -32,6 +32,24 @@ class Rulebase(str, Enum):
32
32
  POST = "post"
33
33
 
34
34
 
35
+ class RuleType(str, Enum):
36
+ """The "security-shaped" rulebases beyond `security`/`nat` that carry object
37
+ references. Each value is also the PAN-OS XML container tag / `set` keyword
38
+ (see `psc.core.rulebases`), so the reference graph and the appliers stay
39
+ table-driven rather than hard-coding one branch per rulebase.
40
+ """
41
+
42
+ PBF = "pbf"
43
+ DECRYPTION = "decryption"
44
+ AUTHENTICATION = "authentication"
45
+ QOS = "qos"
46
+ APPLICATION_OVERRIDE = "application-override"
47
+ DOS = "dos"
48
+ SDWAN = "sdwan"
49
+ TUNNEL_INSPECT = "tunnel-inspect"
50
+ NETWORK_PACKET_BROKER = "network-packet-broker"
51
+
52
+
35
53
  class Location(BaseModel):
36
54
  """Where an object lives: Panorama `shared`, or a named device-group.
37
55
 
@@ -184,6 +202,46 @@ class NatRule(BaseModel):
184
202
  return (self.location.name, self.rulebase.value, self.name)
185
203
 
186
204
 
205
+ class PolicyRule(BaseModel):
206
+ """The object-reference surface of one "security-shaped" rulebase rule.
207
+
208
+ A single model spans the nine rulebases in `RuleType` (PBF, decryption,
209
+ authentication, QoS, application-override, DoS, SD-WAN, tunnel-inspect,
210
+ network-packet-broker): they all reference addresses in `source`/
211
+ `destination`, an optional `service` list, and carry rule `tags`. PBF adds a
212
+ forwarding `nexthop` that can name an address object. Like `SecurityRule`,
213
+ this models only the reference surface — `application`/`source-user` are
214
+ omitted because they name no psc-managed object.
215
+
216
+ `SecurityRule`/`NatRule` predate this model and stay separate (NAT's nested
217
+ translation fields don't fit); `rule_type` is what distinguishes the rest.
218
+ """
219
+
220
+ name: str
221
+ location: Location = SHARED
222
+ rulebase: Rulebase = Rulebase.PRE
223
+ rule_type: RuleType
224
+ source: list[str] = Field(default_factory=lambda: ["any"])
225
+ destination: list[str] = Field(default_factory=lambda: ["any"])
226
+ service: list[str] = Field(default_factory=list)
227
+ """Empty when the rulebase has no service field (e.g. application-override)."""
228
+ nexthop: str | None = None
229
+ """PBF forwarding next-hop address object, if the rule names one."""
230
+ disabled: bool = False
231
+ tags: list[str] = Field(default_factory=list)
232
+
233
+ @property
234
+ def referrer_kind(self) -> str:
235
+ """`"qos-rule"`, `"pbf-rule"`, … — the edge label in the reference graph
236
+ and the key the appliers resolve back to a container via
237
+ `rulebases.rule_container`."""
238
+ return f"{self.rule_type.value}-rule"
239
+
240
+ @property
241
+ def key(self) -> tuple[str, str, str, str]:
242
+ return (self.location.name, self.rule_type.value, self.rulebase.value, self.name)
243
+
244
+
187
245
  class Snapshot(BaseModel):
188
246
  """An immutable point-in-time view of the parts of a Panorama config
189
247
  `psc` understands. Built by the XML parser or the live client; consumed
@@ -198,6 +256,8 @@ class Snapshot(BaseModel):
198
256
  tags: list[Tag] = Field(default_factory=list)
199
257
  security_rules: list[SecurityRule] = Field(default_factory=list)
200
258
  nat_rules: list[NatRule] = Field(default_factory=list)
259
+ policy_rules: list[PolicyRule] = Field(default_factory=list)
260
+ """Rules from the nine non-security/nat rulebases (see `RuleType`)."""
201
261
  device_groups: list[str] = Field(default_factory=list)
202
262
  device_group_parents: dict[str, str] = Field(default_factory=dict)
203
263
  """Child device-group name → its parent device-group name. A device-group
@@ -24,7 +24,9 @@ from psc.core.models import (
24
24
  AddressType,
25
25
  Location,
26
26
  NatRule,
27
+ PolicyRule,
27
28
  Rulebase,
29
+ RuleType,
28
30
  SecurityRule,
29
31
  Service,
30
32
  ServiceGroup,
@@ -224,6 +226,40 @@ def _parse_nat_rules(parent: ET.Element, loc: Location, rb: Rulebase) -> list[Na
224
226
  return out
225
227
 
226
228
 
229
+ def _parse_policy_rules(parent: ET.Element, loc: Location, rb: Rulebase) -> list[PolicyRule]:
230
+ """Parse every "security-shaped" rulebase under one `<*-rulebase>` element.
231
+
232
+ Drives off `RuleType` (whose values are the container tags), so a new
233
+ rulebase needs only a new enum member. Reads the shared reference surface
234
+ (source/destination/service/tag); `service` stays empty when the rulebase
235
+ omits it (application-override). PBF additionally captures an object-named
236
+ forwarding next-hop (`action/forward/nexthop/fqdn`); a literal `ip-address`
237
+ next-hop names no object and is skipped.
238
+ """
239
+ out: list[PolicyRule] = []
240
+ for rt in RuleType:
241
+ for entry in parent.findall(f"./{rt.value}/rules/entry"):
242
+ name = entry.get("name")
243
+ if not name:
244
+ continue
245
+ nexthop = _text(entry, "action/forward/nexthop/fqdn") if rt is RuleType.PBF else None
246
+ out.append(
247
+ PolicyRule(
248
+ name=name,
249
+ location=loc,
250
+ rulebase=rb,
251
+ rule_type=rt,
252
+ source=_member_list_or_any(entry, "source"),
253
+ destination=_member_list_or_any(entry, "destination"),
254
+ service=_members(entry, "service"),
255
+ nexthop=nexthop,
256
+ disabled=_text(entry, "disabled") == "yes",
257
+ tags=_members(entry, "tag"),
258
+ )
259
+ )
260
+ return out
261
+
262
+
227
263
  def _collect(snap: Snapshot, parent: ET.Element, loc: Location) -> None:
228
264
  snap.addresses.extend(_parse_addresses(parent, loc))
229
265
  snap.address_groups.extend(_parse_address_groups(parent, loc))
@@ -235,6 +271,7 @@ def _collect(snap: Snapshot, parent: ET.Element, loc: Location) -> None:
235
271
  if rb_el is not None:
236
272
  snap.security_rules.extend(_parse_security_rules(rb_el, loc, rb))
237
273
  snap.nat_rules.extend(_parse_nat_rules(rb_el, loc, rb))
274
+ snap.policy_rules.extend(_parse_policy_rules(rb_el, loc, rb))
238
275
 
239
276
 
240
277
  def _find_config_root(root: ET.Element) -> ET.Element: