feat(orchestration): compose security scenarios
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb
This commit is contained in:
parent
ad46cc89fc
commit
d96aab2321
20 changed files with 1464 additions and 30 deletions
|
|
@ -13,7 +13,7 @@ python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
|||
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml
|
||||
```
|
||||
|
||||
Validate and compose a sample scenario:
|
||||
Validate and compose the legacy single-provider conformance sample:
|
||||
|
||||
```bash
|
||||
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
||||
|
|
@ -21,6 +21,17 @@ python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
|||
--scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
|
||||
```
|
||||
|
||||
The `--scenario` path exists for v0.1 conformance compatibility. It refuses
|
||||
ambiguous providers and unselected overrides, but it does not perform trust
|
||||
sequencing or build a complete owner handoff. Use the canonical Security
|
||||
Scenario Composer for deterministic multi-provider plans:
|
||||
|
||||
```bash
|
||||
python3 tools/security-scenario-composer/security_scenario_composer.py \
|
||||
--scenario examples/security-scenarios/c0-local-identity.yaml \
|
||||
capabilities/playbooks/net-kingdom.local-identity.yaml
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ TRUST_STATES = {
|
|||
"tenant_onboarding_trust",
|
||||
}
|
||||
SCENARIO_AUTHORITIES = {"platform", "netkingdom", "tenant"}
|
||||
SECRET_REFERENCE_PATTERN = re.compile(
|
||||
r"(?:[a-z][a-z0-9+.-]*://\S+|(?:/|\./|\.\./)\S+)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -127,6 +130,10 @@ def is_type(value: Any, declared_type: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def is_secret_reference(value: Any) -> bool:
|
||||
return isinstance(value, str) and SECRET_REFERENCE_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def validate_constraints(value: Any, param: dict[str, Any], path: str) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
constraints = param.get("constraints", {})
|
||||
|
|
@ -277,6 +284,18 @@ def validate_parameters(spec: dict[str, Any]) -> list[Issue]:
|
|||
if item.get("sensitivity") in {"security_sensitive", "secret_reference"} and item.get("tuning_authority") == "tenant_tunable":
|
||||
issues.append(issue("ERROR", path, "security-sensitive parameters cannot be tenant_tunable"))
|
||||
|
||||
if item.get("sensitivity") == "secret_reference":
|
||||
if declared_type != "string":
|
||||
issues.append(issue("ERROR", f"{path}.type", "secret_reference parameters must use string type"))
|
||||
if "default" in item and not is_secret_reference(item["default"]):
|
||||
issues.append(
|
||||
issue(
|
||||
"ERROR",
|
||||
f"{path}.default",
|
||||
"secret_reference default must be a URI or explicit absolute/relative path",
|
||||
)
|
||||
)
|
||||
|
||||
if "default" in item and declared_type in PARAM_TYPES and not is_type(item["default"], declared_type):
|
||||
issues.append(issue("ERROR", f"{path}.default", f"default does not match type {declared_type!r}"))
|
||||
if declared_type in PARAM_TYPES:
|
||||
|
|
@ -405,6 +424,15 @@ def validate_override_allowed(param: dict[str, Any], value: Any, scenario_author
|
|||
if scenario_authority == "tenant" and sensitivity in {"security_sensitive", "secret_reference"}:
|
||||
issues.append(issue("ERROR", path, f"tenant authority cannot override {sensitivity} parameter {name!r}"))
|
||||
|
||||
if sensitivity == "secret_reference" and not is_secret_reference(value):
|
||||
issues.append(
|
||||
issue(
|
||||
"ERROR",
|
||||
path,
|
||||
f"override for {name!r} must be a secret-reference URI or explicit absolute/relative path",
|
||||
)
|
||||
)
|
||||
|
||||
if not is_type(value, declared_type):
|
||||
issues.append(issue("ERROR", path, f"override for {name!r} does not match type {declared_type!r}"))
|
||||
issues.extend(validate_constraints(value, param, path))
|
||||
|
|
@ -433,10 +461,23 @@ def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any])
|
|||
|
||||
selected: list[Declaration] = []
|
||||
for cap_id in required_caps:
|
||||
matches = [declaration for declaration in declarations if cap_id in declaration.capabilities]
|
||||
matches = sorted(
|
||||
[declaration for declaration in declarations if cap_id in declaration.capabilities],
|
||||
key=lambda declaration: declaration.id,
|
||||
)
|
||||
if not matches:
|
||||
issues.append(issue("ERROR", "scenario.requires.capabilities", f"no declaration provides {cap_id!r}"))
|
||||
continue
|
||||
if len(matches) > 1:
|
||||
issues.append(
|
||||
issue(
|
||||
"ERROR",
|
||||
"scenario.requires.capabilities",
|
||||
f"ambiguous providers {[declaration.id for declaration in matches]}; "
|
||||
"use the Security Scenario Composer to pin one explicitly",
|
||||
)
|
||||
)
|
||||
continue
|
||||
selected.append(matches[0])
|
||||
|
||||
# Preserve order while deduplicating declarations selected for several capabilities.
|
||||
|
|
@ -444,6 +485,16 @@ def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any])
|
|||
for declaration in selected:
|
||||
selected_by_id.setdefault(declaration.id, declaration)
|
||||
|
||||
for declaration_id in overrides:
|
||||
if declaration_id not in selected_by_id:
|
||||
issues.append(
|
||||
issue(
|
||||
"ERROR",
|
||||
f"scenario.parameter_overrides.{declaration_id}",
|
||||
"override targets an unselected declaration",
|
||||
)
|
||||
)
|
||||
|
||||
composed = {
|
||||
"scenario": scenario.get("id", "scenario:unnamed"),
|
||||
"authority": authority,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,81 @@ def test_tenant_tunable_secret_reference_fails(tmp_path):
|
|||
assert any("security-sensitive parameters cannot be tenant_tunable" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_plaintext_secret_reference_default_fails(tmp_path):
|
||||
data = valid_declaration()
|
||||
data["spec"]["parameters"][2].update(
|
||||
{
|
||||
"type": "string",
|
||||
"default": "plaintext-looking-value",
|
||||
"sensitivity": "secret_reference",
|
||||
"tuning_authority": "platform_only",
|
||||
}
|
||||
)
|
||||
declaration = declaration_from(data, tmp_path)
|
||||
|
||||
issues = validator.validate_declaration(declaration)
|
||||
|
||||
assert any("secret_reference default must be" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_secret_reference_uri_override_passes(tmp_path):
|
||||
data = valid_declaration()
|
||||
data["spec"]["parameters"][2].update(
|
||||
{
|
||||
"name": "credential_ref",
|
||||
"type": "string",
|
||||
"default": "openbao://kv/platform/reference",
|
||||
"sensitivity": "secret_reference",
|
||||
"tuning_authority": "platform_only",
|
||||
}
|
||||
)
|
||||
declaration = declaration_from(data, tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:secret-reference",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {
|
||||
"railiance-infra.bootstrap-host": {
|
||||
"target_hosts": ["railiance01"],
|
||||
"credential_ref": "kubernetes://platform/reference#token",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert error_messages(issues) == []
|
||||
|
||||
|
||||
def test_plaintext_secret_reference_override_fails(tmp_path):
|
||||
data = valid_declaration()
|
||||
data["spec"]["parameters"][2].update(
|
||||
{
|
||||
"name": "credential_ref",
|
||||
"type": "string",
|
||||
"default": "openbao://kv/platform/reference",
|
||||
"sensitivity": "secret_reference",
|
||||
"tuning_authority": "platform_only",
|
||||
}
|
||||
)
|
||||
declaration = declaration_from(data, tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:bad-secret-reference",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {
|
||||
"railiance-infra.bootstrap-host": {
|
||||
"target_hosts": ["railiance01"],
|
||||
"credential_ref": "plaintext-looking-value",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert any("must be a secret-reference URI" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_scenario_composition_selects_and_overrides(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
scenario = {
|
||||
|
|
@ -196,3 +271,36 @@ def test_required_parameter_without_override_fails(tmp_path):
|
|||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert any("required parameter has no default or override" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_demo_composition_refuses_ambiguous_provider(tmp_path):
|
||||
first = declaration_from(valid_declaration(), tmp_path)
|
||||
second_data = valid_declaration()
|
||||
second_data["metadata"]["id"] = "railiance-infra.bootstrap-host-alternative"
|
||||
second_path = tmp_path / "alternative.yaml"
|
||||
second_path.write_text(yaml.safe_dump(second_data, sort_keys=False), encoding="utf-8")
|
||||
second = validator.Declaration(path=second_path, data=second_data)
|
||||
scenario = {
|
||||
"id": "scenario:ambiguous-provider",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([second, first], scenario)
|
||||
|
||||
assert any("ambiguous providers" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_demo_composition_refuses_unselected_override(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:unselected-override",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {"owner.unselected": {"target_hosts": ["railiance01"]}},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert any("override targets an unselected declaration" in msg for msg in error_messages(issues))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue