NK-WP-0048-T04: SecurityCaseType oidc-browser-relying-party 1.0 (proposed)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Composes key-cape.oidc-client-registration; confidential/public conditions,
one justified attended step, threat model, fit table of hand-built clients,
Forgejo and public-portal examples. Validator gains when/required_when and
provider discovery. 43 tests pass. Adds T08 (identity-provisioner groups ensure).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
This commit is contained in:
tegwick 2026-10-08 22:45:31 +02:00
parent 9f49aae925
commit fa2cb57384
12 changed files with 976 additions and 76 deletions

View file

@ -29,6 +29,7 @@ parameters that are not custody references, and high-risk types without founder
acceptance.
To start a type: copy `security-case-types/_template/`, rename the directory to
the type id, and rewrite every section. The template's tests travel with it.
the type id, and rewrite every section. The template's tests travel with it:
rename `tests/test_type.py` to `tests/test_<type_id>_contract.py` so module names stay unique.
Tests: `python3 -m pytest tools/security-case-types/tests security-case-types/*/tests`.

View file

@ -88,6 +88,28 @@ def _walk_strings(value: Any, path: str):
yield from _walk_strings(v, f"{path}[{i}]")
WHEN = re.compile(r"^([a-z][a-z0-9_]*) (==|!=) ([A-Za-z0-9_.:/-]+)$")
def check_when(expr: Any, params: dict[str, dict], path: str) -> list[Issue]:
"""A condition is '<parameter> == <value>' or '<parameter> != <value>'."""
m = WHEN.fullmatch(str(expr))
if not m:
return [err(path, "must be '<parameter> == <value>' or '<parameter> != <value>'")]
if m.group(1) not in params:
return [err(path, f"condition uses unknown parameter {m.group(1)!r}")]
return []
def holds(expr: str | None, values: dict[str, Any]) -> bool:
if not expr:
return True
name, op, literal = WHEN.fullmatch(expr).groups()
actual = values.get(name)
actual = str(actual).lower() if isinstance(actual, bool) else str(actual)
return (actual == literal) if op == "==" else (actual != literal)
def secret_shaped(text: str) -> bool:
return any(p.search(text) for p in SECRET_SHAPES)
@ -123,6 +145,14 @@ def validate_type(data: dict[str, Any], type_dir: Path | None = None) -> list[Is
_validate_verification(spec["verification"], params, issues)
_validate_rollback(spec["rollback"], step_ids, spec["materialization"], issues)
_validate_acceptance(spec["acceptance"], issues)
for section in ("materialization", "follow_through", "verification", "rollback"):
for i, item in enumerate(spec[section] if isinstance(spec[section], list) else []):
if isinstance(item, dict) and "when" in item:
issues.extend(check_when(item["when"], params, f"type.spec.{section}[{i}].when"))
for i, p in enumerate(spec["parameters"] if isinstance(spec["parameters"], list) else []):
rw = (p.get("constraints") or {}).get("required_when") if isinstance(p, dict) else None
if rw is not None:
issues.extend(check_when(rw, params, f"type.spec.parameters[{i}].constraints.required_when"))
for path, text in _walk_strings(spec, "type.spec"):
if secret_shaped(text):
@ -345,7 +375,10 @@ def validate_case(case: dict[str, Any], type_data: dict[str, Any], *, require_im
for name, p in params.items():
path = f"case.spec.parameters.{name}"
if name not in values:
if p.get("case_settable") and p.get("required", True) and "default" not in p:
rw = (p.get("constraints") or {}).get("required_when")
if rw and WHEN.fullmatch(str(rw)) and holds(rw, {**{q: d.get("default") for q, d in params.items() if "default" in d}, **values}):
issues.append(err(path, f"required when {rw}"))
elif not rw and p.get("case_settable") and p.get("required", True) and "default" not in p:
issues.append(err(path, "required"))
continue
if not p.get("case_settable"):
@ -390,9 +423,21 @@ def effective_values(case: dict[str, Any], type_data: dict[str, Any]) -> dict[st
return values
def _applicable(items: list[dict], values: dict[str, Any]) -> list[dict]:
return [{k: v for k, v in i.items() if k != "when"} for i in items if holds(i.get("when"), values)]
class _Blank(dict):
"""Template values where an optional parameter a case leaves out renders empty."""
def __missing__(self, key: str) -> str:
return ""
def _fill(value: Any, values: dict[str, Any]) -> Any:
if isinstance(value, str):
return value.format_map({k: (",".join(map(str, v)) if isinstance(v, list) else v) for k, v in values.items()})
return value.format_map(_Blank({k: (",".join(map(str, v)) if isinstance(v, list) else v)
for k, v in values.items()}))
if isinstance(value, dict):
return {k: _fill(v, values) for k, v in value.items()}
if isinstance(value, list):
@ -432,10 +477,10 @@ def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: l
"case": cspec["id"],
"type": f"{tspec['id']}@{tspec['version']}",
"composition": plan,
"materialization": _fill(tspec["materialization"], values),
"follow_through": _fill(tspec["follow_through"], values),
"verification": _fill(tspec["verification"], values),
"rollback": _fill(tspec["rollback"], values),
"materialization": _fill(_applicable(tspec["materialization"], values), values),
"follow_through": _fill(_applicable(tspec["follow_through"], values), values),
"verification": _fill(_applicable(tspec["verification"], values), values),
"rollback": _fill(_applicable(tspec["rollback"], values), values),
"execution": {"permitted": False},
}
rendered["digest"] = "sha256:" + hashlib.sha256(
@ -443,6 +488,23 @@ def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: l
return issues, rendered
def discover_declarations(type_data: dict[str, Any], repos_root: Path) -> tuple[list[str], list[Issue]]:
"""Find each pinned provider's declaration in its owning repository.
A pin '<repo>.<name>' resolves to <repos_root>/<repo>/capabilities/playbooks/<pin>.yaml,
the publication path the Playbook Capability Contract defines.
"""
paths, issues = [], []
for cap, pin in sorted(type_data["spec"]["composition"].get("providers", {}).items()):
repo = pin.split(".", 1)[0]
path = repos_root / repo / "capabilities" / "playbooks" / f"{pin}.yaml"
if path.is_file():
paths.append(str(path))
else:
issues.append(err(f"composition.providers.{cap}", f"declaration {pin!r} not found at {path}"))
return paths, issues
# ── CLI ──────────────────────────────────────────────────────────────────────
def _report(issues: list[Issue]) -> int:
@ -463,7 +525,9 @@ def main(argv: list[str] | None = None) -> int:
help="validate an example case against a type that is not implemented yet")
rd = sub.add_parser("render")
rd.add_argument("case")
rd.add_argument("declarations", nargs="+")
rd.add_argument("declarations", nargs="*", help="declaration files; omit to discover them under --repos-root")
rd.add_argument("--repos-root", default=str(TOOL_DIR.parents[2]),
help="directory holding the owning repositories (default: the parent of this checkout)")
rd.add_argument("--types", required=True)
rd.add_argument("--allow-unimplemented", action="store_true")
args = ap.parse_args(argv)
@ -485,7 +549,11 @@ def main(argv: list[str] | None = None) -> int:
if code == 0:
print(f"OK case {case['spec']['id']}")
return code
declarations, load_issues = composer.load_declarations(args.declarations)
decl_paths, discover_issues = (args.declarations, []) if args.declarations else \
discover_declarations(type_data, Path(args.repos_root))
if has_errors(discover_issues):
return _report(discover_issues)
declarations, load_issues = composer.load_declarations(decl_paths)
issues, rendered = render_case(case, type_data, declarations,
require_implemented=not args.allow_unimplemented)
issues = load_issues + issues

View file

@ -157,3 +157,29 @@ def test_template_conforms_to_the_json_schemas():
bad["spec"]["surprise"] = True
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(bad, case_schema)
def test_conditions_filter_rendering_and_require_parameters():
t = implemented()
t["spec"]["parameters"][3]["required"] = False
t["spec"]["parameters"][3]["constraints"]["required_when"] = "operator_username == bootstrap-admin"
t["spec"]["materialization"][0]["when"] = "operator_username == bootstrap-admin"
t["spec"]["rollback"][1]["when"] = "operator_username == bootstrap-admin"
assert msgs(v.validate_type(t)) == []
c = copy.deepcopy(CASE)
del c["spec"]["parameters"]["signing_key_ref"]
assert any("required when" in m for m in msgs(v.validate_case(c, t)))
c["spec"]["parameters"]["operator_username"] = "someone-else"
assert msgs(v.validate_case(c, t)) == []
_, r = v.render_case(c, t, DECLS)
assert [m["id"] for m in r["materialization"]] == ["signing-key-policy", "catalog-entry"]
assert all("when" not in m for m in r["materialization"])
def test_bad_condition_is_refused():
t = copy.deepcopy(TYPE)
t["spec"]["materialization"][0]["when"] = "nonexistent == x"
t["spec"]["follow_through"][0]["when"] = "operator_username is x"
found = msgs(v.validate_type(t))
assert any("unknown parameter 'nonexistent'" in m for m in found)
assert any("must be '<parameter> == <value>'" in m for m in found)