#!/usr/bin/env python3
"""
keystone_policy_compare.py -- offline policy-artifact helper for
scripts/keystone-policy-drift.sh (hardening register H4; the D-051/D-064/
D-065/D-073 keystone policy family). Read-only; no network, no juju.
Modes:
parse <file.yaml>
Validate the overlay parses as a FLAT string->string rule mapping.
Prints "rules=<N>". Exit 0 ok | 2 unreadable/bad shape.
zipcheck <file.zip> <file.yaml>
Byte-compare the zip member domain-manager-policy.yaml against the
on-disk yaml (same check as provider-bundle-check item 7 -- mtimes
inside the zip cannot false-fail a content compare).
Exit 0 match | 1 differs / member missing | 2 unreadable.
compare <repo.yaml> <live.yaml>
Compare PARSED rule sets. The keystone charm strips comments in the
policyd round-trip, so bytes/sizes NEVER match between the repo file
and /etc/keystone/policy.d/ -- only parsed rules may be compared.
Prints per-rule DIFF / REPO-ONLY / LIVE-ONLY lines when not identical.
Exit 0 identical
3 staged-D-073: live == repo minus EXACTLY identity:list_trusts
held at the adopted D-073 value (repo ahead of live by the
staged delta; live apply pending in the gated attach window)
1 any other difference
2 unreadable / bad shape.
ASCII + LF.
"""
import sys
import zipfile
import yaml
MEMBER = "domain-manager-policy.yaml"
D073_RULE = "identity:list_trusts"
D073_VALUE = "rule:admin_required or (role:reader and system_scope:all)"
def load_rules(path):
try:
with open(path, "r") as f:
d = yaml.safe_load(f)
except Exception as e: # noqa: BLE001 -- report, do not crash
print("UNREADABLE %s: %s" % (path, e))
return None
if not isinstance(d, dict) or not all(
isinstance(k, str) and isinstance(v, str) for k, v in d.items()
):
print("BAD-SHAPE %s: not a flat string->string rule mapping" % path)
return None
return d
def cmd_parse(path):
d = load_rules(path)
if d is None:
return 2
print("rules=%d" % len(d))
return 0
def cmd_zipcheck(zpath, ypath):
try:
with zipfile.ZipFile(zpath) as z:
names = z.namelist()
if MEMBER not in names:
print("zip lacks top-level %s (members: %s)" % (MEMBER, names))
return 1
inzip = z.read(MEMBER)
with open(ypath, "rb") as f:
ondisk = f.read()
except Exception as e: # noqa: BLE001
print("UNREADABLE: %s" % e)
return 2
if inzip == ondisk:
print("zip member %s matches %s byte-for-byte" % (MEMBER, ypath))
return 0
print("zip content DIFFERS from %s (rebuild + recommit the zip)" % ypath)
return 1
def cmd_compare(rpath, lpath):
repo = load_rules(rpath)
live = load_rules(lpath)
if repo is None or live is None:
return 2
if repo == live:
print("parsed rule sets IDENTICAL (%d rules)" % len(repo))
return 0
repo_only = sorted(set(repo) - set(live))
live_only = sorted(set(live) - set(repo))
changed = sorted(k for k in set(repo) & set(live) if repo[k] != live[k])
if (
repo_only == [D073_RULE]
and not live_only
and not changed
and repo[D073_RULE] == D073_VALUE
):
print(
"STAGED-D-073: live lacks exactly %s at the adopted value "
"(%d live vs %d repo rules); live apply pending" % (D073_RULE, len(live), len(repo))
)
return 3
for k in changed:
print("DIFF rule %s:" % k)
print(" repo: %s" % repo[k])
print(" live: %s" % live[k])
for k in repo_only:
print("REPO-ONLY rule %s = %s" % (k, repo[k]))
for k in live_only:
print("LIVE-ONLY rule %s = %s" % (k, live[k]))
return 1
def main(argv):
if len(argv) == 2 and argv[0] == "parse":
return cmd_parse(argv[1])
if len(argv) == 3 and argv[0] == "zipcheck":
return cmd_zipcheck(argv[1], argv[2])
if len(argv) == 3 and argv[0] == "compare":
return cmd_compare(argv[1], argv[2])
sys.stderr.write(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))