diff --git a/.github/agents/04g-governance.agent.md b/.github/agents/04g-governance.agent.md index 2e8b8d7f..b5072421 100644 --- a/.github/agents/04g-governance.agent.md +++ b/.github/agents/04g-governance.agent.md @@ -165,7 +165,8 @@ Reuse current content; recover missing/changed evidence after compaction or resu 2. Run `apex-recall show --json` to verify project context exists (project name, complexity, decisions) 3. **Read the committed baseline subscription entry when present**. It is comparison evidence, not live policy authority or a prerequisite for live discovery. - A missing baseline routes to live discovery; never fabricate an entry. Determine the target + A missing baseline routes to live discovery; never fabricate an entry. An `alz-corp-default` + baseline has no entry for the target subscription; follow Phase 0.45 instead. Determine the target subscription ID from the architecture, load the entire `.github/data/governance-policy-baseline.json.gz → subscriptions[]` object into context for baseline selection/comparison. The subscription diff --git a/.github/data/governance-policy-baseline.json.gz b/.github/data/governance-policy-baseline.json.gz index 32d485b2..5bbaf308 100644 Binary files a/.github/data/governance-policy-baseline.json.gz and b/.github/data/governance-policy-baseline.json.gz differ diff --git a/.github/instructions/governance-discovery.instructions.md b/.github/instructions/governance-discovery.instructions.md index d0835463..07024a02 100644 --- a/.github/instructions/governance-discovery.instructions.md +++ b/.github/instructions/governance-discovery.instructions.md @@ -12,6 +12,9 @@ committed under `.github/data/governance-policy-baseline.json.gz`. **GATE**: This is a mandatory gate. If Azure connectivity fails or policies cannot be retrieved during live discovery, STOP and inform the user. Do NOT generate governance constraints from assumptions. +**Single exception**: the user may explicitly choose the shipped `alz-corp-default` baseline as *assumed* +governance (see `baseline-check.md`). It must be labelled assumed and not tenant-verified in every artifact, +and the Step 3.5 review stays mandatory. Do NOT accept arbitrary static files as governance input — only the approved workflow output file (`.github/data/governance-policy-baseline.json.gz`) is valid for cached baseline mode. diff --git a/.github/skills/apex-azure-governance-discovery/references/baseline-check.md b/.github/skills/apex-azure-governance-discovery/references/baseline-check.md index 17d769d1..7ed7966a 100644 --- a/.github/skills/apex-azure-governance-discovery/references/baseline-check.md +++ b/.github/skills/apex-azure-governance-discovery/references/baseline-check.md @@ -20,6 +20,8 @@ same subscription and for offline / sandboxed environments. ## Procedure 1. Check whether `.github/data/governance-policy-baseline.json.gz` exists. + If `--get baseline_kind` prints `alz-corp-default`, follow + [ALZ Corp default baseline](#alz-corp-default-baseline-assumed-governance) instead of steps 2-5. 2. If it exists, read the target subscription ID from the project's `02-architecture-assessment.md` or session state. 3. **All** eligibility conditions must be true: @@ -61,3 +63,37 @@ same subscription and for offline / sandboxed environments. 6. If the baseline file is missing, eligibility fails, or the user chooses live discovery, proceed to Phase 0.5 (Cache-First Check). +## ALZ Corp default baseline (assumed governance) + +The repository ships an offline-generated baseline (`baseline_kind: alz-corp-default`): the default policy a +subscription inherits under the Azure landing zone Corp management group (`root`, `landing_zones` and `corp` +archetypes, pinned in `alz_library_ref`). It holds one synthetic subscription entry, so no target subscription +ID ever matches. It is **assumed governance**, never discovered policy, and adopters replace it by running the +tenant baseline workflow. + +1. Read the file's `baseline_kind` and `alz_library_ref`: + + ```bash + python3 .github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py \ + .github/data/governance-policy-baseline.json.gz --get baseline_kind + ``` + +2. When the target subscription is not a key in `subscriptions`, use `askQuestions` with the options + **Run live discovery** (recommended when Azure access exists) and **Use ALZ Corp default as assumed + governance**. Never choose the default on the user's behalf. +3. If the user picks live discovery, proceed to Phase 0.5. +4. If the user picks the default, extract and render it: + + ```bash + python3 .github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py \ + .github/data/governance-policy-baseline.json.gz --alz-default > /tmp/{project}-baseline-sub.json + ``` + + Then run `render_cached_governance.py` as in step 5 above, adding `--subscription `. + The renderer refuses the default without it and writes the target ID, not the synthetic baseline ID, into the + artifact (the synthetic ID stays in `provenance.baseline_subscription_id`). The preview carries an **Assumed + governance** banner; keep it in `04-governance-constraints.md`. +5. Record the choice with `apex-recall decide --decision "Governance assumed from the ALZ Corp + default baseline, not tenant-verified" --rationale "" --json`. +6. The Step 3.5 governance-reconciliation review stays mandatory; do not bypass it for assumed governance. + Treat any tag contract as the greenfield fallback, because the ALZ Corp set has no tag policy. diff --git a/.github/skills/apex-azure-governance-discovery/scripts/discover.py b/.github/skills/apex-azure-governance-discovery/scripts/discover.py index c69fd2fc..bbe760a3 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/discover.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/discover.py @@ -234,12 +234,26 @@ def _fetch(url: str, expected_id: str) -> dict[str, Any] | None: return list(pool.map(_fetch, urls, expected_ids)) +# Built-in diagnostic-settings initiatives gate each member's effect on a resource-type list parameter. +_CONDITIONAL_EFFECT = re.compile( + r"\[if\(contains\(parameters\('([^']+)'\),\s*'([^']+)'\),\s*parameters\('([^']+)'\),\s*'([^']+)'\)\]", + re.IGNORECASE, +) + + def _resolve_parameters(value: Any, parameters: dict[str, Any]) -> Any: if isinstance(value, str): + normalized = {key.lower(): item for key, item in parameters.items()} match = re.fullmatch(r"\[parameters\('([^']+)'\)\]", value.strip(), re.IGNORECASE) if match: - normalized = {key.lower(): item for key, item in parameters.items()} return normalized.get(match.group(1).lower(), value) + conditional = _CONDITIONAL_EFFECT.fullmatch(value.strip()) + if conditional: + container = normalized.get(conditional.group(1).lower()) + chosen = normalized.get(conditional.group(3).lower()) + if isinstance(container, list) and chosen is not None: + listed = {str(item).lower() for item in container} + return chosen if conditional.group(2).lower() in listed else conditional.group(4) if isinstance(value, dict): return {key: _resolve_parameters(item, parameters) for key, item in value.items()} if isinstance(value, list): @@ -878,7 +892,7 @@ def discover( audit_count += 1 continue if eff not in RELEVANT_EFFECTS: - if eff != "Append": + if eff not in {"Append", "enforceSetting"}: unresolved.append(original_defn.get("id") or pid) continue diff --git a/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py new file mode 100644 index 00000000..f9ff60be --- /dev/null +++ b/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Generate the ALZ Corp default governance baseline without calling Azure. + +Resolves the `root`, `landing_zones` and `corp` archetypes of the pinned Azure Landing Zones +library to policy assignments, resolves their definitions (custom from the library, built-in from +the public azure-policy repo) and runs them through ``discover.discover`` with an offline REST +adapter, so classification matches a live run exactly. +""" + +from __future__ import annotations + +import argparse +import gzip +import io +import json +import re +import sys +import tarfile +import tempfile +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import discover as discover_mod + +LIBRARY_REF = "platform/alz/2026.10.0" +AZURE_POLICY_REF = "184a951ee59050e19d6e9b5db894383528cb86f3" +LIBRARY_URL = f"https://codeload.github.com/Azure/Azure-Landing-Zones-Library/tar.gz/refs/tags/{LIBRARY_REF}" +AZURE_POLICY_URL = f"https://codeload.github.com/Azure/azure-policy/tar.gz/{AZURE_POLICY_REF}" + +BASELINE_KIND = "alz-corp-default" +# Synthetic id: the baseline describes a policy set, not a real subscription. +SYNTHETIC_SUBSCRIPTION_ID = "00000000-0000-0000-0000-00000000a1c0" +DEFAULT_LOCATION = "swedencentral" +DEFAULT_OUT = Path(".github/data/governance-policy-baseline.json.gz") + +# Archetypes a subscription under the Corp management group inherits, outermost first. +ARCHETYPES: tuple[tuple[str, str], ...] = ( + ("root", "alz"), + ("landing_zones", "alz-landingzones"), + ("corp", "alz-corp"), +) +ROOT_MG = "alz" +MG_PREFIX = "/providers/Microsoft.Management/managementGroups" +PLACEHOLDER_MG = re.compile(r"/providers/Microsoft\.Management/managementGroups/[^/\"]+") + + +def _fetch(url: str) -> bytes: + if not url.startswith("https://"): + raise ValueError(f"refusing non-https source: {url}") + with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 — https constant above + return response.read() + + +def _extract(url: str, dest: Path, wanted: tuple[str, ...]) -> Path: + """Extract only members whose path contains one of ``wanted`` and return the archive root.""" + with tarfile.open(fileobj=io.BytesIO(_fetch(url)), mode="r:gz") as tar: + members = [m for m in tar.getmembers() if any(w in m.name for w in wanted)] + tar.extractall(dest, members=members, filter="data") # noqa: S202 — filter="data" blocks traversal + roots = [p for p in dest.iterdir() if p.is_dir()] + if len(roots) != 1: + raise RuntimeError(f"unexpected archive layout under {dest}") + return roots[0] + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _localize(value: Any) -> Any: + """Point every library management group (placeholder, contoso, ...) at the synthetic root.""" + text = json.dumps(value) + text = PLACEHOLDER_MG.sub(f"{MG_PREFIX}/{ROOT_MG}", text) + text = text.replace("${current_scope_resource_id}", f"{MG_PREFIX}/{ROOT_MG}") + text = text.replace("${default_location}", DEFAULT_LOCATION) + return json.loads(text) + + +def _name_index(directory: Path) -> dict[str, dict[str, Any]]: + """Index library definition files by their `name`, keeping the highest file name on duplicates.""" + index: dict[str, tuple[str, dict[str, Any]]] = {} + for path in sorted(directory.glob("*.json")): + doc = _read_json(path) + name = str(doc.get("name", "")).lower() + if name: + index[name] = (path.name, doc) + return {name: doc for name, (_, doc) in index.items()} + + +def _builtin_index(builtins_dir: Path, kind: str) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + for path in (builtins_dir / kind).rglob("*.json"): + try: + doc = _read_json(path) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + name = doc.get("name") if isinstance(doc, dict) else None + if isinstance(name, str) and isinstance(doc.get("properties"), dict): + index[name.lower()] = doc + return index + + +class OfflinePolicyApi: + """Serves the ARM reads that ``discover.discover`` issues from local library and built-in JSON.""" + + def __init__(self, assignments: list[dict[str, Any]], documents: dict[str, dict[str, Any]]) -> None: + self.assignments = assignments + self.documents = documents + self.missing: set[str] = set() + + def __call__(self, url: str) -> dict[str, Any]: + path = url.removeprefix(discover_mod.ARM).split("?", 1)[0] + lowered = path.lower() + if lowered.endswith("/policyassignments"): + return {"value": self.assignments} + if lowered.endswith(("/policydefinitions", "/policysetdefinitions", "/policyexemptions")): + return {"value": []} + document = self.documents.get(lowered) + if document is None: + self.missing.add(path) + return {} + return {**document, "id": path} + + +def build_policy_api(library_root: Path, builtins_dir: Path) -> tuple[OfflinePolicyApi, dict[str, list[str]]]: + alz = library_root / "platform" / "alz" + custom_defs = _name_index(alz / "policy_definitions") + custom_sets = _name_index(alz / "policy_set_definitions") + builtin_defs = _builtin_index(builtins_dir, "policyDefinitions") + builtin_sets = _builtin_index(builtins_dir, "policySetDefinitions") + assignment_files = { + str(_read_json(p)["name"]): _read_json(p) for p in sorted((alz / "policy_assignments").glob("*.json")) + } + + documents: dict[str, dict[str, Any]] = {} + custom_base = f"{MG_PREFIX}/{ROOT_MG}/providers/Microsoft.Authorization".lower() + for name, doc in custom_defs.items(): + documents[f"{custom_base}/policydefinitions/{name}"] = _localize(doc) + for name, doc in custom_sets.items(): + documents[f"{custom_base}/policysetdefinitions/{name}"] = _localize(doc) + for name, doc in builtin_defs.items(): + documents.setdefault(f"/providers/microsoft.authorization/policydefinitions/{name}", doc) + for name, doc in builtin_sets.items(): + documents.setdefault(f"/providers/microsoft.authorization/policysetdefinitions/{name}", doc) + + assignments: list[dict[str, Any]] = [] + included: dict[str, list[str]] = {} + for archetype, mg_name in ARCHETYPES: + archetype_doc = _read_json(alz / "archetype_definitions" / f"{archetype}.alz_archetype_definition.json") + scope = f"{MG_PREFIX}/{mg_name}" + included[archetype] = list(archetype_doc["policy_assignments"]) + for name in archetype_doc["policy_assignments"]: + source = _localize(assignment_files[name]) + props = {**source["properties"], "scope": scope} + assignments.append( + { + "id": f"{scope}/providers/Microsoft.Authorization/policyAssignments/{name}", + "name": name, + "properties": props, + } + ) + return OfflinePolicyApi(assignments, documents), included + + +def build_baseline( + library_root: Path, + builtins_dir: Path, + generated_at: str, + library_ref: str = LIBRARY_REF, + azure_policy_ref: str = AZURE_POLICY_REF, +) -> dict[str, Any]: + api, included = build_policy_api(library_root, builtins_dir) + envelope = discover_mod.discover( + SYNTHETIC_SUBSCRIPTION_ID, project=BASELINE_KIND, az_rest=api + ) + if api.missing: + raise RuntimeError("unresolved policy documents: " + ", ".join(sorted(api.missing))) + if envelope.get("discovery_status") != "COMPLETE": + raise RuntimeError(f"discovery incomplete: {envelope.get('discovery_status')}") + + envelope.pop("project", None) + envelope["source"] = BASELINE_KIND + envelope["subscription_id"] = SYNTHETIC_SUBSCRIPTION_ID + envelope["discovered_at"] = generated_at + metadata = envelope.get("discovery_metadata") + if isinstance(metadata, dict): + metadata["discovered_at"] = generated_at + scope = metadata.get("scope") + if isinstance(scope, dict): + scope["subscription_id"] = SYNTHETIC_SUBSCRIPTION_ID + envelope["provenance"] = { + "kind": BASELINE_KIND, + "alz_library_ref": library_ref, + "azure_policy_ref": azure_policy_ref, + "archetypes": included, + "verified_against_tenant": False, + } + + summary = envelope.get("discovery_summary", {}) + return { + "schema_version": "governance-baseline-v1", + "baseline_kind": BASELINE_KIND, + "alz_library_ref": library_ref, + "azure_policy_ref": azure_policy_ref, + "generated_at": generated_at, + "management_group_id": ARCHETYPES[-1][1], + "coverage_status": "COMPLETE", + "subscriptions_discovered": 1, + "subscriptions_processed": 1, + "subscriptions_skipped": [], + "subscriptions_excluded": [], + "summary": { + "total_findings": len(envelope.get("findings", [])), + "total_blockers": summary.get("blocker_count", 0), + "total_auto_remediate": summary.get("auto_remediate_count", 0), + "subscriptions_complete": 1, + }, + "subscriptions": {SYNTHETIC_SUBSCRIPTION_ID: envelope}, + } + + +def write_gzip_json(path: Path, document: dict[str, Any]) -> None: + """Write deterministic gzip (fixed mtime, sorted keys) so regeneration only diffs on content.""" + payload = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8") + b"\n" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0, filename="") as gz: + gz.write(payload) + + +def _preserve_timestamp(out: Path, document: dict[str, Any]) -> None: + """Keep the prior generated_at when content is unchanged, to avoid no-op churn.""" + if not out.exists(): + return + try: + prior = json.loads(gzip.decompress(out.read_bytes())) + except (OSError, ValueError): + return + prior_time = prior.get("generated_at") + if not isinstance(prior_time, str): + return + entry = document["subscriptions"][SYNTHETIC_SUBSCRIPTION_ID] + current_time = document["generated_at"] + + def normalise(doc: dict[str, Any], stamp: str) -> str: + return json.dumps(doc, sort_keys=True).replace(stamp, "") + + if normalise(prior, prior_time) == normalise(document, current_time): + document["generated_at"] = prior_time + entry["discovered_at"] = prior_time + if isinstance(entry.get("discovery_metadata"), dict): + entry["discovery_metadata"]["discovered_at"] = prior_time + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate the ALZ Corp default governance baseline.") + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + parser.add_argument("--library-dir", type=Path, help="Pre-extracted Azure-Landing-Zones-Library archive root.") + parser.add_argument("--builtins-dir", type=Path, help="Pre-extracted azure-policy built-in-policies directory.") + parser.add_argument("--library-ref", help="ALZ library tag the --library-dir tree was taken from (required with it).") + parser.add_argument( + "--azure-policy-ref", help="azure-policy commit the --builtins-dir tree was taken from (required with it)." + ) + args = parser.parse_args(argv) + # A local tree is not the pinned release, so its provenance must be stated rather than assumed. + for tree, ref, tree_flag, ref_flag in ( + (args.library_dir, args.library_ref, "--library-dir", "--library-ref"), + (args.builtins_dir, args.azure_policy_ref, "--builtins-dir", "--azure-policy-ref"), + ): + if tree and not ref: + parser.error(f"{tree_flag} requires {ref_flag}") + if ref and not tree: + parser.error(f"{ref_flag} is only valid with {tree_flag}") + library_ref = args.library_ref or LIBRARY_REF + azure_policy_ref = args.azure_policy_ref or AZURE_POLICY_REF + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + if args.library_dir: + library_root = args.library_dir + else: + library_root = _extract(LIBRARY_URL, tmp_path / "lib", ("/platform/alz/",)) + if args.builtins_dir: + builtins_dir = args.builtins_dir + else: + builtins_dir = _extract( + AZURE_POLICY_URL, tmp_path / "builtin", ("/built-in-policies/policyDefinitions/", "/built-in-policies/policySetDefinitions/") + ) / "built-in-policies" + generated_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + document = build_baseline(library_root, builtins_dir, generated_at, library_ref, azure_policy_ref) + except (RuntimeError, KeyError, OSError, ValueError) as error: + print(f"generate_alz_baseline: {error}", file=sys.stderr) + return 1 + _preserve_timestamp(args.out, document) + write_gzip_json(args.out, document) + summary = document["summary"] + print( + f"wrote {args.out}: {summary['total_findings']} findings " + f"({summary['total_blockers']} blockers, {summary['total_auto_remediate']} auto-remediate)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py index d39725c3..a611d79a 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py @@ -33,12 +33,23 @@ def resolve_path(value: Any, dotted_path: str) -> Any: return current +def default_subscription(baseline: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Return the single synthetic envelope of an alz-corp-default baseline.""" + if baseline.get("baseline_kind") != "alz-corp-default": + raise KeyError("baseline is not an alz-corp-default baseline") + subscriptions = baseline.get("subscriptions") + if not isinstance(subscriptions, dict) or len(subscriptions) != 1: + raise ValueError("alz-corp-default baseline must hold exactly one subscription entry") + return next(iter(subscriptions.items())) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Read a JSON or JSON.gz governance baseline") parser.add_argument("baseline", help="Path to governance baseline") group = parser.add_mutually_exclusive_group() group.add_argument("--get", dest="field", help="Print one dotted field") group.add_argument("--subscription", help="Print one subscriptions. envelope") + group.add_argument("--alz-default", action="store_true", help="Print the alz-corp-default envelope") parser.add_argument("--compact", action="store_true", help="Emit compact JSON") args = parser.parse_args(argv) @@ -48,6 +59,8 @@ def main(argv: list[str] | None = None) -> int: value = resolve_path(baseline, args.field) elif args.subscription: value = baseline["subscriptions"][args.subscription] + elif args.alz_default: + value = default_subscription(baseline)[1] else: value = baseline except (OSError, gzip.BadGzipFile, UnicodeDecodeError, json.JSONDecodeError, KeyError, ValueError) as exc: diff --git a/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py b/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py index e0fa9b0f..017daf4b 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py @@ -11,6 +11,7 @@ import argparse import json +import re import sys from datetime import UTC, datetime from pathlib import Path @@ -82,6 +83,11 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--in", dest="input", required=True, help="Path to governance-constraints-v1 JSON envelope.") parser.add_argument("--out", required=True, help="Path to project-local 04-governance-constraints.json.") + parser.add_argument( + "--subscription", + default=None, + help="Target subscription ID; required for the alz-corp-default baseline, ignored otherwise.", + ) parser.add_argument( "--arch", default=None, help="Path to 02-architecture-assessment.md for policy→resource mapping." ) @@ -107,6 +113,29 @@ def main(argv: list[str] | None = None) -> int: sys.stdout.write(json.dumps(status, separators=(",", ":")) + "\n") return 2 + if envelope.get("source") == "alz-corp-default": + target = args.subscription or "" + if not re.fullmatch(r"[0-9a-fA-F-]{36}", target): + status = { + "status": "FAILED", + "error": "subscription-required", + "detail": "The ALZ Corp default is rendered for a target; pass --subscription ", + } + sys.stdout.write(json.dumps(status, separators=(",", ":")) + "\n") + return 2 + # The baseline's synthetic id must not leak into the project artifact; keep it only as provenance. + provenance = envelope.setdefault("provenance", {}) + provenance["baseline_subscription_id"] = envelope.get("subscription_id") + envelope["subscription_id"] = target + scope = (envelope.get("discovery_metadata") or {}).get("scope") + if isinstance(scope, dict): + scope["subscription_id"] = target + # Static policy set, evaluated now; the generation date says nothing about tenant freshness. + rendered_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + envelope["discovered_at"] = rendered_at + if isinstance(envelope.get("discovery_metadata"), dict): + envelope["discovery_metadata"]["discovered_at"] = rendered_at + try: metadata = envelope.get("discovery_metadata") timestamp = metadata.get("discovered_at") if isinstance(metadata, dict) else envelope.get("discovered_at") diff --git a/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py b/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py index be9af257..34fc5793 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py @@ -351,10 +351,20 @@ def emit_preview_md(envelope: dict[str, Any], out_path: Path, arch_resources: li a(f"| Tag Policies | {len(tags_required)} tags required | {discovered_at} |") a(f"| Security Policies | {len(security)} constraints | {discovered_at} |\n") is_cached = envelope.get("source") == "cached_baseline" or envelope.get("cached_baseline", False) + is_assumed = envelope.get("source") == "alz-corp-default" discovery_method = "Cached governance baseline (governance-policy-baseline.json.gz)" if is_cached else "Azure Policy REST API (discover.py)" + if is_assumed: + discovery_method = "ALZ Corp default policy set (assumed governance, not verified against the tenant)" a(f"**Discovery Method**: {discovery_method}") a(f"**Subscription**: {envelope.get('subscription_id', 'unknown')}") a(f"**Scope**: Subscription + management-group inherited\n") + if is_assumed: + provenance = envelope.get("provenance") or {} + a( + "> ⚠️ **Assumed governance.** These are the default Azure landing zone policies for a subscription under " + f"the Corp management group (library `{provenance.get('alz_library_ref', 'unknown')}`). They were not " + "read from this tenant; run live discovery to verify before deployment.\n" + ) if blockers: a(f"> ⚠️ **{len(blockers)} deployment blocker(s)** detected. Review the [Deployment Blockers](#-deployment-blockers) section before proceeding to IaC planning.\n") @@ -640,6 +650,8 @@ def emit_preview_md(envelope: dict[str, Any], out_path: Path, arch_resources: li a("| Tag Governance | [Tagging Strategy](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging) |\n") a("---\n") footer_source = "cached governance baseline" if is_cached else "Azure Policy REST API via discover.py" + if is_assumed: + footer_source = "the ALZ Corp default policy set (assumed, not tenant-verified)" a(f"_Governance constraints discovered from {footer_source}._\n") preview_path.write_text("\n".join(lines) + "\n") diff --git a/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py new file mode 100644 index 00000000..c409dec0 --- /dev/null +++ b/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py @@ -0,0 +1,284 @@ +"""Tests for generate_alz_baseline.py using a tiny synthetic library and built-in tree (no network).""" + +from __future__ import annotations + +import gzip +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import discover # noqa: E402 +import generate_alz_baseline as gen # noqa: E402 +from governance_baseline import default_subscription, load_governance_json # noqa: E402 + +DENY_GUID = "11111111-1111-1111-1111-111111111111" +DINE_GUID = "22222222-2222-2222-2222-222222222222" +SET_GUID = "33333333-3333-3333-3333-333333333333" +TARGET_SUBSCRIPTION = "44444444-4444-4444-4444-444444444444" + + +def _write(path: Path, doc: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc), encoding="utf-8") + + +def _assignment(name: str, definition_id: str, **props: Any) -> dict[str, Any]: + return { + "name": name, + "properties": { + "displayName": name, + "policyDefinitionId": definition_id, + "enforcementMode": props.pop("enforcementMode", "Default"), + "parameters": props.pop("parameters", {}), + "scope": "/providers/Microsoft.Management/managementGroups/placeholder", + **props, + }, + } + + +@pytest.fixture +def sources(tmp_path: Path) -> tuple[Path, Path]: + library = tmp_path / "lib" + alz = library / "platform" / "alz" + builtins = tmp_path / "builtin" + + for archetype, assignments in { + "root": ["Enforce-Set"], + "landing_zones": ["Deny-Thing"], + "corp": ["Deploy-Thing", "Audit-Only"], + }.items(): + _write( + alz / "archetype_definitions" / f"{archetype}.alz_archetype_definition.json", + {"name": archetype, "policy_assignments": assignments}, + ) + + mg = "/providers/Microsoft.Management/managementGroups/placeholder/providers/Microsoft.Authorization" + builtin = "/providers/Microsoft.Authorization" + for assignment in ( + _assignment("Enforce-Set", f"{mg}/policySetDefinitions/Custom-Set", enforcementMode="DoNotEnforce"), + _assignment("Deny-Thing", f"{builtin}/policyDefinitions/{DENY_GUID}"), + _assignment("Deploy-Thing", f"{builtin}/policyDefinitions/{DINE_GUID}"), + _assignment("Audit-Only", f"{builtin}/policyDefinitions/{DENY_GUID}", parameters={"effect": {"value": "Audit"}}), + ): + _write(alz / "policy_assignments" / f"{assignment['name']}.alz_policy_assignment.json", assignment) + + _write( + alz / "policy_set_definitions" / "Custom-Set.alz_policy_set_definition.json", + { + "name": "Custom-Set", + "properties": { + "parameters": {"effect": {"type": "String", "defaultValue": "Deny"}}, + "policyDefinitions": [ + { + "policyDefinitionReferenceId": "member", + "policyDefinitionId": "${current_scope_resource_id}/providers/Microsoft.Authorization/policyDefinitions/Custom-Def", + "parameters": {"effect": {"value": "[parameters('effect')]"}}, + } + ], + }, + }, + ) + _write( + alz / "policy_definitions" / "Custom-Def.alz_policy_definition.json", + { + "name": "Custom-Def", + "properties": { + "displayName": "Custom definition", + "parameters": {"effect": {"type": "String", "defaultValue": "Deny"}}, + "policyRule": { + "if": {"field": "type", "equals": "Microsoft.Storage/storageAccounts"}, + "then": {"effect": "[parameters('effect')]"}, + }, + }, + }, + ) + + for guid, effect in ((DENY_GUID, "Deny"), (DINE_GUID, "DeployIfNotExists")): + _write( + builtins / "policyDefinitions" / "Cat" / f"{guid}.json", + { + "name": guid, + "id": f"/providers/Microsoft.Authorization/policyDefinitions/{guid}", + "properties": { + "displayName": f"Built-in {effect}", + "parameters": {"effect": {"type": "String", "defaultValue": effect}}, + "policyRule": { + "if": {"field": "type", "equals": "Microsoft.Storage/storageAccounts"}, + "then": {"effect": "[parameters('effect')]"}, + }, + }, + }, + ) + (builtins / "policySetDefinitions").mkdir(parents=True) + return library, builtins + + +def test_baseline_resolves_corp_archetypes_and_marks_provenance(sources: tuple[Path, Path]) -> None: + library, builtins = sources + document = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + + assert document["baseline_kind"] == "alz-corp-default" + assert document["alz_library_ref"] == gen.LIBRARY_REF + entry = document["subscriptions"][gen.SYNTHETIC_SUBSCRIPTION_ID] + assert entry["source"] == "alz-corp-default" + assert entry["provenance"]["verified_against_tenant"] is False + assert entry["provenance"]["archetypes"] == { + "root": ["Enforce-Set"], + "landing_zones": ["Deny-Thing"], + "corp": ["Deploy-Thing", "Audit-Only"], + } + assert entry["discovery_status"] == "COMPLETE" + assert document["summary"]["total_findings"] == len(entry["findings"]) + + +def test_scopes_follow_the_archetype_and_enforcement_mode_is_kept(sources: tuple[Path, Path]) -> None: + library, builtins = sources + entry = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z")["subscriptions"][ + gen.SYNTHETIC_SUBSCRIPTION_ID + ] + scopes = {a["displayName"]: a["scope"].rsplit("/", 1)[-1] for a in entry["assignment_inventory"]} + assert scopes == { + "Enforce-Set": "alz", + "Deny-Thing": "alz-landingzones", + "Deploy-Thing": "alz-corp", + "Audit-Only": "alz-corp", + } + by_assignment = {f["assignment_display_name"]: f for f in entry["findings"]} + # DoNotEnforce is included but never a blocker. + assert by_assignment["Enforce-Set"]["enforcement_mode"] == "DoNotEnforce" + assert by_assignment["Enforce-Set"]["classification"] == "informational" + assert by_assignment["Deny-Thing"]["classification"] == "blocker" + assert by_assignment["Deploy-Thing"]["classification"] == "auto-remediate" + assert "Audit-Only" not in by_assignment + + +def test_unresolved_definition_fails_loudly(sources: tuple[Path, Path]) -> None: + library, builtins = sources + (builtins / "policyDefinitions" / "Cat" / f"{DENY_GUID}.json").unlink() + with pytest.raises(RuntimeError, match="unresolved"): + gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + + +def test_gzip_output_is_deterministic_and_preserves_timestamp(tmp_path: Path, sources: tuple[Path, Path]) -> None: + library, builtins = sources + out = tmp_path / "baseline.json.gz" + first = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + gen._preserve_timestamp(out, first) + gen.write_gzip_json(out, first) + first_bytes = out.read_bytes() + + second = gen.build_baseline(library, builtins, "2026-10-20T00:00:00Z") + gen._preserve_timestamp(out, second) + gen.write_gzip_json(out, second) + + assert out.read_bytes() == first_bytes + assert load_governance_json(out)["generated_at"] == "2026-10-13T00:00:00Z" + + +def test_default_subscription_requires_the_alz_kind(sources: tuple[Path, Path]) -> None: + library, builtins = sources + document = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + sub_id, envelope = default_subscription(document) + assert sub_id == gen.SYNTHETIC_SUBSCRIPTION_ID + assert envelope["source"] == "alz-corp-default" + + document.pop("baseline_kind") + with pytest.raises(KeyError): + default_subscription(document) + + +def test_conditional_effect_follows_the_resource_type_list() -> None: + expression = "[if(contains(parameters('resourceTypeList'),'microsoft.sql/servers'),parameters('effect'),'Disabled')]" + listed = {"resourceTypeList": ["Microsoft.SQL/servers"], "effect": "DeployIfNotExists"} + unlisted = {"resourceTypeList": ["microsoft.web/sites"], "effect": "DeployIfNotExists"} + assert discover._resolve_parameters(expression, listed) == "DeployIfNotExists" + assert discover._resolve_parameters(expression, unlisted) == "Disabled" + # A missing list is not evaluated; the expression stays unresolved instead of guessing. + assert discover._resolve_parameters(expression, {"effect": "Deny"}) == expression + + +def test_enforce_setting_is_not_reported_as_unresolved(sources: tuple[Path, Path]) -> None: + library, builtins = sources + path = builtins / "policyDefinitions" / "Cat" / f"{DENY_GUID}.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["properties"]["parameters"]["effect"]["defaultValue"] = "enforceSetting" + path.write_text(json.dumps(doc), encoding="utf-8") + + entry = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z")["subscriptions"][ + gen.SYNTHETIC_SUBSCRIPTION_ID + ] + assert entry["discovery_status"] == "COMPLETE" + assert all(f["assignment_display_name"] != "Deny-Thing" for f in entry["findings"]) + + +def test_gzip_roundtrip_is_plain_json(tmp_path: Path) -> None: + out = tmp_path / "x.json.gz" + gen.write_gzip_json(out, {"b": 1, "a": 2}) + assert json.loads(gzip.decompress(out.read_bytes())) == {"a": 2, "b": 1} + + +def test_cached_renderer_accepts_the_default_with_a_fresh_timestamp_and_assumed_banner( + tmp_path: Path, sources: tuple[Path, Path] +) -> None: + import render_cached_governance as renderer + + library, builtins = sources + document = gen.build_baseline(library, builtins, "2020-01-01T00:00:00Z") + _, envelope = default_subscription(document) + source = tmp_path / "sub.json" + source.write_text(json.dumps(envelope), encoding="utf-8") + out = tmp_path / "agent-output" / "demo" / "04-governance-constraints.json" + + assert renderer.main(["--in", str(source), "--out", str(out), "--subscription", TARGET_SUBSCRIPTION]) == 0 + + rendered = json.loads(out.read_text(encoding="utf-8")) + assert rendered["source"] == "alz-corp-default" + assert rendered["discovered_at"] > "2026-01-01" + assert rendered["subscription_id"] == TARGET_SUBSCRIPTION + assert rendered["discovery_metadata"]["scope"]["subscription_id"] == TARGET_SUBSCRIPTION + assert rendered["provenance"]["baseline_subscription_id"] == gen.SYNTHETIC_SUBSCRIPTION_ID + preview = (out.parent / "04-governance-constraints.preview.md").read_text(encoding="utf-8") + assert "Assumed governance" in preview + assert gen.SYNTHETIC_SUBSCRIPTION_ID not in preview + + +@pytest.mark.parametrize("extra", [[], ["--subscription", "not-a-guid"]]) +def test_cached_renderer_rejects_the_default_without_a_valid_target_subscription( + tmp_path: Path, sources: tuple[Path, Path], extra: list[str] +) -> None: + import render_cached_governance as renderer + + library, builtins = sources + _, envelope = default_subscription(gen.build_baseline(library, builtins, "2020-01-01T00:00:00Z")) + source = tmp_path / "sub.json" + source.write_text(json.dumps(envelope), encoding="utf-8") + out = tmp_path / "agent-output" / "demo" / "04-governance-constraints.json" + + assert renderer.main(["--in", str(source), "--out", str(out), *extra]) == 2 + assert not out.exists() + + +def test_local_source_trees_require_explicit_refs(tmp_path: Path, sources: tuple[Path, Path]) -> None: + library, builtins = sources + out = tmp_path / "baseline.json.gz" + base = ["--out", str(out), "--library-dir", str(library), "--builtins-dir", str(builtins)] + + with pytest.raises(SystemExit): + gen.main(base) + with pytest.raises(SystemExit): + gen.main([*base, "--library-ref", "platform/alz/9.9.9"]) + assert not out.exists() + + assert gen.main([*base, "--library-ref", "platform/alz/9.9.9", "--azure-policy-ref", "abc123"]) == 0 + document = load_governance_json(out) + assert document["alz_library_ref"] == "platform/alz/9.9.9" + assert document["azure_policy_ref"] == "abc123" + provenance = document["subscriptions"][gen.SYNTHETIC_SUBSCRIPTION_ID]["provenance"] + assert provenance["alz_library_ref"] == "platform/alz/9.9.9" + assert provenance["azure_policy_ref"] == "abc123" diff --git a/package.json b/package.json index 83918013..6527422e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "lint:skills-format": "node tools/scripts/validate-skills.mjs", "lint:governance-refs": "node tools/scripts/validate-governance-refs.mjs", "test:governance-discovery": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_discover.py -q", - "test:governance-baseline-python": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py -q", + "test:governance-baseline-python": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py .github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py -q", "validate:instruction-checks": "node tools/scripts/validate-instruction-checks.mjs", "validate:session-state": "node tools/scripts/validate-session-state.mjs", "lint:prose": "command -v vale >/dev/null 2>&1 || { echo 'Vale is not installed; install it before running lint:prose' >&2; exit 127; }; vale --config .vale.ini .github/ README.md", @@ -58,6 +58,7 @@ "validate:terraform": "status=0; for dir in infra/terraform/*/; do if [ -f \"$dir/main.tf\" ]; then echo \"Validating $dir\"; (cd \"$dir\" && terraform init -backend=false -input=false && terraform validate) || status=1; fi; done; exit \"$status\"", "validate:vscode": "node tools/scripts/validate-vscode-config.mjs", "fetch:deprecations": "node tools/scripts/fetch-azure-deprecations.mjs", + "generate:governance-baseline-alz": "python3 .github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py", "snapshot:baseline": "bash tools/scripts/snapshot-agent-context.sh", "diff:baseline": "bash tools/scripts/diff-context-baseline.sh", "profile:debug-log": "python3 tools/scripts/profile_debug_log.py", diff --git a/tools/schemas/governance-baseline.schema.json b/tools/schemas/governance-baseline.schema.json index f375c2a4..36772c8f 100644 --- a/tools/schemas/governance-baseline.schema.json +++ b/tools/schemas/governance-baseline.schema.json @@ -16,11 +16,34 @@ "subscriptions" ], "additionalProperties": false, + "allOf": [ + { + "if": { "required": ["baseline_kind"] }, + "then": { "required": ["alz_library_ref", "azure_policy_ref", "generated_at"] } + } + ], "properties": { "schema_version": { "type": "string", "const": "governance-baseline-v1" }, + "baseline_kind": { + "type": "string", + "description": "Absent for a tenant baseline collected by the workflow. alz-corp-default marks the shipped, offline-generated ALZ Corp policy set, which is assumed governance and not verified against any tenant.", + "enum": ["alz-corp-default"] + }, + "alz_library_ref": { + "type": "string", + "description": "Azure-Landing-Zones-Library tag the baseline was generated from." + }, + "azure_policy_ref": { + "type": "string", + "description": "Azure/azure-policy commit the built-in definitions were read from." + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, "management_group_id": { "type": "string", "minLength": 1 diff --git a/tools/schemas/governance-constraints.schema.json b/tools/schemas/governance-constraints.schema.json index 957223fe..ad0a5064 100644 --- a/tools/schemas/governance-constraints.schema.json +++ b/tools/schemas/governance-constraints.schema.json @@ -18,7 +18,7 @@ "discovered_at": { "type": "string", "format": "date-time" }, "source": { "type": "string", - "enum": ["azure-policy-rest-api", "az-cli", "static", "partial", "github-actions-baseline"] + "enum": ["azure-policy-rest-api", "az-cli", "static", "partial", "github-actions-baseline", "alz-corp-default"] }, "discovery_metadata": { "description": "L0 attestation envelope. Mandatory after 30-day rollout window; warn-only before then. Every downstream consumer (05-IaC Planner, 06b/06t CodeGen, 07b/07t Deploy) reads this first and STOPS on staleness, signature drift, or non-COMPLETE status.", diff --git a/tools/scripts/audit-retirement-candidates.mjs b/tools/scripts/audit-retirement-candidates.mjs index 820eb796..060244e7 100644 --- a/tools/scripts/audit-retirement-candidates.mjs +++ b/tools/scripts/audit-retirement-candidates.mjs @@ -223,6 +223,15 @@ function generatedOwner(filePath) { source_of_truth: "Azure Verified Module registries", regeneration_command: "npm run refresh:avm-module-index", }, + ".github/data/governance-policy-baseline.json.gz": { + generated_by: [ + ".github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py", + "tools/scripts/collect-governance-baseline.ps1", + ], + source_of_truth: + "pinned ALZ library archetypes (shipped default) or the tenant policy state (collector workflow)", + regeneration_command: "npm run generate:governance-baseline-alz", + }, }; return owners[filePath] ?? { generated_by: [], source_of_truth: null, regeneration_command: null }; }