NK-WP-0048-T04: SecurityCaseType oidc-browser-relying-party 1.0 (proposed)
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:
parent
9f49aae925
commit
fa2cb57384
12 changed files with 976 additions and 76 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue