docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
0863ba073a
commit
121097524c
46 changed files with 8039 additions and 0 deletions
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"scope": "disposable synthetic only",
|
||||
"actual_source": "Approval App over TLS with authenticated create, configured synthetic human entry, read; actual SQLite engine",
|
||||
"actual_access": "production serve binary from frozen B2/B3 temporal R2",
|
||||
"allow": true,
|
||||
"original_context_unchanged": true,
|
||||
"published_digest_match": true,
|
||||
"returned_source_record_match": true,
|
||||
"actual_source_consumed_refused": true,
|
||||
"actual_strict_prepare_and_consume": true,
|
||||
"no_record_after_refusal": true,
|
||||
"native_admission": false,
|
||||
"files_sha256": {
|
||||
"negative-cases.json": "sha256:1bef572773c34f87a3c6587dbc60ebeb2835c01125f690e8d2f5b0d70a6abadd",
|
||||
"source-record.json": "sha256:e7e9f7a0ecfc86c7cd449de7443f615e9ba19cecab1cf85f67ec03267e949e64",
|
||||
"handoff.json": "sha256:ef9739fce5d6be84f0a9e5a593499d4289fcc8bda79573c7d21316a45310234a",
|
||||
"response.json": "sha256:e7e9f7a0ecfc86c7cd449de7443f615e9ba19cecab1cf85f67ec03267e949e64",
|
||||
"original-approval-claim.json": "sha256:9e62510e1599d9bc61b15ebb24099e48a6992b808451af4fb3ce26a4b3338f1e",
|
||||
"request.json": "sha256:d65fb473885c9d6fbb089f5b7dcdcecb75cf31cf20e60a9158e562cb0eb3af4d"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
5a2099f400fb0c97fed2d991832c0e31513bbdfb
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
........................................................................ [ 33%]
|
||||
........................................................................ [ 67%]
|
||||
..................................................................... [100%]
|
||||
213 passed in 17.26s
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
........................................................................ [ 36%]
|
||||
........................................................................ [ 73%]
|
||||
.................................................... [100%]
|
||||
196 passed in 24.01s
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
........................................................................ [ 34%]
|
||||
........................................................................ [ 69%]
|
||||
................................................................ [100%]
|
||||
208 passed in 19.25s
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
........................................................................ [ 37%]
|
||||
........................................................................ [ 75%]
|
||||
................................................ [100%]
|
||||
192 passed in 25.13s
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"prior_handoff_failure_file": "docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-full.log",
|
||||
"actual_current_result": "784 passed,16 failed,7 skipped",
|
||||
"exact_failure_list_matches_prior": true,
|
||||
"failures": [
|
||||
"FAILED tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment",
|
||||
"FAILED tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch",
|
||||
"FAILED tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]",
|
||||
"FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]",
|
||||
"FAILED tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child",
|
||||
"FAILED tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner"
|
||||
],
|
||||
"new_failures": []
|
||||
}
|
||||
|
|
@ -0,0 +1,562 @@
|
|||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
............s........................................................... [ 26%]
|
||||
.................................................................F...... [ 35%]
|
||||
..FF..................FF....................FF..FFFFFFF................. [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..........................................F............F................ [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
............................................ssssss...................... [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
............... [100%]
|
||||
=================================== FAILURES ===================================
|
||||
___ test_actual_bound_child_gets_only_selected_secret_and_fixed_environment ____
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...nd_child_gets_o0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_actual_bound_child_gets_o0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d72750>
|
||||
capsys = <_pytest.capture.CaptureFixture object at 0x756740d70da0>
|
||||
|
||||
def test_actual_bound_child_gets_only_selected_secret_and_fixed_environment(bound, monkeypatch, capsys):
|
||||
data, private, _ = bound
|
||||
entry = validate_entry(data)
|
||||
for name in ["PARENT_CREDENTIAL", "BAO_TOKEN", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "PYTHONPATH"]:
|
||||
monkeypatch.setenv(name, "parent-sensitive-fixture")
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: "synthetic-owner-value")
|
||||
> assert exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env", expected_owner_digest=owner_digest(entry)) == 0
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
tests/test_exec_owner.py:55:
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
src/secrets_engine/exec_delivery.py:199: in exec_with_secret
|
||||
binding_digest = validate_delivery_target(entry, field, command, mode)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
src/secrets_engine/exec_owner.py:171: in validate_delivery_target
|
||||
_check_path(Path(binding["cwd"]), directory=True, private=True)
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
|
||||
path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_actual_bound_child_gets_o0/owner')
|
||||
directory = True, private = True
|
||||
|
||||
def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None:
|
||||
"""No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe."""
|
||||
for candidate in [*reversed(path.parents), path]:
|
||||
info = candidate.lstat()
|
||||
leaf = candidate == path
|
||||
is_dir = not leaf or directory
|
||||
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
|
||||
raise DeliveryError("exec owner path is not a regular file/directory")
|
||||
if info.st_uid not in {0, os.getuid()}:
|
||||
> raise DeliveryError("exec owner path has an untrusted owner")
|
||||
E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner
|
||||
|
||||
src/secrets_engine/exec_owner.py:146: DeliveryError
|
||||
_______________ test_config_changed_during_fetch_does_not_launch _______________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...nged_during_fet0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_config_changed_during_fet0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740da8500>
|
||||
|
||||
def test_config_changed_during_fetch_does_not_launch(bound, monkeypatch):
|
||||
data, _, script = bound
|
||||
entry = validate_entry(data)
|
||||
def fetch(*a, **k):
|
||||
script.write_text("changed during fetch")
|
||||
return "synthetic-owner-value"
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch"))
|
||||
> with pytest.raises(DeliveryError, match="digest"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'digest'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner.py:91: AssertionError
|
||||
___________ test_binding_changed_after_approval_refuses_before_fetch ___________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...anged_after_app0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_binding_changed_after_app0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5cbf0>
|
||||
|
||||
def test_binding_changed_after_approval_refuses_before_fetch(bound, monkeypatch):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(data)
|
||||
expected = owner_digest(entry)
|
||||
entry.delivery_config["exec_owner"]["environment"]["LANG"] = "C"
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must not read"))
|
||||
> with pytest.raises(DeliveryError, match="after action admission"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'after action admission'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner.py:101: AssertionError
|
||||
___________ test_companion_value_reaches_bound_child_and_is_redacted ___________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...value_reaches_b0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_value_reaches_b0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ef8b00>
|
||||
capsys = <_pytest.capture.CaptureFixture object at 0x756741efa2a0>
|
||||
|
||||
def test_companion_value_reaches_bound_child_and_is_redacted(bound, monkeypatch, capsys):
|
||||
data, _, script = bound
|
||||
script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10
|
||||
test "$WORKER_TOKEN" = synthetic-worker-value || exit 11
|
||||
read ignored && exit 15
|
||||
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
|
||||
printf 'companion-child-ok\\n'
|
||||
''')
|
||||
import hashlib
|
||||
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(script.read_bytes()).hexdigest()
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
companions = resolve_companions(entry, _lookup(lane))
|
||||
values = {"test-lane": "synthetic-owner-value", "test-worker": "synthetic-worker-value"}
|
||||
seen = []
|
||||
def fetch(client, e, field, **kwargs):
|
||||
seen.append((e.id, field))
|
||||
return values[e.id]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
|
||||
sessions = {}
|
||||
> rc = exec_delivery.exec_with_secret(
|
||||
object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
|
||||
mode="exec-env", expected_owner_digest=owner_digest(entry),
|
||||
companions=companions, companion_sessions=sessions,
|
||||
)
|
||||
|
||||
tests/test_exec_owner_companions.py:55:
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
src/secrets_engine/exec_delivery.py:199: in exec_with_secret
|
||||
binding_digest = validate_delivery_target(entry, field, command, mode)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
src/secrets_engine/exec_owner.py:171: in validate_delivery_target
|
||||
_check_path(Path(binding["cwd"]), directory=True, private=True)
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
|
||||
path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_value_reaches_b0/owner')
|
||||
directory = True, private = True
|
||||
|
||||
def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None:
|
||||
"""No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe."""
|
||||
for candidate in [*reversed(path.parents), path]:
|
||||
info = candidate.lstat()
|
||||
leaf = candidate == path
|
||||
is_dir = not leaf or directory
|
||||
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
|
||||
raise DeliveryError("exec owner path is not a regular file/directory")
|
||||
if info.st_uid not in {0, os.getuid()}:
|
||||
> raise DeliveryError("exec owner path has an untrusted owner")
|
||||
E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner
|
||||
|
||||
src/secrets_engine/exec_owner.py:146: DeliveryError
|
||||
_________________ test_companion_fetch_failure_starts_no_child _________________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...fetch_failure_s0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_fetch_failure_s0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37ec0>
|
||||
|
||||
def test_companion_fetch_failure_starts_no_child(bound, monkeypatch):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
def fetch(client, e, field, **kwargs):
|
||||
if e.id == "test-worker":
|
||||
raise DeliveryError("scoped read failed for lane 'test-worker' (denied or absent)")
|
||||
return "synthetic-owner-value"
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch"))
|
||||
> with pytest.raises(DeliveryError, match="test-worker"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'test-worker'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:77: AssertionError
|
||||
______________ test_each_lane_is_gated_separately_before_backend _______________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...is_gated_separa0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_each_lane_is_gated_separa0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f36990>
|
||||
tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_each_lane_is_gated_separa0')
|
||||
|
||||
def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_path):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
table = {entry.id: entry, lane.id: lane}
|
||||
monkeypatch.setattr(cli, "get_entry", lambda _dir, cid: table[cid])
|
||||
gated = []
|
||||
def gate(cfg, e, action, evidence, *, fields=(), auth_args=None):
|
||||
assert auth_args is args
|
||||
gated.append((e.id, action, fields))
|
||||
if e.id == "test-worker":
|
||||
raise DeliveryError("companion lane denied")
|
||||
return SimpleNamespace(status="approved")
|
||||
monkeypatch.setattr(cli, "_require_lane_approval", gate)
|
||||
monkeypatch.setattr(cli, "require_delivery_state", lambda *a: None)
|
||||
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend after a lane refusal"))
|
||||
command = data["delivery_config"]["exec_owner"]["command"]
|
||||
args = SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env")
|
||||
> with pytest.raises(DeliveryError, match="companion lane denied"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion lane denied'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:166: AssertionError
|
||||
_____________ test_unresolvable_companion_refuses_before_any_gate ______________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...le_companion_re0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_unresolvable_companion_re0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37bf0>
|
||||
tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_unresolvable_companion_re0')
|
||||
|
||||
def test_unresolvable_companion_refuses_before_any_gate(bound, monkeypatch, tmp_path):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
def get(_dir, cid):
|
||||
if cid == entry.id:
|
||||
return entry
|
||||
raise KeyError(cid)
|
||||
monkeypatch.setattr(cli, "get_entry", get)
|
||||
monkeypatch.setattr(cli, "_require_lane_approval", lambda *a, **k: pytest.fail("no gate"))
|
||||
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend"))
|
||||
command = data["delivery_config"]["exec_owner"]["command"]
|
||||
> with pytest.raises(DeliveryError, match="unavailable"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'unavailable'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:182: AssertionError
|
||||
______ test_delivery_rechecks_companion_contract_before_any_read[missing] ______
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani0/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5c860>
|
||||
change = 'missing'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
_______ test_delivery_rechecks_companion_contract_before_any_read[extra] _______
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani1/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani1/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5d9a0>
|
||||
change = 'extra'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
________ test_delivery_rechecks_companion_contract_before_any_read[env] ________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani2/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani2/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec4800>
|
||||
change = 'env'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
_______ test_delivery_rechecks_companion_contract_before_any_read[field] _______
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani3/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani3/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec4260>
|
||||
change = 'field'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
_______ test_delivery_rechecks_companion_contract_before_any_read[lane] ________
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani4/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani4/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec7b60>
|
||||
change = 'lane'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
______ test_delivery_rechecks_companion_contract_before_any_read[consent] ______
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani5/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani5/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37b60>
|
||||
change = 'consent'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
_______ test_delivery_rechecks_companion_contract_before_any_read[stage] _______
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani6/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani6/owner/owner.sh'))
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f34200>
|
||||
change = 'stage'
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"])
|
||||
def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane_data = _companion()
|
||||
if change == "consent":
|
||||
lane_data["delivery_config"] = {}
|
||||
elif change == "stage":
|
||||
lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "lane":
|
||||
lane_data["id"] = "another-worker"
|
||||
lane = validate_entry(lane_data)
|
||||
companions = [(lane, "worker_token", "WORKER_TOKEN")]
|
||||
if change == "missing": companions = []
|
||||
elif change == "extra": companions *= 2
|
||||
elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")]
|
||||
elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read"))
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child"))
|
||||
> with pytest.raises(DeliveryError, match="companion"):
|
||||
E AssertionError: Regex pattern did not match.
|
||||
E Expected regex: 'companion'
|
||||
E Actual message: 'exec owner path has an untrusted owner'
|
||||
|
||||
tests/test_exec_owner_companions.py:223: AssertionError
|
||||
_ test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child _
|
||||
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'prod', ...}, PosixPath('/tmp/pytest-of-worsch/py...handler_rejects0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0/owner/owner.sh'))
|
||||
tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0')
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741eef5f0>
|
||||
|
||||
def test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child(bound, tmp_path, monkeypatch):
|
||||
raw, _, _ = bound
|
||||
raw["stage"] = "prod"
|
||||
raw["approval"].update(model="decision", authorization_id=APPROVAL_ID,
|
||||
purpose="contract-test", human_control=True)
|
||||
entry = validate_entry(raw)
|
||||
cfg = replace(_config(tmp_path), approval_url="https://approval.example",
|
||||
approval_token_file=_token(tmp_path), authorization_subject_id="user:alice",
|
||||
authorization_subject_type="Human")
|
||||
claim = _served(entry, action="exec")
|
||||
monkeypatch.setattr(cli, "get_entry", lambda *a: entry)
|
||||
monkeypatch.setattr(cli, "authorize_action", lambda *a, **k: authorize_action(*a, **k,
|
||||
opener=_opener(claim), pdp_opener=lambda *a, **k: pytest.fail("must not check")))
|
||||
for name in ("require_production_consume", "_open_backend"):
|
||||
monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("must not consume or open backend"))
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
> cli.cmd_exec(cfg, SimpleNamespace(catalog=entry.id, field="api_token", mode="exec-env",
|
||||
command=raw["delivery_config"]["exec_owner"]["command"]))
|
||||
|
||||
tests/test_human_control.py:161:
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
src/secrets_engine/cli.py:569: in cmd_exec
|
||||
owner_digest = validate_delivery_target(entry, field, args.command, args.mode)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
src/secrets_engine/exec_owner.py:171: in validate_delivery_target
|
||||
_check_path(Path(binding["cwd"]), directory=True, private=True)
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
|
||||
path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0/owner')
|
||||
directory = True, private = True
|
||||
|
||||
def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None:
|
||||
"""No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe."""
|
||||
for candidate in [*reversed(path.parents), path]:
|
||||
info = candidate.lstat()
|
||||
leaf = candidate == path
|
||||
is_dir = not leaf or directory
|
||||
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
|
||||
raise DeliveryError("exec owner path is not a regular file/directory")
|
||||
if info.st_uid not in {0, os.getuid()}:
|
||||
> raise DeliveryError("exec owner path has an untrusted owner")
|
||||
E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner
|
||||
|
||||
src/secrets_engine/exec_owner.py:146: DeliveryError
|
||||
_____________________ test_two_lanes_reach_one_bound_owner _____________________
|
||||
|
||||
bao_dev = OpenBaoClient(addr='http://127.0.0.1:55527', token='se-test-root', bao_bin='/home/worsch/.local/bin/bao')
|
||||
bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...reach_one_bound0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0/owner/owner.sh'))
|
||||
tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0')
|
||||
capsys = <_pytest.capture.CaptureFixture object at 0x756740d5fd70>
|
||||
|
||||
def test_two_lanes_reach_one_bound_owner(bao_dev, bound, tmp_path, capsys):
|
||||
data, _, script = bound
|
||||
script.write_text('''test "$API_TOKEN" = primary-integration-value || exit 10
|
||||
test "$WORKER_TOKEN" = worker-integration-value || exit 11
|
||||
test -z "$BAO_TOKEN" || exit 12
|
||||
read ignored && exit 15
|
||||
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
|
||||
printf 'two-lane-child-ok\\n'
|
||||
''')
|
||||
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(
|
||||
script.read_bytes()
|
||||
).hexdigest()
|
||||
entry = validate_entry(_with_companion(copy.deepcopy(data)))
|
||||
lane = validate_entry(_companion())
|
||||
_provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path)
|
||||
_provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path)
|
||||
|
||||
sessions, primary_session = {}, {}
|
||||
> rc = exec_with_secret(
|
||||
bao_dev, entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
|
||||
mode="exec-env", session_evidence=primary_session,
|
||||
expected_owner_digest=owner_digest(entry),
|
||||
companions=resolve_companions(entry, _lookup(lane)), companion_sessions=sessions,
|
||||
)
|
||||
|
||||
tests/test_integration_companions.py:47:
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
src/secrets_engine/exec_delivery.py:199: in exec_with_secret
|
||||
binding_digest = validate_delivery_target(entry, field, command, mode)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
src/secrets_engine/exec_owner.py:171: in validate_delivery_target
|
||||
_check_path(Path(binding["cwd"]), directory=True, private=True)
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
|
||||
path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0/owner')
|
||||
directory = True, private = True
|
||||
|
||||
def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None:
|
||||
"""No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe."""
|
||||
for candidate in [*reversed(path.parents), path]:
|
||||
info = candidate.lstat()
|
||||
leaf = candidate == path
|
||||
is_dir = not leaf or directory
|
||||
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
|
||||
raise DeliveryError("exec owner path is not a regular file/directory")
|
||||
if info.st_uid not in {0, os.getuid()}:
|
||||
> raise DeliveryError("exec owner path has an untrusted owner")
|
||||
E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner
|
||||
|
||||
src/secrets_engine/exec_owner.py:146: DeliveryError
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment
|
||||
FAILED tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch
|
||||
FAILED tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch
|
||||
FAILED tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted
|
||||
FAILED tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child
|
||||
FAILED tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend
|
||||
FAILED tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]
|
||||
FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]
|
||||
FAILED tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child
|
||||
FAILED tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner
|
||||
16 failed, 784 passed, 7 skipped in 55.47s
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
diff --git a/.custodian-brief.md b/.custodian-brief.md
|
||||
index 0cb912a..ba794c8 100644
|
||||
--- a/.custodian-brief.md
|
||||
+++ b/.custodian-brief.md
|
||||
@@ -2,7 +2,7 @@
|
||||
# Custodian Brief — secrets-engine
|
||||
|
||||
**Domain:** infotech
|
||||
-**Last synced:** 2026-10-11 17:52 UTC
|
||||
+**Last synced:** 2026-10-16 05:55 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
@@ -11,8 +11,24 @@
|
||||
Progress: 1/3 done | workplan_id: `5e38aecc-85ca-5f55-94c6-9fbb08604819`
|
||||
|
||||
**Open tasks:**
|
||||
+- ! Define the exact mediated execution contract `b2f0d462`
|
||||
+ *(wait: Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred.)*
|
||||
- ! Implement and prove one native metadata action `5f8f7c41`
|
||||
-- ► Define the exact mediated execution contract `b2f0d462`
|
||||
+
|
||||
+### Constrain PDP transport and authenticate decision responses
|
||||
+Progress: 1/3 done | workplan_id: `7252469d-d7a5-5c18-8911-b511b8c6a736`
|
||||
+
|
||||
+**Open tasks:**
|
||||
+- ! Verify the admitted native consumer and rotation path `85aa4c78`
|
||||
+ *(wait: Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet.)*
|
||||
+- ► Implement independently anchored responder authentication `877782e5`
|
||||
+
|
||||
+### Production-safe provisioning, authorization, and lifecycle hardening
|
||||
+Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3`
|
||||
+
|
||||
+**Open tasks:**
|
||||
+- ! Resume native production lane adoption `a0a1dd92`
|
||||
+ *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)*
|
||||
|
||||
### Activate native Claude credential delivery for Glas
|
||||
Progress: 2/3 done | workplan_id: `40ccc3b4-d046-5a58-8649-e7935f45c974`
|
||||
@@ -26,27 +42,20 @@ Progress: 4/6 done | workplan_id: `31f7f8ea-7f73-516c-8877-f03a13f1db82`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Stage live apply and verification `fb103f1e`
|
||||
- *(wait: OpenRouter key-check native acceptance is recorded in SECRETS-WP-0010-T03. Other lanes still need exact approvals, scoped attended apply and per-lane positive/negative/health/revocation evidence.)*
|
||||
+ *(wait: Owner work first: publish a reviewed scoped-apply command per lane. Per-lane approvals are no longer needed (D1 standing rule); each lane with a command enters the attended window.)*
|
||||
- ! Reconcile routing ownership and retire interim proxies `1431edae`
|
||||
*(wait: Needs owner-agreed routing/proxy retirement for each verified lane and custody disposition of the legacy npm pointer; a single approved OpenRouter key-check does not authorize broader routing cutover.)*
|
||||
|
||||
-### Production-safe provisioning, authorization, and lifecycle hardening
|
||||
-Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3`
|
||||
-
|
||||
-**Open tasks:**
|
||||
-- ! Resume native production lane adoption `a0a1dd92`
|
||||
- *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)*
|
||||
-
|
||||
### Evolve the Lifecycle engine to the accepted security layer model
|
||||
Progress: 5/6 done | workplan_id: `9c9e5164-b2f5-5ea2-a557-5368d65e9fe0`
|
||||
|
||||
**Open tasks:**
|
||||
- ! No standing engine credential `d7bc8bdc`
|
||||
- *(wait: Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env.)*
|
||||
+ *(wait: Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback.)*
|
||||
|
||||
## Inbox Hygiene
|
||||
|
||||
-**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start.
|
||||
+**Stale unread:** 3 message(s) older than 3 day(s) — triage at session start.
|
||||
**Missing thread_id:** 2 unread message(s) lack supersession chains.
|
||||
|
||||
---
|
||||
diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md
|
||||
index 70e5831..4d1cfb6 100644
|
||||
--- a/WORK-RECORDS.md
|
||||
+++ b/WORK-RECORDS.md
|
||||
@@ -22,6 +22,7 @@
|
||||
| workplan | SECRETS-WP-0011 | finished | — | workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md |
|
||||
| workplan | SECRETS-WP-0012 | finished | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md |
|
||||
| workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||
+| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
||||
| task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md |
|
||||
| task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
|
||||
| task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md |
|
||||
@@ -83,9 +84,12 @@
|
||||
| task | SECRETS-WP-0012-T02 | cancel | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md |
|
||||
| task | SECRETS-WP-0012-T03 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md |
|
||||
| task | SECRETS-WP-0012-T04 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md |
|
||||
-| task | SECRETS-WP-0013-T01 | progress | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||
+| task | SECRETS-WP-0013-T01 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||
| task | SECRETS-WP-0013-T02 | done | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||
| task | SECRETS-WP-0013-T03 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||
+| task | SECRETS-WP-0014-T01 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
||||
+| task | SECRETS-WP-0014-T02 | progress | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
||||
+| task | SECRETS-WP-0014-T03 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
||||
| intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md |
|
||||
| intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md |
|
||||
| intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md |
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Secrets trusted-successor source review R1
|
||||
|
||||
Base: canonical Secrets `5a2099f400fb0c97fed2d991832c0e31513bbdfb`.
|
||||
Source proposal only. Canonical owner checkout and production installed tools,
|
||||
credentials, configuration, keys, policy, grants and native state are unchanged.
|
||||
Preexisting owner metadata diff is retained separately and excluded from patch.
|
||||
|
||||
The exact producer API/sign files match frozen Access B2/B3 R1 SHA
|
||||
`6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1`.
|
||||
No signature or canonicalization implementation was forked. Wrapper calls
|
||||
producer DecodeEnvelope. Exact authenticated typed JSON is retained through
|
||||
strict CAS, rather than reconstructing signed bytes from Python numbers.
|
||||
Snapshots additionally compare parsed types via JSON serialization (which is
|
||||
never signing input), refusing boolean/integer/float substitution. The helper
|
||||
runs with empty environment. Direct authentication now also rejects malformed,
|
||||
duplicate, nonfinite and invalid UTF-8 inputs before the producer process.
|
||||
|
||||
Signed optional source observation is independently signature/policy-pin bound.
|
||||
Strict handoff checks nonempty source/revision, digest syntax, exact native act
|
||||
digest, observation <= validation <= current upper bound, current freshness,
|
||||
and decision expiry <= source deadline. It supplements original carried claim;
|
||||
original request/context/freshness, exact descriptor and caller, native and full
|
||||
PDP joins, deadlines and once-only CAS remain required. Source identifiers are
|
||||
not independently admitted by a fixture, and raw claim-byte digest is not
|
||||
recomputed from a parsed claim with different formatting.
|
||||
|
||||
103 focused signature/strict tests pass on final runtime source; 192 combined
|
||||
focused tests passed before the final direct-parser strengthening (unchanged
|
||||
strict signature suite rerun on final source). Seven actual offline bundle tests
|
||||
pass, including deterministic builds/fresh installation/signed consumer and
|
||||
missing or tampered helper refusals. No test signer is installed. Go packages
|
||||
build/test and exact producer source checker pass. Existing legacy producer
|
||||
signed rotate fixture remains accepted; fourteen actual Approval owner native
|
||||
vectors verify and each tampered envelope refuses. These signature vectors do
|
||||
not broaden request numeric admission.
|
||||
|
||||
Actual regenerated Approval App HTTPS -> frozen Access production serve v2
|
||||
ALLOW -> Secrets signature boundary -> exact-byte reauthentication succeeds;
|
||||
actual provider revocation then refuses, without a new durable record.
|
||||
`actual-source-signature/receipt.json` retains artifacts/hashes. That separate
|
||||
synthetic request is not accepted as a strict prepared task or estate admission.
|
||||
|
||||
REMAINING POSITIVE STRICT BLOCKER: actual Secrets builder emits no
|
||||
`service_actor_binding`, whereas v2 service input and policy require that exact
|
||||
five-field public declaration before approval issuance. Current builder's actual
|
||||
request therefore refuses. No injected context, callback, or equality relaxation
|
||||
hides this mismatch. Parent has been asked to scope the narrow owner mapping
|
||||
addition. This R1 package is ready for verifier-source review, not final v2 strict
|
||||
positive handoff acceptance. Native trust/custody/runtime blockers remain open.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Secrets successor R2 source disposition
|
||||
|
||||
R1 artifacts remain immutable. The full patch applies to canonical Secrets
|
||||
5a2099f400fb0c97fed2d991832c0e31513bbdfb; the delta applies after frozen R1.
|
||||
Exact hashes and source preimages are retained in manifest-r2.json/preimages.
|
||||
|
||||
The execution builder adds only the fixed Access DeclaredServiceBinding public
|
||||
mapping after validating an actual ExecutionContract, prod/platform lane and exact
|
||||
secrets-engine service subject. It sets subject/resource platform tenants before
|
||||
approval issue, matching existing Access normalization. The strict exact subject
|
||||
check accepts only that complete fixed subject. Other subjects refuse; lifecycle
|
||||
requests gain no declaration or tenant change. This declaration is caller-supplied
|
||||
public input, never authentication evidence; the independent signed enforced
|
||||
transport caller remains mandatory. No arbitrary enrichment API or schema exists.
|
||||
|
||||
196 focused tests and 7 actual offline reproducible build/install tests pass on
|
||||
final R2 source. Actual source checker matches frozen producer bytes. The real
|
||||
builder -> actual Approval App/SQLite with separate synthetic human entry ->
|
||||
Access production serve with actual TokenReview and separately configured source
|
||||
reader -> strict Secrets authorize -> exact CAS -> independent actual
|
||||
ExecutionConfirmationClient GET succeeds. Six negative cases refuse: absent
|
||||
service declaration, changed descriptor, changed native target, expired original
|
||||
claim despite fresh provider, signature tamper, and prepared replay. After CAS,
|
||||
Access also refuses the carried claim without writing another durable record.
|
||||
No monkeypatch, injected approval context or authorization callback substitutes
|
||||
for production request/claim/signature/handoff paths. Original context is exact.
|
||||
|
||||
The proof uses fixed whole-second operation deadline inside v2's15-second allow
|
||||
TTL, because consumer requires operation deadline<=decision expiry while producer
|
||||
caps/floors expiry at that deadline. Fractional deadlines or longer operations
|
||||
continue to refuse; this source change does not waive or extend any authority.
|
||||
No native metadata task, package install outside disposable prefix, production
|
||||
trust/key/custody/grant/policy admission or live protected effect occurred.
|
||||
|
||||
The first actual proof reached strict CAS then failed a test-only GET assertion
|
||||
using a nonexistent top-level provider key. Failure is preserved in
|
||||
actual-strict-attempt1-r2.txt; correction uses actual owner ConfirmationClient.
|
||||
Final source proof and six negative cases pass in actual-strict-test-r2.txt and
|
||||
actual-strict-r2/. Prior R1 blocker report remains dated historical evidence;
|
||||
this R2 resolves only that source mapping gap, not native acceptance.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Secrets temporal successor R3 review
|
||||
|
||||
R1/R2 patches, manifests, tests and proof evidence remain immutable. This delta
|
||||
changes only contract/workplan/verifier-provenance documentation; all Python and
|
||||
Go runtime bytes match frozen Secrets R2 exactly (manifest-r3.json records each
|
||||
runtime hash). Exact vendored producer API/sign source also matches temporal
|
||||
Access R2; there is no canonicalizer refresh or algorithm change.
|
||||
|
||||
The contract now describes the actual fixed backend_budget.ttl_seconds request,
|
||||
not a dynamic min calculation. Before issuance the native owner requires
|
||||
remaining>=declaredTTL+5s; requested/explicit maximum TTL equal the immutable
|
||||
budget and returned TTL must be positive and <=budget. Operation deadline remains
|
||||
bounded by decision/approval/caller and original/fresh source authority; token
|
||||
expiry may precede operation deadline, with no retry/remint. A120s budget plus5s
|
||||
cannot fit the120s hard operation cap and refuses. These are conservative bounds,
|
||||
not authority extensions. Platform owner coordination was requested.
|
||||
|
||||
Actual ApprovalApp -> Access temporalR2 production serve -> strict Secrets prepare
|
||||
and CAS -> independent actual Approval ExecutionConfirmationClient GET passes
|
||||
using explicit fixture freshness120/backendTTL30/operationdeadline60. Returned
|
||||
signed expiry equals immutable operation deadline as an aware instant (Z and
|
||||
+00:00 presentation differ). The original subsecond-context, full/native/approval
|
||||
and submitted digest checks remain unchanged. Six negatives plus Access refusal
|
||||
after consume pass. Seven actual offline bundle/install tests pass.
|
||||
|
||||
An unresolved precision seam is retained: Approval consumed_at is whole-second
|
||||
truncated, whereas Access temporalR2 signs subsecond not_before. Fast real CAS
|
||||
can fail the strict receipt-before-start guard; actual-strict-fast-cas-refusal-r3.txt
|
||||
retains this. Positive proof sequences CAS using real elapsed time until the
|
||||
signed start's next whole second (<1s); it does not inject time or relax checks.
|
||||
This establishes reachable positive source interoperability, not reliable fast-CAS
|
||||
admission. Root and Access/Approval owners were notified; runtime is frozen until
|
||||
they resolve the precision contract. The first R3 proof additionally failed a
|
||||
test-only lexical Z/+00 comparison; preserved separately and corrected to aware
|
||||
instant equality. No live credentials/effects/trust/policy/native admission.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Secrets retained-registry source R4 review
|
||||
|
||||
R1/R2/R3 source packages remain immutable. R4 full applies to canonical5a2099f;
|
||||
over-R3 delta applies only after frozen R3. Manifest records exact patch/source
|
||||
hashes, producer R3 full46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6,
|
||||
and unchanged admitted-policy proposal digest de5683e5... (not native admission).
|
||||
Exact producer API/sign snapshot is unchanged; no cryptography/canonicalizer fork.
|
||||
|
||||
prepare_task_approval(cfg,entry,execution_contract=typed_contract) uses actual
|
||||
existing signed Check with no approval claim. It accepts DENY only, authenticates
|
||||
independently pinned signer/evaluator/mode/package/version/digest, checks original
|
||||
request and type-sensitive immutable context/submitted digest, producer evaluated
|
||||
self-digest, explicit claimfree approval-free digest, task/service/native/caller
|
||||
correspondence, live original operation/caller bounds, nonfuture decision time,
|
||||
and signed registry digest syntax. It never consumes or authorizes execution.
|
||||
Its detached original_request, native_binding, approval_binding_digest,
|
||||
submitted_request_digest, decision_id, execution_contract and
|
||||
registry_snapshot_digest are public preapproval metadata. Normal Approval issue
|
||||
uses normalized native act and evaluated approval-free digest, never a local
|
||||
registry reconstruction. Final authorization preserves the original proposal and
|
||||
attaches only the original issued claim; Access renormalizes and source-joins.
|
||||
|
||||
No independent normalization TTL is invented: DENY is not authority. An old
|
||||
normalization cannot replace final signed ALLOW/current source verification.
|
||||
Original operation/caller deadlines still apply. Unit case demonstrates an old
|
||||
valid timestamp produces only metadata and cannot consume; actual retained
|
||||
registry drift source proof refuses final Check with no new ALLOW record/CAS and
|
||||
unconsumed Approval. Full final proof awaits owner timestamp repair; prototype
|
||||
wallclock success is not precision acceptance. Registry fileSHA and producer
|
||||
canonical registry digest are distinct evidence fields, not interchangeable pins.
|
||||
|
||||
Shared task identity validation now checks requested resource-attribute subsets
|
||||
with type-sensitive JSON semantic comparisons, preserving legitimate extra
|
||||
registry facts while refusing bool/int/float substitutions. Original signed
|
||||
context/descriptor checks are also type-sensitive. This JSON comparison never
|
||||
supplies signature bytes; exact producer-authenticated JSON remains reverified.
|
||||
|
||||
213 focused checks and7 actual offline bundle tests pass. Full suite784pass,
|
||||
16fail,7skip; exact16failure list matches retained previous handoff baseline
|
||||
(ownership guards on sandbox/tmp ancestry), with no new failures. Full logs and
|
||||
explicit list comparison retained. No guard is relaxed to obtain a green suite.
|
||||
Canonical checkout, installed tools, production keys/config/grants/policy and
|
||||
native state are untouched. No actual protected native effect is claimed.
|
||||
|
||||
|
||||
Subsequent final cross-owner proof: frozen Access R3 and Approval precision patch74eb0ff67d14508c2a2cee14d055790321ee171a96d5290ad6d037ce8f61dbcd resolve the pending provider dependency. Actual retained-registry signed-DENY preapproval, human approval, registry drift refusal before CAS, restored signed ALLOW, immediate strict CAS and independent GET pass without sleep/backdating/waiver. Precise durable consumption instant 2026-10-16T15:05:44.235319+00:00 is independently confirmed. Original signed DENY/ALLOW records and schema-valid synthetic public trust replay offline. Exact final dependency/proof hashes are in verification-final-r4.json. Earlier pending/fast-CAS observations remain historical evidence; no native runtime/trust/grant/policy adoption is inferred. Source R4 and all prior source patch hashes remain unchanged.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
....... [100%]
|
||||
7 passed in 6.17s
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
....... [100%]
|
||||
7 passed in 4.03s
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
....... [100%]
|
||||
7 passed in 8.91s
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
....... [100%]
|
||||
7 passed in 3.92s
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
............ [100%]
|
||||
12 passed in 1.74s
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
..................................................................... [100%]
|
||||
69 passed in 8.59s
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Strict Secrets service task-execution handoff
|
||||
|
||||
This is a narrow source seam for existing SECRETS-WP-0013, not a native grant or
|
||||
an execute-ccr CLI. Both Access runtime-deny policy variants remain unchanged.
|
||||
The proposed service subject/transport mapping and the complete native executor
|
||||
still need independent owner admission. Positive source fixtures do not admit
|
||||
that mapping, a policy, a caller, a production key or an OpenBao entitlement.
|
||||
|
||||
Platform's trusted owner process calls:
|
||||
|
||||
```python
|
||||
from secrets_engine.task_execution_handoff import (
|
||||
authorize_task_execution, consume_task_execution,
|
||||
)
|
||||
authorization = authorize_task_execution(cfg, entry, execution_contract=contract)
|
||||
record = authorization.as_owner_handoff()
|
||||
# Independently bind exact owner Plan/descriptor; reserve durable one-shot journal.
|
||||
# Persist pending strict-CAS intent before calling:
|
||||
consumption = consume_task_execution(cfg, authorization)
|
||||
# Independently GET-confirm exact original consumption with Approval Engine's
|
||||
# ExecutionConfirmationClient before journaling consume-confirmed/native work.
|
||||
```
|
||||
|
||||
Preparation uses the existing empty fields/policy_targets/auth_targets defaults
|
||||
and existing build_action_request/claim/Check validators. No caller-selected
|
||||
transport, authorizer callback, policy result or raw handoff mapping is accepted.
|
||||
The normal authorizer retains detached serialized request, claim and authenticated
|
||||
Go-typed envelope snapshots; its existing evidence output does not expose them.
|
||||
The narrow adapter reuses the validators, rather than evaluating a policy.
|
||||
|
||||
The adapter checks the exact descriptor in the signed context, full evaluated and
|
||||
submitted request digests, approval-free digest, exact five-field native act and
|
||||
native binding digest, service subject/resource/tenant correspondence and original
|
||||
decision/approval/deadline bounds. Initiating/represented subject is strictly
|
||||
`secrets-engine` of type `service`; the execution actor is `service:secrets-engine`.
|
||||
Signed caller provenance must report `enforce`, the proposed exact Secrets
|
||||
ServiceAccount principal, `flex-auth` audience and live expiry bounding the
|
||||
operation deadline. This consumes the signed PDP's caller attestation; it does
|
||||
not independently perform TokenReview or promote the proposed server mapping.
|
||||
Human/delegation/projected-identity flags cannot substitute for this service seam.
|
||||
No service_actor_binding request propagation or source-policy activation is added.
|
||||
|
||||
`as_owner_handoff()` returns a detached non-secret mapping with:
|
||||
|
||||
- execution_contract
|
||||
- approval_id, request_digest, decision_id
|
||||
- native_binding, approval_binding_digest, submitted_request_digest
|
||||
- decision_not_before, decision_expires_at, approval_expires_at
|
||||
|
||||
The exact native binding is derived from the signed evaluated request using the
|
||||
published Approval five-field mapping, and is compared to the served claim.
|
||||
Descriptor/CCR/grant/source/catalog/target correspondence is retained end to end;
|
||||
Platform still independently compiles its owner Plan and grant from owner bytes.
|
||||
The mapping is evidence for that join, never a transferable bearer authorization.
|
||||
|
||||
Consumption accepts only the module-created opaque prepared object in that same
|
||||
trusted owner process. It fixes source/config coordinates, reauthenticates using
|
||||
currently admitted responder keys, and rechecks claim/decision/descriptor/caller
|
||||
freshness before reading the CAS credential. Each object is reserved once before
|
||||
CAS/revalidation and cannot retry after any failure or unknown outcome. Its
|
||||
process-local guard complements Platform's durable cross-process journal; it is
|
||||
not a replacement for that journal or process isolation.
|
||||
|
||||
The strict response path uses the existing CAS transport but rejects duplicate,
|
||||
nonfinite or excessively deep JSON, missing/mismatched approval and decision IDs,
|
||||
wrong digest/status, missing or nonboolean idempotence, historical idempotent
|
||||
success, verdict fields and absent/naive/invalid consumption timestamps. The
|
||||
consumption instant and post-CAS freshness must remain inside the original
|
||||
bounds. No field is coerced or filled from the submitted binding. A late/uncertain
|
||||
CAS may have occurred; it never licenses retry or protected native work.
|
||||
|
||||
The returned immutable TaskExecutionConsumption contains exact approval_id,
|
||||
request_digest, decision_id, consumed_at and explicit false idempotent. It proves
|
||||
only the strict CAS response observed by Secrets. Platform must independently
|
||||
confirm original durable consumption using Approval's real provider client,
|
||||
journal it, then recheck its exact Plan, owner sources, grant and deadlines before
|
||||
any native operation. Neither a dataclass nor a caller-supplied boolean admits
|
||||
execution. Existing generic CLI CAS behavior is otherwise unchanged.
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
# Task-scoped protected OpenBao execution — owner design
|
||||
|
||||
Design ID: `SECRETS-EXEC-20261011-v1`. Status: proposed, source preparation only.
|
||||
Owning draft: `SECRETS-WP-0013`; adoption remains `SECRETS-WP-0008-T06`.
|
||||
No native entitlement, owner assent or founder go is established here.
|
||||
|
||||
## The concrete gap and immediate correction
|
||||
|
||||
`cli._require_lane_approval` consumes the approved action before `_open_backend`.
|
||||
`engine_auth.select_engine_auth` selects service-jwt for auto whenever its login
|
||||
contract is configured. CCR-2026-0038 grants a 300-second, eight-use token with
|
||||
exactly `secrets-engine-login-self`. It explicitly denies custody data/metadata,
|
||||
policy/auth writes and child-token creation. Thus a valid approval can be
|
||||
consumed before an ordinary apply/provision/exec fails for lack of backend power.
|
||||
|
||||
The staged correction checks **local execution readiness before claim/PDP/CAS**
|
||||
for every caller about to access protected OpenBao work. Until a real reviewed
|
||||
execution backend ships, service-jwt is rejected for those callers. Explicit
|
||||
bootstrap/env providers retain their existing approval/stance gates; no fallback
|
||||
or default changes. Service-auth login/self probes remain available. Local-only
|
||||
compromise/reactivate records retain their existing path. A file/boolean claiming
|
||||
entitlement cannot make the guard pass. Tests exercise the actual apply handler,
|
||||
asserting zero claim/PDP/consume/login/apply calls for configured auto and missing
|
||||
explicit service-jwt entitlement. This correction fixes early consumption; it
|
||||
does **not** implement task-scoped protected work or complete T06.
|
||||
|
||||
## One initial execution surface
|
||||
|
||||
Implement `secrets-engine execute-ccr --catalog <id> --stage prod --request <file>`
|
||||
as an explicit, metadata-only action. It admits one owner-reviewed Platform
|
||||
`workload-kv-read` CCR with already-existing KV and auth mounts, exact generated
|
||||
policy and exact Kubernetes or existing netkingdom OIDC role. It does not call
|
||||
generic `apply_plan`: that function can manage AppRoles/mounts, which differs
|
||||
from Platform's typed CCR applier. Provision, exec delivery, rotation, KV reads,
|
||||
SecretIDs, token creation by the caller, mounting and arbitrary CLI commands
|
||||
remain outside this first entitlement. Their backend obligations require their
|
||||
own later exact action contracts, not reuse of this metadata allowance.
|
||||
|
||||
CCR38 and its role/policy stay byte-for-byte unchanged. The engine identifies
|
||||
itself through that login-only service session; authentication does not create
|
||||
authority for the metadata action. A new separately reviewed Platform
|
||||
`task-execution-grant` CCR admits the founder-owned execution mediator and its
|
||||
temporary exact two-path task token. Existing applied CCRs, including CCR38,
|
||||
cannot be replayed to manufacture new native work. A real unexecuted, separately
|
||||
approved metadata CCR and native success/negative/cleanup receipts are needed
|
||||
for the first proof; none is selected or approved by this design.
|
||||
|
||||
## Exact request binding
|
||||
|
||||
Use `authorization.build_action_request(entry, 'execute-ccr', ...)` with the
|
||||
existing canonical digest rules. `apply` is a distinct legacy action: its v2
|
||||
policy ignores this metadata contract and must never stand in for execute-ccr.
|
||||
Add a reviewed `context.execution_contract` object
|
||||
before observing the claim or calling Check. Its fields are:
|
||||
|
||||
| Field | Binding |
|
||||
| --- | --- |
|
||||
| contract_version | `SECRETS-EXEC-20261011-v1` |
|
||||
| operation_id | Owner-issued UUID, part of the request digest |
|
||||
| operation | `metadata-apply-ccr`, closed enum |
|
||||
| execution_actor | Verified `service:secrets-engine`, separate from initiating subject |
|
||||
| ccr_id / ccr_immutable_sha256 | Exact unapplied owner CCR ID and immutable content digest |
|
||||
| execution_grant_ccr_id / grant_immutable_sha256 | Exact reviewed task-token materialization and cleanup contract |
|
||||
| catalog_sha256 | Exact non-secret approved catalog target specification |
|
||||
| source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source |
|
||||
| backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity |
|
||||
| operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body |
|
||||
| operation_deadline | Upper bound no later than approval, PDP or task-token expiry |
|
||||
| backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses |
|
||||
| expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents |
|
||||
|
||||
The action subject/type/purpose/tenant are the actual initiating principal's
|
||||
verified identity and existing catalog purpose, not inferred from engine service
|
||||
identity or arbitrary environment fields. Founder-attended invocation names that
|
||||
principal and the representation explicitly. The mediator refuses absent owner
|
||||
identity correspondence; a caller-authored subject string is not evidence.
|
||||
|
||||
The signed/served approval must bind the same approval-free execution context;
|
||||
the PDP must return the exact canonical request and matching approval digest.
|
||||
Publish and pin the updated access-engine policy package/version that evaluates
|
||||
this context; an older package accepting only a catalog ID cannot authorize it.
|
||||
No package default or implicit semantic acceptance is assumed. Approval identity,
|
||||
decision ID and canonical request digest travel together in a non-secret request
|
||||
descriptor. They are provenance until freshly validated and CAS-consumed.
|
||||
|
||||
CCR immutable digests exclude only explicitly owner-defined append-only review/
|
||||
evidence fields; policy bytes, role bytes, typed mutation body, target coordinates,
|
||||
grant/deadline and source revisions are included. Approval IDs added after
|
||||
issuance do not change their own binding. The mediator independently reconstructs
|
||||
all operation bodies from owner sources and compares the resulting digest list.
|
||||
It accepts no caller ACL template, path override, shell argv or raw JSON body.
|
||||
|
||||
## Execution mediation and consume ordering
|
||||
|
||||
Use the existing attended Platform front door, invoked by the founder, to run a
|
||||
new fixed Platform owner mediator. No daemon, broad new service or new bearer
|
||||
delivery to agents is required. The caller engine prepares a non-secret request
|
||||
and receives a non-secret receipt. The founder-held admin credential exists only
|
||||
inside the reviewed Platform child, through the existing attended route. It is
|
||||
never exported to the caller. Neither local receipt files nor user-authored
|
||||
`approved` labels authorize the child.
|
||||
|
||||
The Platform child runs the reviewed Secrets Engine authorization/consume code
|
||||
at the last responsible execution boundary; the CLI preparation path does not
|
||||
consume early and then hand an untrusted receipt to a more privileged process.
|
||||
The backend mediation contract must preserve engine ownership of lifecycle
|
||||
validation while Platform owns its OpenBao credential and typed native primitive.
|
||||
|
||||
1. Load pinned sources and exact non-secret request; check typed CCR/grant owner
|
||||
approval, catalog/subject/operation correspondence, source/state expectations,
|
||||
owner-only execution state directory, supported method/body allowlist and
|
||||
local clock/deadline. No protected OpenBao call occurs yet.
|
||||
2. Atomically reserve the operation in a dedicated engine execution ledger,
|
||||
uniquely keyed by `approval_id + request_digest`; the operation UUID is inside
|
||||
the digest. Bind the authenticated caller, sources and deadline. This is
|
||||
execution state, not the audit outbox or a local evidence-presence gate.
|
||||
Multiworker locking uses SQLite `BEGIN IMMEDIATE` and uniqueness constraints;
|
||||
only the reservation creator may proceed.
|
||||
3. Use `authorize_action` / approval-claim validation / `check_decision` / exact
|
||||
envelope validation, with pinned policy package and trusted application time.
|
||||
Recheck freshness after network calls. Require served human control for the
|
||||
production grant; missing claims, unreachable PDP or mismatched binding refuse.
|
||||
4. Call `require_production_consume` once for that exact binding. Require
|
||||
confirmed matching consumption. Persist `consume-confirmed` before any
|
||||
protected OpenBao login, custody read, capability check or mutation. A consume
|
||||
timeout is `unknown`; never treat it as absence or automatically retry.
|
||||
A consumed/idempotent response without the same live local admission is not
|
||||
permission to execute again. Retrying a completed operation returns only its
|
||||
existing receipt; an incomplete/unknown operation requires owner reconciliation
|
||||
and a fresh exact approval for any additional backend action.
|
||||
5. After consume, perform the existing attended in-memory client-secret source
|
||||
and CCR38 service login without provider fallback. Verify its exact login-self
|
||||
policy/TTL/uses and service identity; keep this session self-only. Failure
|
||||
consumes the trial without metadata effects; no implicit re-login or retry.
|
||||
6. In the Platform child, use the separately authorized founder-held identity
|
||||
only to materialize the reviewed exact ephemeral task policy/token. OpenBao's
|
||||
token parent remains platform-owned; the CCR38 token cannot mint it. The grant
|
||||
binds every policy/token create/revoke/delete step to this operation and its
|
||||
cleanup. Do not give a standing `sys/policies/acl/*`, `auth/*/role/*` or
|
||||
`auth/token/create` grant to the engine service.
|
||||
7. Recheck decision, approval and operation deadlines before **each** protected
|
||||
request. The task token is nonrenewable, no default/identity policies and
|
||||
grants only read/create/update on the one approved ACL policy path and the
|
||||
one approved workload auth-role path, plus revoke-self. No child token, mount
|
||||
administration, KV data/metadata, SecretID or sibling role is admitted.
|
||||
OpenBao ACL narrows endpoint access; it cannot restrict the HCL/role payload.
|
||||
Only the mediator's independently reconstructed body equality enforces that.
|
||||
8. Use an injected restricted backend adapter for the exact ordered native
|
||||
calls. Compare current expected state; write only the approved generated body,
|
||||
read it back, and compare it. No `OpenBaoClient._run_ok` raw failure reaches
|
||||
receipts: capture/discard backend output and use closed reason codes. A generic
|
||||
403/timeout or “already exists” is not successful verification.
|
||||
9. Finally self-revoke the task token and login-self token, then retire only the
|
||||
exact per-operation grant policy under founder-owned cleanup authority.
|
||||
Record confirmed revocation/absence separately from native apply status.
|
||||
Policy retirement alone does not prove issued-token revocation.
|
||||
|
||||
Task-token use budget is declared before issuance: two before-state reads, two
|
||||
writes, two readbacks and one self-revoke (seven uses), plus at most four explicit
|
||||
metadata capability checks and one reserved cleanup use (twelve total). Failed
|
||||
backend calls count; never retry to evade limits. Task TTL is
|
||||
`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus
|
||||
the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain
|
||||
for planned requests. No renewal or remint extends the operation. CCR38 keeps its
|
||||
own 300s/eight-use limit; login-self checks and revoke fit its separate budget.
|
||||
|
||||
## Failure, replay and recovery
|
||||
|
||||
Ledger states: `reserved → consume-confirmed → authenticating → grant-issued →
|
||||
applying → verified`, followed by a separate `cleanup-confirmed` or
|
||||
`cleanup-unconfirmed` result. Every external call is durably recorded as about
|
||||
to execute before the call, then recorded complete afterward. Crash between
|
||||
those steps is `unknown` rather than presumed failure. Concurrent duplicate
|
||||
requests never both reach consume or OpenBao.
|
||||
|
||||
An operation can end refused before consumption, consumed-without-backend-work,
|
||||
partially-applied, backend-outcome-unknown, verified-cleanup-unconfirmed or
|
||||
verified-cleanup-confirmed. No incomplete state permits blind reapplication.
|
||||
Cleanup continues when an action deadline expires, under the original explicit
|
||||
cleanup obligation, without new business writes. Read/repair of changed target
|
||||
state requires a fresh exact owner-reviewed recovery approval; no old snapshot
|
||||
overwrites concurrent owner edits. Hard interruption uses the Platform owner's
|
||||
bounded revocation/cleanup procedure with protected accessor custody. Ledger
|
||||
and evidence carry only fingerprints/metadata; the raw accessor/token never
|
||||
leaves owner memory or the separately admitted owner-private recovery channel.
|
||||
|
||||
Revocation failure blocks owner acceptance and T06 closure, even if TTL will
|
||||
eventually expire. Emit non-secret atomic local lifecycle events/outbox records
|
||||
at the ledger boundaries; audit-core outage does not suppress cleanup. Do not
|
||||
claim an empty archive proves no effects, or that local ledger state is founder
|
||||
approval. Owner acceptance requires native before/after equality, denied sibling
|
||||
actions and verified cleanup under the same exact trial.
|
||||
|
||||
## Implementation hooks and closure tests
|
||||
|
||||
- `engine_auth.py`: staged readiness guard now. Later separate identity-session
|
||||
and execution-adapter objects; generic service-jwt client remains self-only.
|
||||
- `cli.py`: staged guard passed through backend-action approval callers now.
|
||||
New explicit execute-ccr preparation/mediated dispatch later; ordinary apply
|
||||
does not acquire new rights merely because the login contract is configured.
|
||||
- `authorization.py`, `approval_consume.py`: execution context participates in
|
||||
both approval-free binding and full PDP digest; owner child returns the actual
|
||||
`ConsumedApproval`, not only an `AuthorizedAction` ID.
|
||||
- New `execution_contract.py`, `execution_ledger.py`, `mediated_backend.py`:
|
||||
strict closed schema, deterministic compiler, separate admission state,
|
||||
pinned owner-native adapter and non-secret outcome types. Ordinary subprocess
|
||||
command execution is not an implementation of this adapter.
|
||||
- Platform owns task-execution-grant CCR/typed validator/token materializer/
|
||||
fixed execution child/recovery hooks. Approval Engine/PDP owners acknowledge
|
||||
the request+claim/package correspondence; secrets-engine cannot assent for them.
|
||||
|
||||
Counterexamples required before shipping: login-only auto provider consumes
|
||||
nothing; stale/forged grant file changes nothing; same name with changed HCL or
|
||||
role body refuses; wrong method/sibling policy/role/KV/SecretID/child-token/mount
|
||||
write refuses; changed source/caller/tenant/catalog/deadline alters the bound
|
||||
request and refuses; consume conflict/unavailability/timeout produces zero
|
||||
backend calls; idempotent approval consumption never causes a second execution;
|
||||
concurrent calls admit one worker; postconsume login failure never falls back;
|
||||
expiry during a multi-call operation stops new mutations but permits scoped
|
||||
cleanup; readback mismatch/transport failure is incomplete; revoke failure
|
||||
never passes; partial apply and crash after write require explicit recovery.
|
||||
|
||||
Current staged readiness regression suite: eight new checks plus 28 existing
|
||||
auth, consume and privileged-evidence checks pass (36 total). No native action
|
||||
was performed. Full entitlement implementation, policy-package publication,
|
||||
independent Platform/Secrets Engine/identity/approval/PDP acknowledgement and
|
||||
exact founder-scoped native proof remain required. A successful metadata trial
|
||||
alone would not cover value provision or exec delivery.
|
||||
|
||||
## Existing commands and the next contract they actually need
|
||||
|
||||
| Existing operation | Current login-only outcome | Contract required after the metadata pilot |
|
||||
| --- | --- | --- |
|
||||
| apply / apply_plan | Readiness refusal before consume; no backend | A separate exact policy/AppRole/mount structure compiler and grant for each admitted PlanAction. The workload-kv-read execute-ccr pilot does not implement these generic AppRole/mount calls. |
|
||||
| provision / rotate | Readiness refusal before consume; no KV writes | Exact KV v2 data path, declared field set, expected CAS version, no sibling changes, owner-held opaque input handle, confirmed new version and separately admitted revoke/cleanup. Never expose secret plaintext or guessable secret-value hashes in request/evidence. |
|
||||
| verify positive/negative | Readiness refusal before consume; no custody reads | Metadata capability probe plus, only when required, exact declared field-presence read through a restricted owner adapter. Return booleans; malformed/denied/transport error is inconclusive, not absence. Independent negative identity and exact deny paths are separate bindings. |
|
||||
| exec / wrap | Readiness refusal before consume; no read/injection/wrap | Exact custody path/field, signed executable/cwd/destination/exec-owner binding and duration, no user arbitrary shell, owner-mediated read and approved process-local delivery, or exact one-use wrap creation/unwrap/expiry; self revoke and injected material cleanup. |
|
||||
| handoff | Readiness refusal before consume; no role-id/SecretID mint | Exact existing AppRole and single-use SecretID creation, bounded TTL/uses and owner-admitted delivery; native role-id lookup/SecretID mint/login/revoke budget and no caller standing credential. |
|
||||
| revoke / native suspend/deactivate | Readiness refusal before consume; no backend revocation/deletion | Exact policy/role/lease/accessor target and expected state, admitted lifecycle effect, rollback/custody preservation and denial readback. Token/lease handles remain owner-private; receipt fingerprints cannot authorize another handle. |
|
||||
| session revoke | Readiness refusal before its backend; legacy stance remains | Exact already-held protected handle, typed token/lease revocation and independent subject/authority binding; no arbitrary accessor or lease-prefix wildcard. |
|
||||
| destroy | Existing live destroy refusal remains | Exact destructive dual-control contract and custody/backup/version proof; metadata pilot supplies neither. |
|
||||
| compromise / reactivate local state | Existing local state gate remains | No new OpenBao entitlement; these local records do not establish actual native revocation/reactivation. |
|
||||
| service-auth probe-openbao | Existing explicit self-only identity trial | CCR38 positive/negative/lifetime/revoke proof, with no implied entitlement to any catalog lane. |
|
||||
|
||||
Each subsequent contract needs its own owner-reviewed compiler, request/PDP
|
||||
package binding, method/body enforcement, consume admission, TTL/use budget,
|
||||
failure/recovery rules and native counterexamples. No success in the initial
|
||||
metadata pilot unblocks these rows by default. Full T06 adoption requires the
|
||||
actual selected engine production workflow to run through the corresponding
|
||||
reviewed entitlement, with native effect, denial/no-fallback and cleanup proof.
|
||||
|
||||
## Source preparation: typed request propagation (2026-10-11)
|
||||
|
||||
The staged ExecutionContract descriptor is deliberately a closed, immutable
|
||||
non-secret value. `parse` validates shape and captures JSON; every `as_dict`
|
||||
revalidates even directly constructed instances and returns a detached copy.
|
||||
It is not an owner approval, source authenticity attestation, principal
|
||||
representation or proof that the requested origin is admitted. Compiler and
|
||||
mediator must independently reconstruct and authenticate sources and bodies.
|
||||
|
||||
The exact proposed descriptor serialization refines the table above:
|
||||
|
||||
- Digests use `sha256:` plus 64 lowercase hexadecimal characters. UUIDs use
|
||||
canonical lowercase hyphenated form. CCR and grant IDs must be distinct.
|
||||
- `source_bundle` has exactly engine_dispatcher, platform_mediator, typed_ccr and
|
||||
policy_auth; each has exactly an absolute normalized `path` and `sha256`.
|
||||
This parses paths without reading files; pin verification remains unimplemented.
|
||||
- `operations` is an ordered list of exactly method/path/payload_sha256 objects.
|
||||
It accepts only GET/PUT metadata policy and auth-role endpoint shapes, with
|
||||
normalized relative paths. This schema does not reconstruct bodies or prove
|
||||
that all before/write/readback operations occur or target exactly two paths.
|
||||
A later owner compiler must enforce those semantics before any execution.
|
||||
- `backend_origin` is an explicit-port HTTP(S) origin without credentials, path,
|
||||
query or fragment. Syntactic acceptance creates no approved route or network
|
||||
call. Estate pinning and server authentication remain mediator obligations.
|
||||
- `backend_budget` has exactly ttl_seconds (integer 30–120) and uses (integer
|
||||
1–12); booleans fail. Listed calls must leave at least one cleanup use.
|
||||
Renewal, remint and live per-call accounting remain outside this descriptor.
|
||||
- expected_before/expected_after are non-secret state digests; operation_deadline
|
||||
is timezone-aware. Before claim, refuse expired deadlines using the existing
|
||||
trusted-time window. After claim and Check, refuse a deadline later than either
|
||||
served approval or decision expiry. A future mediator must recheck before every
|
||||
effect and provide separately authorized cleanup after business expiry.
|
||||
- catalog_sha256 is a descriptor-local sorted compact JSON fingerprint of the
|
||||
complete existing context.catalog_target, distinct from canonical PDP digests.
|
||||
Resource catalog ID/stage, subject, tenant and purpose remain bound separately
|
||||
through the unchanged canonical request hashing implementation.
|
||||
|
||||
The same typed descriptor passes through build_action_request, _expected_request,
|
||||
resolve_approval_observation and authorize_action. Any contract on apply or another
|
||||
legacy action refuses. execute-ccr without a typed valid contract, prod stage or
|
||||
platform tenant refuses. execute-ccr always binds human_control=true, even on a
|
||||
legacy bootstrap catalog, and cannot use an unserved/demo fallback. Invalid or
|
||||
catalog-mismatched descriptors refuse before claim/PDP/CAS. Returned requests are
|
||||
independent copies; later caller mutation cannot change the stored descriptor.
|
||||
|
||||
99 focused source tests pass across context propagation, the existing approval
|
||||
join, decision validation, readiness, consume and engine auth. New tests exercise
|
||||
all four actual builders, actual consume request formatting and claim-free/full
|
||||
joins; mutation of operation, CCR/grant/source/body/method/path/budget/deadline/
|
||||
estate/state/principal/purpose/catalog/tenant/stage refuses the original approval.
|
||||
Transport/PDP stand-ins deliberately supply hypothetical human control and an
|
||||
ALLOW: they prove propagation, not a shipped policy or real principal admission.
|
||||
|
||||
No execute-ccr CLI, identity-owner verified initiating-principal interface,
|
||||
new access policy package, typed owner compiler, task-grant materializer,
|
||||
independent authenticated consume-confirmation integration, durable admission
|
||||
journal, restricted backend, per-call deadline checks or native entitlement is
|
||||
supplied by this stage. Existing cfg subject strings remain legacy provenance;
|
||||
this change does not make them verified human representation. Existing generic
|
||||
consume receipt validation limitations remain explicitly separate. service-jwt
|
||||
readiness guard/default and ordinary actions remain unchanged. T01 stays in
|
||||
progress, T03 stays wait, WP13 stays proposed, and WP8-T06 stays open.
|
||||
|
||||
## D3 wrapped-delivery primitive and its separate entitlement
|
||||
|
||||
D3 P3=b chooses response-wrapped issuer delivery with one-time unwrap. The
|
||||
existing wrapped-handoff primitive now verifies native TTL/accessor/creation-path
|
||||
metadata and reserves a private immutable recipient file before minting. Native
|
||||
KV-v2 creation paths include the `data/` endpoint; caller-requested CLI paths
|
||||
cannot substitute for returned metadata. Failed mint/write or an altered
|
||||
reservation retains an uncertain attempt and never silently overwrites/remints.
|
||||
|
||||
A disposable local OpenBao integration proves the exact source path,60-second
|
||||
TTL, successful first unwrap and specific HTTP400 consumed-token replay denial.
|
||||
This proves backend semantics, not the estate's first delivered lane, audited
|
||||
initiating principal, consumer identity, independent owner acceptance or cleanup
|
||||
under crash/revocation. Production CLI wrapping remains fail-closed. Existing
|
||||
metadata execute-ccr contracts expressly exclude wrap/KV value delivery and cannot
|
||||
be borrowed for this purpose. A separate exact owner-admitted delivery operation
|
||||
must bind actor,path,audience,deadline,authorization/consume state,destination,
|
||||
audit,replay/crash recovery and rollback before native adoption. The prepared
|
||||
D2 repository-issuer lane remains attended until its own reviewed execution and
|
||||
independent destination/adoption proofs pass.
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
"""PEP consume-before-side-effect client (GH-DEC-2026-003).
|
||||
|
||||
The PEP that is about to cause a protected OpenBao write MUST obtain a
|
||||
successful approval-engine CAS consume first. Holding a claim or an ALLOW is
|
||||
not authority to act. Conflict, unavailability, or a missing binding means
|
||||
do not call OpenBao.
|
||||
|
||||
This module does not render an authorization decision. The consume response
|
||||
is mutation evidence, never a permission.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request
|
||||
|
||||
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
||||
from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline
|
||||
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
|
||||
from secrets_engine.catalog import human_control_required
|
||||
from secrets_engine.decision_check import check_decision, require_supported_pdp_address
|
||||
from secrets_engine.decision_signature import load_trust, strict_json
|
||||
from secrets_engine.authorization import (
|
||||
build_action_request,
|
||||
validate_decision_envelope,
|
||||
wait_for_decision_start,
|
||||
)
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.application_time import read_window, recheck_binding
|
||||
from secrets_engine.openbao import read_strict_token_file
|
||||
from secrets_engine.pep_stance import demo_exception_enabled
|
||||
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_MAX_BODY = 256 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsumeBinding:
|
||||
"""Inputs the PEP presents to approval-engine consume."""
|
||||
|
||||
approval_id: str
|
||||
request_digest: str
|
||||
decision_id: str = ""
|
||||
human_control: bool = False
|
||||
decision_not_before: str = ""
|
||||
decision_expires_at: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalObservation:
|
||||
"""Fresh approval input awaiting PDP correspondence; never a consume binding."""
|
||||
|
||||
approval_id: str
|
||||
request: dict[str, Any] = field(repr=False)
|
||||
claim: dict[str, Any] = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedAction:
|
||||
"""A validated claim and decision for one exact proposed action.
|
||||
|
||||
Holding this is not authority to act: GH-DEC-2026-003 still requires a
|
||||
successful CAS consume before the OpenBao call.
|
||||
"""
|
||||
|
||||
binding: ConsumeBinding
|
||||
decision_id: str
|
||||
expires_at: str
|
||||
# Detached original served artifacts for the narrow task-execution handoff.
|
||||
artifacts: str = field(default="", repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""The actual PDP decision id for shared CLI evidence/plan callers."""
|
||||
return self.decision_id
|
||||
|
||||
def as_evidence(self) -> dict[str, object]:
|
||||
return {
|
||||
"authorization_decision_id": self.decision_id,
|
||||
"authorization_expires_at": self.expires_at,
|
||||
"request_digest": self.binding.request_digest,
|
||||
"approval_human_control": self.binding.human_control,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsumedApproval:
|
||||
"""Non-secret confirmation that consume succeeded for this request."""
|
||||
|
||||
approval_id: str
|
||||
request_digest: str
|
||||
decision_id: str = ""
|
||||
idempotent: bool = False
|
||||
consumed_at: str = ""
|
||||
|
||||
def as_evidence(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"approval_consumed": True,
|
||||
"approval_id": self.approval_id,
|
||||
"request_digest": self.request_digest,
|
||||
"approval_consume_idempotent": self.idempotent,
|
||||
}
|
||||
if self.decision_id:
|
||||
payload["decision_id"] = self.decision_id
|
||||
if self.consumed_at:
|
||||
payload["approval_consumed_at"] = self.consumed_at
|
||||
return payload
|
||||
|
||||
|
||||
def _authorization_id(entry: Any, decision: Any) -> str:
|
||||
"""Non-secret approval-engine object id for this lane, or "" if unbound.
|
||||
|
||||
flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID.
|
||||
It is never the State Hub decision UUID, so it is not inferred from one.
|
||||
"""
|
||||
approval = getattr(entry, "approval", None) or {}
|
||||
if isinstance(approval, dict):
|
||||
declared = str(approval.get("authorization_id", "") or "").strip()
|
||||
if declared:
|
||||
return declared
|
||||
for attr in ("authorization_id", "action_authorization_id"):
|
||||
served = str(getattr(decision, attr, "") or "").strip()
|
||||
if served:
|
||||
return served
|
||||
return ""
|
||||
|
||||
|
||||
def _request_purpose(entry: Any) -> str:
|
||||
"""Declared purpose for the request context. Never invented at call time."""
|
||||
approval = getattr(entry, "approval", None) or {}
|
||||
if isinstance(approval, dict):
|
||||
declared = str(approval.get("purpose", "") or "").strip()
|
||||
if declared:
|
||||
return declared
|
||||
for consumer in getattr(entry, "consumers", None) or []:
|
||||
if isinstance(consumer, dict):
|
||||
declared = str(consumer.get("purpose", "") or "").strip()
|
||||
if declared:
|
||||
return declared
|
||||
return ""
|
||||
|
||||
|
||||
def _expected_request(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
*,
|
||||
fields: tuple[str, ...] = (),
|
||||
policy_targets: tuple[str, ...] = (),
|
||||
auth_targets: tuple[str, ...] = (),
|
||||
execution_contract: ExecutionContract | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the exact CheckRequest both steps must agree on.
|
||||
|
||||
Steps 1 and 2 must describe the same proposed action or the digests cannot
|
||||
correspond, so neither builds its own.
|
||||
"""
|
||||
subject_id = str(getattr(cfg, "authorization_subject_id", "") or "")
|
||||
subject_type = str(getattr(cfg, "authorization_subject_type", "") or "")
|
||||
if not subject_id or not subject_type:
|
||||
raise DecisionError(
|
||||
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
|
||||
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
|
||||
)
|
||||
purpose = _request_purpose(entry)
|
||||
if not purpose:
|
||||
raise DecisionError(
|
||||
"authorization join requires a declared approval/consumer purpose"
|
||||
)
|
||||
return build_action_request(
|
||||
entry,
|
||||
action,
|
||||
subject_id=subject_id,
|
||||
subject_type=subject_type,
|
||||
purpose=purpose,
|
||||
fields=fields,
|
||||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
execution_contract=execution_contract,
|
||||
)
|
||||
|
||||
|
||||
def fetch_approval_claim(
|
||||
*,
|
||||
base_url: str,
|
||||
token_file: Path | None = None,
|
||||
token_provider: Callable[[], str] | None = None,
|
||||
authorization_id: str,
|
||||
timeout_seconds: float = 3,
|
||||
opener: Callable[..., Any] = credential_urlopen,
|
||||
) -> dict[str, Any]:
|
||||
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed.
|
||||
|
||||
The body is approval-engine's approval-claim, not a flex-auth
|
||||
ActionAuthorization -- that object is deferred and was never ratified
|
||||
(GH-DEC-2026-005 / FLEX-DEC-2026-006).
|
||||
"""
|
||||
if not base_url or not base_url.startswith(("http://", "https://")):
|
||||
raise DecisionError("approval-engine claim URL is missing or invalid")
|
||||
ident = authorization_id.strip()
|
||||
if not ident or "/" in ident or any(ch.isspace() for ch in ident):
|
||||
raise DecisionError("approval claim requires a concrete authorization id")
|
||||
token = _request_token(token_file, token_provider)
|
||||
request = Request(
|
||||
base_url.rstrip("/") + f"/v1/approvals/{ident}/claim",
|
||||
method="GET",
|
||||
)
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
try:
|
||||
with opener(request, timeout=timeout_seconds) as response:
|
||||
if getattr(response, "status", 200) != 200:
|
||||
raise DecisionError("approval claim did not return a claim")
|
||||
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e
|
||||
except URLError as e:
|
||||
raise DecisionError("approval-engine is unreachable for claim") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise DecisionError("approval claim returned a non-JSON body") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise DecisionError("approval claim returned a non-object body")
|
||||
return payload
|
||||
|
||||
|
||||
def resolve_approval_observation(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
decision: Any,
|
||||
*,
|
||||
fields: tuple[str, ...] = (),
|
||||
policy_targets: tuple[str, ...] = (),
|
||||
auth_targets: tuple[str, ...] = (),
|
||||
execution_contract: ExecutionContract | None = None,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ApprovalObservation | None:
|
||||
"""Observe a fresh fact and prepare the claim-bearing request (step 1).
|
||||
|
||||
Missing serving coordinates return None for undeclared lanes. A declared
|
||||
human control raises instead, so no stage/demo fallback can discharge it.
|
||||
Correspondence and a consume binding require authorize_action's PDP step.
|
||||
"""
|
||||
execution_request = None
|
||||
if action == "execute-ccr" or execution_contract is not None:
|
||||
execution_request = _expected_request(
|
||||
cfg, entry, action, fields=fields, policy_targets=policy_targets,
|
||||
auth_targets=auth_targets, execution_contract=execution_contract,
|
||||
)
|
||||
# The caller's descriptor is not authenticated principal/owner evidence.
|
||||
# This requirement cannot disappear on an unserved/demo lane.
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
auth_configured = approval_auth_configured(cfg)
|
||||
authorization_id = _authorization_id(entry, decision)
|
||||
required_human_control = action == "execute-ccr" or human_control_required(entry)
|
||||
if not base_url or not auth_configured or not authorization_id:
|
||||
if required_human_control:
|
||||
raise DecisionError("human_control requires a served approval claim and exact action binding")
|
||||
return None
|
||||
|
||||
expected_request = execution_request or _expected_request(
|
||||
cfg, entry, action,
|
||||
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
||||
execution_contract=execution_contract,
|
||||
)
|
||||
|
||||
require_execution_deadline(expected_request, time_window=read_window(cfg))
|
||||
claim = fetch_approval_claim(
|
||||
base_url=base_url,
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:read"),
|
||||
authorization_id=authorization_id,
|
||||
opener=opener or credential_urlopen,
|
||||
)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id,
|
||||
require_human_control=required_human_control, time_window=read_window(cfg))
|
||||
require_execution_deadline(expected_request, time_window=read_window(cfg),
|
||||
bounds=(claim["validity"]["expires_at"],))
|
||||
expected_request["context"]["approval"] = claim
|
||||
return ApprovalObservation(authorization_id, expected_request, claim)
|
||||
|
||||
|
||||
def authorize_action(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
decision: Any = None,
|
||||
*,
|
||||
fields: tuple[str, ...] = (),
|
||||
policy_targets: tuple[str, ...] = (),
|
||||
auth_targets: tuple[str, ...] = (),
|
||||
execution_contract: ExecutionContract | None = None,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
pdp_opener: Callable[..., Any] | None = None,
|
||||
) -> AuthorizedAction | None:
|
||||
"""Run steps 1 and 2 for one proposed action, or return None if unserved.
|
||||
|
||||
Step 1 validates the approval-claim; step 2 obtains and validates the
|
||||
flex-auth DecisionEnvelope. Returning None means no serving path is
|
||||
configured at all, which leaves production fail-closed. A configured but
|
||||
failing path raises: a partial deployment must not read as an absent one.
|
||||
"""
|
||||
serving = (getattr(cfg, "approval_url", "") and approval_auth_configured(cfg)
|
||||
and _authorization_id(entry, decision))
|
||||
if serving or (getattr(cfg, "pdp_url", "") and getattr(cfg, "pdp_token_file", None)):
|
||||
if not getattr(cfg, "pdp_url", "") or not getattr(cfg, "pdp_token_file", None):
|
||||
raise DecisionError("production action requires configured access-engine URL/token and responder trust")
|
||||
require_supported_pdp_address(str(cfg.pdp_url))
|
||||
load_trust(getattr(cfg, "pdp_trust_file", None))
|
||||
verifier = getattr(cfg, "pdp_verifier", None)
|
||||
if verifier is None or not Path(verifier).is_absolute() or not Path(verifier).is_file():
|
||||
raise DecisionError("access-engine signature verifier is not installed")
|
||||
observation = resolve_approval_observation(
|
||||
cfg, entry, action, decision,
|
||||
fields=fields,
|
||||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
execution_contract=execution_contract,
|
||||
opener=opener,
|
||||
)
|
||||
if observation is None:
|
||||
return None
|
||||
|
||||
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
|
||||
pdp_token = getattr(cfg, "pdp_token_file", None)
|
||||
if not pdp_url or not pdp_token:
|
||||
raise DecisionError(
|
||||
"production action requires an access-engine decision; "
|
||||
"SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset"
|
||||
)
|
||||
package = str(getattr(cfg, "authorization_policy_package", "") or "")
|
||||
version = str(getattr(cfg, "authorization_policy_version", "") or "")
|
||||
if not package or not version:
|
||||
raise DecisionError(
|
||||
"authorization join requires an explicitly configured policy "
|
||||
"package/version pin; the reserved coordinate is a reservation, "
|
||||
"not a publication, and must not be used as a default"
|
||||
)
|
||||
|
||||
expected_request = observation.request
|
||||
envelope = check_decision(
|
||||
base_url=pdp_url,
|
||||
token_file=Path(pdp_token),
|
||||
request=expected_request,
|
||||
opener=pdp_opener,
|
||||
trust_file=getattr(cfg, "pdp_trust_file", None),
|
||||
verifier=getattr(cfg, "pdp_verifier", None),
|
||||
)
|
||||
if not getattr(cfg, "clock_trust_file", None):
|
||||
wait_for_decision_start(envelope)
|
||||
window = read_window(cfg)
|
||||
validated = validate_decision_envelope(
|
||||
envelope,
|
||||
expected_request,
|
||||
accepted_policy_packages={package},
|
||||
accepted_policy_versions={version},
|
||||
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
|
||||
time_window=window,
|
||||
)
|
||||
# Recheck freshness after the network call before issuing a consume binding.
|
||||
validate_approval_claim(
|
||||
observation.claim, approval_id=observation.approval_id,
|
||||
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
|
||||
require_human_control=expected_request["context"].get("human_control") is True,
|
||||
time_window=window,
|
||||
)
|
||||
require_execution_deadline(expected_request, time_window=window,
|
||||
bounds=(observation.claim["validity"]["expires_at"], validated.expires_at))
|
||||
if validated.action != action:
|
||||
raise DecisionError("access-engine decision does not bind this action")
|
||||
return AuthorizedAction(
|
||||
binding=ConsumeBinding(
|
||||
approval_id=observation.approval_id,
|
||||
request_digest=validated.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
human_control=observation.claim["binding"].get("human_control") is True,
|
||||
decision_not_before=envelope["lifetime"].get("not_before") or "",
|
||||
decision_expires_at=validated.expires_at,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope},
|
||||
sort_keys=True,separators=(",",":"),allow_nan=False),
|
||||
)
|
||||
|
||||
|
||||
def consume_approval(
|
||||
*,
|
||||
base_url: str,
|
||||
token_file: Path | None = None,
|
||||
token_provider: Callable[[], str] | None = None,
|
||||
binding: ConsumeBinding,
|
||||
timeout_seconds: float = 3,
|
||||
opener: Callable[..., Any] = credential_urlopen,
|
||||
strict_response: bool = False,
|
||||
) -> ConsumedApproval:
|
||||
"""POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use."""
|
||||
if not base_url or not base_url.startswith(("http://", "https://")):
|
||||
raise DecisionError("approval-engine consume URL is missing or invalid")
|
||||
approval_id = binding.approval_id.strip()
|
||||
if not approval_id or "/" in approval_id or any(ch.isspace() for ch in approval_id):
|
||||
raise DecisionError("approval consume requires a concrete approval id")
|
||||
if not DIGEST_RE.fullmatch(binding.request_digest):
|
||||
raise DecisionError("approval consume requires the canonical request digest")
|
||||
|
||||
token = _request_token(token_file, token_provider)
|
||||
body: dict[str, str] = {"request_digest": binding.request_digest}
|
||||
if binding.decision_id:
|
||||
body["decision_id"] = binding.decision_id
|
||||
encoded = json.dumps(body).encode("utf-8")
|
||||
request = Request(
|
||||
base_url.rstrip("/") + f"/v1/approvals/{approval_id}/consume",
|
||||
data=encoded,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
try:
|
||||
status = int(response.getcode())
|
||||
raw = response.read(_MAX_BODY + 1)
|
||||
finally:
|
||||
response.close()
|
||||
except HTTPError as exc:
|
||||
status = int(getattr(exc, "code", 0) or 0)
|
||||
try:
|
||||
exc.read(_MAX_BODY)
|
||||
except Exception:
|
||||
pass
|
||||
raise DecisionError(_status_message(status)) from None
|
||||
except (URLError, TimeoutError, OSError):
|
||||
raise DecisionError(
|
||||
"approval-engine unreachable; OpenBao must not be called"
|
||||
) from None
|
||||
|
||||
if status != 200 or len(raw) > _MAX_BODY:
|
||||
raise DecisionError(_status_message(status if status != 200 else 502))
|
||||
try:
|
||||
payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, RecursionError) as exc:
|
||||
raise DecisionError("approval consume returned invalid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise DecisionError("approval consume returned invalid payload")
|
||||
if payload.get("status") != "consumed":
|
||||
raise DecisionError("approval consumption was not confirmed")
|
||||
if payload.get("request_digest") != binding.request_digest:
|
||||
raise DecisionError("approval consume digest does not match the request")
|
||||
if strict_response:
|
||||
from datetime import datetime
|
||||
if (payload.get("approval_id") != approval_id
|
||||
or payload.get("decision_id") != binding.decision_id
|
||||
or payload.get("idempotent") is not False
|
||||
or any(key in payload for key in ("effect","allow","deny","decision"))):
|
||||
raise DecisionError("strict task consumption correspondence refused")
|
||||
try:
|
||||
stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00"))
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError("timezone required")
|
||||
except (KeyError,TypeError,AttributeError,ValueError):
|
||||
raise DecisionError("strict task consumption timestamp refused") from None
|
||||
decision_id = payload.get("decision_id")
|
||||
if decision_id is not None and (
|
||||
not isinstance(decision_id, str) or not decision_id
|
||||
):
|
||||
raise DecisionError("approval consume returned an invalid decision id")
|
||||
consumed_at = payload.get("consumed_at")
|
||||
if consumed_at is not None and not isinstance(consumed_at, str):
|
||||
raise DecisionError("approval consume returned an invalid consumed_at")
|
||||
return ConsumedApproval(
|
||||
approval_id=str(payload.get("approval_id") or approval_id),
|
||||
request_digest=binding.request_digest,
|
||||
decision_id=decision_id or binding.decision_id,
|
||||
idempotent=bool(payload.get("idempotent")),
|
||||
consumed_at=consumed_at or "",
|
||||
)
|
||||
|
||||
|
||||
def require_production_consume(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
*,
|
||||
binding: ConsumeBinding | None,
|
||||
evidence: Any = None,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ConsumedApproval | None:
|
||||
"""CAS-consume before production or an explicitly human-controlled action.
|
||||
|
||||
Undeclared build/test and the unsafe-demo exception retain their existing
|
||||
behavior. Declared human controls require a verified binding and consume
|
||||
regardless of stage/demo; missing inputs cannot fall back to a lane review.
|
||||
"""
|
||||
required_human_control = human_control_required(entry)
|
||||
if required_human_control:
|
||||
if binding is None or binding.human_control is not True:
|
||||
raise DecisionError("human_control requires an observed declared-human consume binding")
|
||||
else:
|
||||
if getattr(entry, "stage", "") != "prod":
|
||||
return None
|
||||
if demo_exception_enabled(cfg):
|
||||
return None
|
||||
if binding is None:
|
||||
raise DecisionError(
|
||||
"production OpenBao call requires CAS consume of an approval "
|
||||
"after an access-engine ALLOW; no durable consume binding is served"
|
||||
)
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
auth_configured = approval_auth_configured(cfg)
|
||||
if not base_url:
|
||||
raise DecisionError(
|
||||
"production OpenBao call requires approval-engine consume; "
|
||||
"SECRETS_ENGINE_APPROVAL_URL is unset"
|
||||
)
|
||||
if not auth_configured:
|
||||
raise DecisionError(
|
||||
"production OpenBao call requires approval-engine consume; "
|
||||
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
|
||||
)
|
||||
recheck_binding(cfg, binding)
|
||||
consumed = consume_approval(
|
||||
base_url=base_url,
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
|
||||
binding=binding,
|
||||
opener=opener or credential_urlopen,
|
||||
)
|
||||
if evidence is not None and hasattr(evidence, "mark_consumed"):
|
||||
evidence.mark_consumed(consumed)
|
||||
recheck_binding(cfg, binding)
|
||||
return consumed
|
||||
|
||||
|
||||
def _status_message(status: int) -> str:
|
||||
if status == 409:
|
||||
return "approval consume conflict; OpenBao must not be called"
|
||||
if status == 404:
|
||||
return "approval not found; OpenBao must not be called"
|
||||
if status in {401, 403}:
|
||||
return "approval consume unauthorized; OpenBao must not be called"
|
||||
if status == 503:
|
||||
return "approval-engine unavailable; OpenBao must not be called"
|
||||
if status == 0:
|
||||
return "approval-engine unreachable; OpenBao must not be called"
|
||||
return "approval consume failed; OpenBao must not be called"
|
||||
|
||||
|
||||
def _request_token(
|
||||
token_file: Path | None, token_provider: Callable[[], str] | None,
|
||||
) -> str:
|
||||
if (token_file is None) == (token_provider is None):
|
||||
raise DecisionError("approval request requires exactly one credential provider")
|
||||
token = (
|
||||
token_provider() if token_provider is not None
|
||||
else read_strict_token_file(Path(token_file), purpose="approval credential")
|
||||
)
|
||||
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
|
||||
raise DecisionError("approval credential is invalid")
|
||||
return token
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"""Independently admitted Access Engine responder keys, never responder discovery.
|
||||
|
||||
The bundled Go helper uses the exact producer types and verifier. No Python
|
||||
canonicalization or unsigned compatibility mode participates in authorization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
|
||||
def _unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON member")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def strict_json(data):
|
||||
return json.loads(data, object_pairs_hook=_unique_object,
|
||||
parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number")))
|
||||
|
||||
|
||||
def load_trust(path: Path | None) -> dict[str, Any]:
|
||||
"""Read an operator-owned public admission file; missing trust is a refusal.
|
||||
|
||||
Admission config is a local authority input like the existing policy pin.
|
||||
File provenance/approval and the executable's installation are rollout gates,
|
||||
not established by a successful local signature test.
|
||||
"""
|
||||
try:
|
||||
if path is None or not Path(path).is_absolute() or Path(path).is_symlink():
|
||||
raise ValueError("trust path must be absolute and regular")
|
||||
with Path(path).open('rb') as stream:
|
||||
import os
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022:
|
||||
raise ValueError("trust must not be group/world writable")
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError("trust is oversized")
|
||||
trust = strict_json(raw)
|
||||
if set(trust) != {'schema', 'evaluator', 'mode', 'policy_package', 'policy_version', 'policy_package_digest', 'keys'}:
|
||||
raise ValueError("invalid trust fields")
|
||||
if trust['schema'] != 'secrets-engine.pdp-trust.v1':
|
||||
raise ValueError("unsupported trust schema")
|
||||
if any(not isinstance(trust[k], str) or not trust[k] for k in trust if k != 'keys'):
|
||||
raise ValueError("missing issuer/pin")
|
||||
if not isinstance(trust['keys'], dict) or not trust['keys'] or len(trust['keys']) > 16:
|
||||
raise ValueError("missing admitted keys")
|
||||
if any(not isinstance(k,str) or not k or not isinstance(v,str) or not v for k,v in trust['keys'].items()):
|
||||
raise ValueError("invalid keys")
|
||||
for key in trust["keys"].values():
|
||||
if len(key) != 43 or len(base64.b64decode(key + "=", altchars=b"-_", validate=True)) != 32:
|
||||
raise ValueError("invalid Ed25519 public key")
|
||||
return trust
|
||||
except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc:
|
||||
raise DecisionError("access-engine responder trust is missing or invalid") from exc
|
||||
|
||||
|
||||
def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict:
|
||||
if verifier is None or not Path(verifier).is_absolute():
|
||||
raise DecisionError("access-engine signature verifier is not installed")
|
||||
try:
|
||||
result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}',
|
||||
capture_output=True, env={}, timeout=3, check=False)
|
||||
if result.returncode != 0 or len(result.stdout) > 524288:
|
||||
raise ValueError("signature refused")
|
||||
authenticated = strict_json(result.stdout)
|
||||
provenance = authenticated['provenance']
|
||||
if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')):
|
||||
raise ValueError("issuer/pin mismatch")
|
||||
return authenticated
|
||||
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc:
|
||||
raise DecisionError("access-engine responder authentication failed") from exc
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"""Closed, immutable metadata descriptor; shape is never execution authority.
|
||||
|
||||
This source-only compiler input cannot grant an action, authenticate a principal,
|
||||
prove owner source authenticity or open a backend. Those integration gates remain
|
||||
outside this type. Existing actions cannot borrow the new descriptor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
VERSION = "SECRETS-EXEC-20261011-v1"
|
||||
HASH = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
KEYS = {
|
||||
"contract_version", "operation_id", "operation", "execution_actor", "ccr_id",
|
||||
"ccr_immutable_sha256", "execution_grant_ccr_id", "grant_immutable_sha256",
|
||||
"catalog_sha256", "source_bundle", "backend_origin", "cluster_uid",
|
||||
"operations", "operation_deadline", "backend_budget", "expected_before",
|
||||
"expected_after",
|
||||
}
|
||||
SOURCE_ROLES = {"engine_dispatcher", "platform_mediator", "typed_ccr", "policy_auth"}
|
||||
|
||||
|
||||
def _refuse() -> None:
|
||||
# Do not interpolate untrusted descriptors into messages/evidence.
|
||||
raise DecisionError("invalid execution_contract descriptor")
|
||||
|
||||
|
||||
def _closed(value, keys):
|
||||
if type(value) is not dict or set(value) != set(keys):
|
||||
_refuse()
|
||||
return value
|
||||
|
||||
|
||||
def _text(value):
|
||||
if type(value) is not str or not value or len(value) > 4096 or any(ord(c) < 32 for c in value):
|
||||
_refuse()
|
||||
return value
|
||||
|
||||
|
||||
def _hash(value):
|
||||
if type(value) is not str or not HASH.fullmatch(value):
|
||||
_refuse()
|
||||
|
||||
|
||||
def _path(value, *, absolute=False):
|
||||
_text(value)
|
||||
if (value.startswith("/") != absolute or "\\" in value or "%" in value
|
||||
or "?" in value or "#" in value or ":" in value
|
||||
or any(p in {"", ".", ".."} for p in value.lstrip("/").split("/"))
|
||||
or str(PurePosixPath(value)) != value):
|
||||
_refuse()
|
||||
|
||||
|
||||
def _validated(value):
|
||||
value = copy.deepcopy(_closed(value, KEYS))
|
||||
if value['contract_version'] != VERSION or value['operation'] != 'metadata-apply-ccr' or value['execution_actor'] != 'service:secrets-engine':
|
||||
_refuse()
|
||||
try:
|
||||
if str(UUID(_text(value['operation_id']))) != value['operation_id']:
|
||||
_refuse()
|
||||
except (ValueError, AttributeError):
|
||||
_refuse()
|
||||
for key in ['ccr_id', 'execution_grant_ccr_id', 'cluster_uid']:
|
||||
_text(value[key])
|
||||
if value['ccr_id'] == value['execution_grant_ccr_id']:
|
||||
_refuse()
|
||||
for key in ['ccr_immutable_sha256', 'grant_immutable_sha256', 'catalog_sha256', 'expected_before', 'expected_after']:
|
||||
_hash(value[key])
|
||||
sources = _closed(value['source_bundle'], SOURCE_ROLES)
|
||||
for source in sources.values():
|
||||
_closed(source, {'path', 'sha256'})
|
||||
_path(source['path'], absolute=True)
|
||||
_hash(source['sha256'])
|
||||
try:
|
||||
origin = urlsplit(_text(value['backend_origin']))
|
||||
if (origin.scheme not in {'http', 'https'} or not origin.hostname
|
||||
or origin.username is not None or origin.password is not None
|
||||
or origin.path or origin.query or origin.fragment or origin.port is None):
|
||||
_refuse()
|
||||
except ValueError:
|
||||
_refuse()
|
||||
try:
|
||||
deadline = datetime.fromisoformat(_text(value['operation_deadline']).replace('Z', '+00:00'))
|
||||
if deadline.tzinfo is None:
|
||||
_refuse()
|
||||
except ValueError:
|
||||
_refuse()
|
||||
budget = _closed(value['backend_budget'], {'ttl_seconds', 'uses'})
|
||||
if (type(budget['ttl_seconds']) is not int or not 30 <= budget['ttl_seconds'] <= 120
|
||||
or type(budget['uses']) is not int or not 1 <= budget['uses'] <= 12):
|
||||
_refuse()
|
||||
operations = value['operations']
|
||||
if type(operations) is not list or not 1 <= len(operations) < budget['uses']:
|
||||
_refuse() # Reserve at least one cleanup use; no silent retries.
|
||||
for operation in operations:
|
||||
_closed(operation, {'method', 'path', 'payload_sha256'})
|
||||
if type(operation['method']) is not str or operation['method'] not in {'GET', 'PUT'}:
|
||||
_refuse()
|
||||
_path(operation['path'])
|
||||
# Only metadata pilot endpoint shapes, never KV/mount/token/secretid.
|
||||
if not re.fullmatch(r'(sys/policies/acl/[A-Za-z0-9_-]+|auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+)', operation['path']):
|
||||
_refuse()
|
||||
_hash(operation['payload_sha256'])
|
||||
return value
|
||||
|
||||
|
||||
def catalog_target_digest(target: dict) -> str:
|
||||
"""Descriptor-local sorted JSON fingerprint, distinct from PDP digest."""
|
||||
encoded = json.dumps(target, sort_keys=True, ensure_ascii=False, allow_nan=False,
|
||||
separators=(',', ':')).encode('utf-8')
|
||||
return 'sha256:' + hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionContract:
|
||||
_serialized: str
|
||||
|
||||
@classmethod
|
||||
def parse(cls, descriptor: dict) -> 'ExecutionContract':
|
||||
validated = _validated(descriptor)
|
||||
return cls(json.dumps(validated, sort_keys=True, separators=(',', ':'), allow_nan=False))
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
# Revalidate even a directly constructed instance: no trusted label bypass.
|
||||
try:
|
||||
return _validated(json.loads(self._serialized))
|
||||
except (ValueError, TypeError, RecursionError):
|
||||
_refuse()
|
||||
|
||||
|
||||
def attach_execution_contract(request: dict, contract: ExecutionContract | None) -> None:
|
||||
action = request['action']
|
||||
if action != 'execute-ccr':
|
||||
if contract is not None:
|
||||
raise DecisionError('execution_contract requires execute-ccr action')
|
||||
return
|
||||
if type(contract) is not ExecutionContract:
|
||||
raise DecisionError('execute-ccr requires typed execution_contract')
|
||||
value = contract.as_dict()
|
||||
if request['resource']['attributes']['stage'] != 'prod':
|
||||
raise DecisionError('execute-ccr requires prod stage')
|
||||
if request['tenant'] != 'tenant:platform':
|
||||
raise DecisionError('execute-ccr requires platform tenant')
|
||||
if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']):
|
||||
raise DecisionError('execution_contract catalog target does not match')
|
||||
request['context']['execution_contract'] = value
|
||||
request['context']['human_control'] = True
|
||||
|
||||
|
||||
def require_execution_deadline(request: dict, *, time_window=None, bounds=()) -> None:
|
||||
"""Check trusted-time freshness and served upper bounds before CAS binding."""
|
||||
contract = request.get('context', {}).get('execution_contract')
|
||||
if contract is None:
|
||||
return
|
||||
from secrets_engine.application_time import validity_bounds
|
||||
deadline = datetime.fromisoformat(contract['operation_deadline'].replace('Z', '+00:00'))
|
||||
_, upper = validity_bounds(None, time_window)
|
||||
if upper >= deadline:
|
||||
raise DecisionError('execution_contract deadline has expired')
|
||||
for bound in bounds:
|
||||
try:
|
||||
limit = datetime.fromisoformat(bound.replace('Z', '+00:00'))
|
||||
if limit.tzinfo is None or deadline > limit:
|
||||
raise DecisionError('execution_contract deadline exceeds served authority')
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise DecisionError('execution_contract requires served deadline') from None
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"""Narrow service-task PEP handoff; records never grant native execution.
|
||||
|
||||
Platform calls preparation itself, binds its independently compiled owner Plan,
|
||||
reserves/journals once, then calls strict consumption and independent Approval
|
||||
GET confirmation. No inbound mapping/boolean or constructor creates authority.
|
||||
Actual policy/server input mapping and runtime entitlement remain unadmitted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from threading import Lock
|
||||
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim
|
||||
from secrets_engine.approval_consume import authorize_action, consume_approval
|
||||
from secrets_engine.application_time import read_window, validity_bounds
|
||||
from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest
|
||||
from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline
|
||||
|
||||
_CALLER = 'system:serviceaccount:secrets-engine:secrets-engine'
|
||||
_NATIVE_FIELDS = ('action','actor','principal','purpose','target')
|
||||
_COORDINATES = ('approval_url','approval_token_file','approval_client_secret_file','keycape_token_url','keycape_issuer',
|
||||
'pdp_url','pdp_token_file','pdp_trust_file','pdp_verifier','clock_trust_file',
|
||||
'authorization_subject_id','authorization_subject_type','authorization_policy_package','authorization_policy_version')
|
||||
_ISSUED = object()
|
||||
|
||||
|
||||
def _refuse():
|
||||
raise DecisionError('strict task execution handoff correspondence refused')
|
||||
|
||||
|
||||
def _coordinates(cfg):
|
||||
return tuple(str(getattr(cfg,name,'') or '') for name in _COORDINATES)
|
||||
|
||||
|
||||
def _stamp(value):
|
||||
try:
|
||||
parsed=datetime.fromisoformat(value.replace('Z','+00:00'))
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
_refuse()
|
||||
return parsed
|
||||
except (AttributeError,TypeError,ValueError):
|
||||
_refuse()
|
||||
|
||||
|
||||
class TaskExecutionAuthorization:
|
||||
"""Opaque process-local prepared state; not a bearer credential or allow."""
|
||||
__slots__=('_action','_contract','_coordinates','_handoff','_used','_lock')
|
||||
|
||||
def __init__(self, issuer=None, *, action=None, contract=None, coordinates=None, handoff=None):
|
||||
if issuer is not _ISSUED:
|
||||
_refuse()
|
||||
object.__setattr__(self,'_action',action)
|
||||
object.__setattr__(self,'_contract',contract)
|
||||
object.__setattr__(self,'_coordinates',coordinates)
|
||||
object.__setattr__(self,'_handoff',json.dumps(handoff,sort_keys=True,separators=(',',':'),allow_nan=False))
|
||||
object.__setattr__(self,'_used',False)
|
||||
object.__setattr__(self,'_lock',Lock())
|
||||
|
||||
def __setattr__(self,name,value):
|
||||
raise AttributeError('prepared task handoff is immutable')
|
||||
|
||||
def as_owner_handoff(self):
|
||||
return strict_json(self._handoff)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskExecutionConsumption:
|
||||
approval_id: str
|
||||
request_digest: str
|
||||
decision_id: str
|
||||
consumed_at: str
|
||||
idempotent: bool
|
||||
|
||||
|
||||
def _validate(cfg, action, contract):
|
||||
if type(contract) is not ExecutionContract or not action.artifacts:
|
||||
_refuse()
|
||||
descriptor=contract.as_dict()
|
||||
artifacts=strict_json(action.artifacts)
|
||||
request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope'))
|
||||
# Rotation/removal and installed-helper changes cannot hide behind a cached
|
||||
# prepared label. Reauthenticate before each strict CAS, without networking.
|
||||
authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(),
|
||||
trust=load_trust(getattr(cfg,'pdp_trust_file',None)),
|
||||
verifier=getattr(cfg,'pdp_verifier',None))
|
||||
if authenticated != envelope:
|
||||
_refuse()
|
||||
window=read_window(cfg)
|
||||
lower,upper=validity_bounds(window=window)
|
||||
validated=validate_decision_envelope(envelope,request,
|
||||
accepted_policy_packages={str(cfg.authorization_policy_package)},
|
||||
accepted_policy_versions={str(cfg.authorization_policy_version)},
|
||||
expected_approval_binding_digest=claim['binding']['pdp_digest'],time_window=window)
|
||||
validate_approval_claim(claim,approval_id=action.binding.approval_id,
|
||||
expected_pdp_digest=envelope['binding']['approval_binding_digest'],
|
||||
require_human_control=True,time_window=window)
|
||||
require_execution_deadline(request,time_window=window,
|
||||
bounds=(claim['validity']['expires_at'],envelope['lifetime']['expires_at']))
|
||||
binding=envelope['binding']
|
||||
if (envelope['lifetime'].get('kind')!='ttl'
|
||||
or _stamp(envelope['lifetime'].get('not_before'))>lower):
|
||||
_refuse()
|
||||
if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr'
|
||||
or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform'
|
||||
or binding.get('context')!=request['context']
|
||||
or request['context'].get('execution_contract')!=descriptor
|
||||
or binding.get('request_digest')!=request_digest(binding)
|
||||
or binding.get('approval_binding_digest')!=approval_binding_digest(binding)
|
||||
or action.binding.request_digest!=validated.request_digest
|
||||
or action.binding.decision_id!=validated.decision_id):
|
||||
_refuse()
|
||||
subject=binding['subject']; resource=binding['resource']
|
||||
if (request['subject']!={'id':'secrets-engine','type':'service'}
|
||||
or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
or subject.get('tenant') not in (None,'tenant:platform')
|
||||
or 'principal' in subject.get('attributes',{})
|
||||
or envelope['subject']!=subject or envelope['resource']!=resource
|
||||
or descriptor['execution_actor']!='service:secrets-engine'
|
||||
or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system'))
|
||||
or resource.get('tenant') not in (None,'tenant:platform')
|
||||
or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items())
|
||||
or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))):
|
||||
_refuse()
|
||||
caller=envelope['provenance'].get('caller')
|
||||
if (not isinstance(caller,dict) or caller.get('mode')!='enforce'
|
||||
or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth'
|
||||
or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline'])
|
||||
or upper >= _stamp(caller.get('not_after'))):
|
||||
_refuse()
|
||||
native=binding_from_check_request(binding)
|
||||
supplied=claim['binding']
|
||||
# Recompute the served native act too: Python equality alone conflates
|
||||
# boolean/integer enrichment and cannot establish canonical correspondence.
|
||||
if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS)
|
||||
or supplied.get('digest')!=claim_binding_digest(**native)
|
||||
or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})):
|
||||
_refuse()
|
||||
return {'execution_contract':descriptor,'approval_id':action.binding.approval_id,
|
||||
'request_digest':validated.request_digest,'decision_id':validated.decision_id,
|
||||
'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'],
|
||||
'submitted_request_digest':validated.submitted_request_digest,
|
||||
'decision_not_before':envelope['lifetime'].get('not_before') or '',
|
||||
'decision_expires_at':envelope['lifetime']['expires_at'],
|
||||
'approval_expires_at':claim['validity']['expires_at']}
|
||||
|
||||
|
||||
def authorize_task_execution(cfg, entry, *, execution_contract):
|
||||
if type(execution_contract) is not ExecutionContract:
|
||||
_refuse()
|
||||
# No injected transports or policy callbacks: use the actual existing PIP/PDP
|
||||
# clients and mandatory signature validators in the owner process.
|
||||
action=authorize_action(cfg,entry,'execute-ccr',execution_contract=execution_contract)
|
||||
if action is None:
|
||||
_refuse()
|
||||
try:
|
||||
handoff=_validate(cfg,action,execution_contract)
|
||||
return TaskExecutionAuthorization(_ISSUED,action=action,contract=ExecutionContract.parse(execution_contract.as_dict()),
|
||||
coordinates=_coordinates(cfg),handoff=handoff)
|
||||
except (ValueError,KeyError,TypeError,RecursionError) as exc:
|
||||
raise DecisionError('strict task execution handoff malformed') from exc
|
||||
|
||||
|
||||
def consume_task_execution(cfg, authorization):
|
||||
if type(authorization) is not TaskExecutionAuthorization:
|
||||
_refuse()
|
||||
with authorization._lock:
|
||||
if authorization._used or _coordinates(cfg)!=authorization._coordinates:
|
||||
_refuse()
|
||||
# Reserve once before all work; failures/unknown outcomes never retry this
|
||||
# prepared object. Platform's durable journal is the cross-process guard.
|
||||
object.__setattr__(authorization,'_used',True)
|
||||
try:
|
||||
handoff=_validate(cfg,authorization._action,authorization._contract)
|
||||
if handoff!=authorization.as_owner_handoff():
|
||||
_refuse()
|
||||
result=consume_approval(base_url=str(cfg.approval_url),
|
||||
token_provider=lambda:approval_token(cfg,scope='approval:consume'),
|
||||
binding=authorization._action.binding,opener=credential_urlopen,strict_response=True)
|
||||
stamp=_stamp(result.consumed_at)
|
||||
window=read_window(cfg);_,upper=validity_bounds(window=window)
|
||||
if (stamp>upper or (handoff['decision_not_before'] and stamp<_stamp(handoff['decision_not_before']))
|
||||
or any(stamp>=_stamp(value) for value in (handoff['decision_expires_at'],handoff['approval_expires_at'],
|
||||
handoff['execution_contract']['operation_deadline']))):
|
||||
_refuse()
|
||||
# Late CAS completion is historical evidence only; refuse native dispatch.
|
||||
_validate(cfg,authorization._action,authorization._contract)
|
||||
return TaskExecutionConsumption(result.approval_id,result.request_digest,result.decision_id,result.consumed_at,result.idempotent)
|
||||
except (ValueError,KeyError,TypeError,RecursionError) as exc:
|
||||
raise DecisionError('strict task execution consumption malformed') from exc
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
"""Source-only builder tests; mocks confer no real grant or principal admission."""
|
||||
import copy
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.approval_consume import consume_approval, _expected_request, authorize_action, resolve_approval_observation
|
||||
from secrets_engine.authorization import approval_binding_digest, build_action_request, request_digest
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.execution_contract import ExecutionContract, catalog_target_digest
|
||||
from tests.test_catalog import VALID
|
||||
from tests.test_consume_binding_join import _Cfg, _token, _opener, APPROVAL_ID
|
||||
from tests.authorization_stub import AuthorizationStub
|
||||
|
||||
H = 'sha256:' + 'a' * 64
|
||||
|
||||
|
||||
def entry():
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw['stage'] = 'prod'
|
||||
raw['approval'] = {'model': 'bootstrap-only', 'authorization_id': APPROVAL_ID, 'purpose': 'metadata-test'}
|
||||
return validate_entry(raw)
|
||||
|
||||
|
||||
def descriptor(e):
|
||||
baseline = build_action_request(e, 'apply', subject_id='secrets-engine', subject_type='service', purpose='metadata-test')
|
||||
return {
|
||||
'contract_version': 'SECRETS-EXEC-20261011-v1', 'operation_id': 'cbfbced0-09d5-48c8-a1e7-a2ea4dc69153',
|
||||
'operation': 'metadata-apply-ccr', 'execution_actor': 'service:secrets-engine',
|
||||
'ccr_id': 'CCR-2099-0001', 'ccr_immutable_sha256': H, 'execution_grant_ccr_id': 'CCR-2099-0002',
|
||||
'grant_immutable_sha256': H, 'catalog_sha256': catalog_target_digest(baseline['context']['catalog_target']),
|
||||
'source_bundle': {name: {'path': '/owner/'+name+'.py', 'sha256': H}
|
||||
for name in ['engine_dispatcher', 'platform_mediator', 'typed_ccr', 'policy_auth']},
|
||||
'backend_origin': 'http://127.0.0.1:18200', 'cluster_uid': 'test-estate-not-live',
|
||||
'operations': [{'method': 'GET', 'path': 'sys/policies/acl/test-policy', 'payload_sha256': H},
|
||||
{'method': 'PUT', 'path': 'auth/netkingdom/role/test-role', 'payload_sha256': H}],
|
||||
'operation_deadline': (datetime.now(timezone.utc)+timedelta(seconds=60)).isoformat(),
|
||||
'backend_budget': {'ttl_seconds': 120, 'uses': 12}, 'expected_before': H, 'expected_after': H,
|
||||
}
|
||||
|
||||
|
||||
def request(e, contract):
|
||||
return build_action_request(e, 'execute-ccr', subject_id='secrets-engine', subject_type='service',
|
||||
purpose='metadata-test', execution_contract=contract)
|
||||
|
||||
|
||||
def served(pdp):
|
||||
claim = pdp.claim()
|
||||
claim['binding']['human_control'] = True
|
||||
return claim
|
||||
|
||||
|
||||
def test_four_builders_preserve_context_and_binding(tmp_path):
|
||||
e = entry(); raw = descriptor(e); contract = ExecutionContract.parse(raw)
|
||||
cfg = _Cfg(_token(tmp_path), authorization_subject_id='secrets-engine', authorization_subject_type='service')
|
||||
expected = request(e, contract)
|
||||
assert expected['context']['human_control'] is True
|
||||
assert _expected_request(cfg,e,'execute-ccr',execution_contract=contract) == expected
|
||||
pdp = AuthorizationStub(approval_id=APPROVAL_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
|
||||
pdp.bind_request(expected); claim = served(pdp)
|
||||
observed = resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=contract,opener=_opener(claim))
|
||||
assert observed.request['context']['execution_contract'] == raw
|
||||
assert approval_binding_digest(observed.request) == request_digest(expected)
|
||||
assert request_digest(observed.request) != request_digest(expected)
|
||||
submitted=[]
|
||||
def check(req, timeout=None):
|
||||
value=json.loads(req.data); submitted.append(value)
|
||||
return _opener(pdp.decision(value))(req)
|
||||
authorized = authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check)
|
||||
assert authorized.binding.request_digest == request_digest(submitted[0])
|
||||
assert submitted[0] == observed.request
|
||||
from tests.test_approval_consume import _Response
|
||||
consume_bodies = []
|
||||
def consume(req, timeout=None):
|
||||
value = json.loads(req.data); consume_bodies.append(value)
|
||||
return _Response(200, {'status':'consumed', 'approval_id':APPROVAL_ID,
|
||||
'request_digest':value['request_digest'], 'decision_id':value['decision_id'],
|
||||
'consumed_at':datetime.now(timezone.utc).isoformat(), 'idempotent':False})
|
||||
confirmed = consume_approval(base_url=cfg.approval_url, token_file=cfg.approval_token_file,
|
||||
binding=authorized.binding, opener=consume)
|
||||
assert consume_bodies == [{'request_digest':request_digest(submitted[0]), 'decision_id':authorized.decision_id}]
|
||||
assert confirmed.request_digest != request_digest(expected)
|
||||
raw['operations'][0]['path']='sys/policies/acl/changed'
|
||||
observed.request['context']['execution_contract']['source_bundle']['typed_ccr']['path']='/changed.py'
|
||||
assert contract.as_dict()['operations'][0]['path']=='sys/policies/acl/test-policy'
|
||||
assert contract.as_dict()['source_bundle']['typed_ccr']['path']=='/owner/typed_ccr.py'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['operation_id','ccr','grant','source','body','method','path','budget','deadline','state','cluster'])
|
||||
def test_changed_binding_cannot_use_original_claim(tmp_path,change):
|
||||
e=entry(); original=descriptor(e); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service')
|
||||
pdp=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
pdp.bind_request(request(e,ExecutionContract.parse(original))); claim=served(pdp)
|
||||
changed=copy.deepcopy(original)
|
||||
if change=='operation_id': changed[change]='775ab8f4-4731-47c8-bd98-342af22885dd'
|
||||
elif change=='ccr': changed['ccr_immutable_sha256']='sha256:'+'b'*64
|
||||
elif change=='grant': changed['grant_immutable_sha256']='sha256:'+'b'*64
|
||||
elif change=='source': changed['source_bundle']['typed_ccr']['sha256']='sha256:'+'b'*64
|
||||
elif change=='body': changed['operations'][1]['payload_sha256']='sha256:'+'b'*64
|
||||
elif change=='method': changed['operations'][0]['method']='PUT'
|
||||
elif change=='path': changed['operations'][0]['path']='sys/policies/acl/changed'
|
||||
elif change=='budget': changed['backend_budget']['uses']=11
|
||||
elif change=='deadline': changed['operation_deadline']=(datetime.now(timezone.utc)+timedelta(seconds=50)).isoformat()
|
||||
elif change=='state': changed['expected_before']='sha256:'+'b'*64
|
||||
elif change=='cluster': changed['cluster_uid']='different-estate'
|
||||
typed=ExecutionContract.parse(changed)
|
||||
assert approval_binding_digest(request(e,typed)) != pdp._pdp_digest
|
||||
def check(req,timeout=None): return _opener(pdp.decision(json.loads(req.data)))(req)
|
||||
with pytest.raises(DecisionError,match='digest'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=typed,opener=_opener(claim),pdp_opener=check)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', ['unknown','extra_nested','missing','wrong_actor','boolean_budget','oversize_budget','sibling_endpoint','path_traversal','source_relative','wrong_hash','bad_deadline','method_object'])
|
||||
def test_closed_schema_refuses(mutation):
|
||||
raw=descriptor(entry())
|
||||
if mutation=='unknown': raw['approved']=True
|
||||
elif mutation=='extra_nested': raw['backend_budget']['renewable']=True
|
||||
elif mutation=='missing': del raw['source_bundle']['typed_ccr']
|
||||
elif mutation=='wrong_actor': raw['execution_actor']='user:admin'
|
||||
elif mutation=='boolean_budget': raw['backend_budget']['uses']=True
|
||||
elif mutation=='oversize_budget': raw['backend_budget']['ttl_seconds']=121
|
||||
elif mutation=='sibling_endpoint': raw['operations'][0]['path']='secret/data/x'
|
||||
elif mutation=='path_traversal': raw['operations'][0]['path']='auth/a/role/../x'
|
||||
elif mutation=='source_relative': raw['source_bundle']['typed_ccr']['path']='foo.py'
|
||||
elif mutation=='wrong_hash': raw['expected_after']='unsafe'
|
||||
elif mutation=='bad_deadline': raw['operation_deadline']='2026-10-11T10:00:00'
|
||||
elif mutation=='method_object': raw['operations'][0]['method']={}
|
||||
with pytest.raises(DecisionError): ExecutionContract.parse(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr'])
|
||||
def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action):
|
||||
e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError
|
||||
contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e))
|
||||
with pytest.raises(DecisionError):
|
||||
authorize_action(cfg,e,action,execution_contract=contract,opener=fetch,pdp_opener=fetch)
|
||||
assert calls==[]
|
||||
|
||||
|
||||
def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path')
|
||||
cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
with pytest.raises(DecisionError,match='catalog'):
|
||||
resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
e=entry(); cfg.approval_url=''
|
||||
with pytest.raises(DecisionError,match='human_control'):
|
||||
resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=ExecutionContract.parse(descriptor(e)))
|
||||
|
||||
|
||||
def test_expired_contract_refuses_before_claim(tmp_path):
|
||||
e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
|
||||
cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
with pytest.raises(DecisionError,match='expired'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changed', ['subject', 'purpose', 'catalog', 'tenant', 'stage'])
|
||||
def test_request_correspondence_changes_refuse_before_authorization(tmp_path, changed):
|
||||
e = entry(); contract = ExecutionContract.parse(descriptor(e))
|
||||
cfg = _Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service')
|
||||
pdp = AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
pdp.bind_request(request(e,contract)); claim = served(pdp)
|
||||
if changed == 'subject': cfg.authorization_subject_id = 'foreign-service'
|
||||
elif changed == 'purpose': e = replace(e,approval={**e.approval, 'purpose':'different-purpose'})
|
||||
elif changed == 'catalog': e = replace(e,path='changed/path')
|
||||
elif changed == 'stage': e = replace(e,stage='test')
|
||||
calls = []
|
||||
def check(req,timeout=None):
|
||||
calls.append('check'); return _opener(pdp.decision(json.loads(req.data)))(req)
|
||||
with pytest.raises(DecisionError):
|
||||
if changed == 'tenant':
|
||||
build_action_request(e,'execute-ccr',subject_id='secrets-engine',subject_type='service',purpose='metadata-test',tenant='tenant:foreign',execution_contract=contract)
|
||||
else:
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check)
|
||||
if changed in {'catalog','stage','tenant'}: assert not calls
|
||||
|
||||
|
||||
def test_raw_dictionary_and_direct_invalid_type_cannot_bypass_validation():
|
||||
e = entry()
|
||||
with pytest.raises(DecisionError): request(e,descriptor(e))
|
||||
with pytest.raises(DecisionError): request(e,ExecutionContract('{}'))
|
||||
|
||||
|
||||
def test_deadline_beyond_claim_refuses_before_pdp(tmp_path):
|
||||
e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat()
|
||||
contract=ExecutionContract.parse(raw); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service')
|
||||
pdp=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
pdp.bind_request(request(e,contract)); calls=[]
|
||||
with pytest.raises(DecisionError,match='exceeds served authority'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(served(pdp)),pdp_opener=lambda *a,**k:calls.append('check'))
|
||||
assert not calls
|
||||
|
||||
# Reuse admitted native producer signing, never bypass responder verification.
|
||||
from tests.test_consume_binding_join import admitted_native_signer
|
||||
|
||||
from tests.signature_support import signature_tools
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Access Engine decision verifier
|
||||
|
||||
The API types and `internal/sign/sign.go` are unchanged producer source, pinned
|
||||
by revision and SHA-256 in `producer-source.json`. License attribution and the
|
||||
original license text are in `LICENSE.access-engine`. This is source vendoring
|
||||
necessitated by the producer's Go `internal` package boundary, not a separately
|
||||
maintained crypto implementation. There are no external Go dependencies.
|
||||
|
||||
Build in this directory: `go build -trimpath -o /tmp/secrets-engine-pdp-verify .`.
|
||||
Install that binary through the deployment's reviewed package/image path and set
|
||||
`SECRETS_ENGINE_PDP_VERIFIER` to its absolute path. Do not compile at request time.
|
||||
The Python wheel alone is insufficient: deployments must install this helper.
|
||||
The `testsupport/signer` executable is test-only and must not be installed.
|
||||
|
||||
From repository root, run `python3 scripts/check_pdp_producer_source.py` before
|
||||
building, and optionally add `--upstream /path/to/access-engine` to detect producer
|
||||
drift. To update: select/review an upstream revision, copy these exact source
|
||||
files and native replay fixtures, update the manifest, run both upstream signature
|
||||
checks and downstream consumer tests, then review the complete diff. Never change
|
||||
canonicalization locally. Producer contract additions fail closed until reviewed.
|
||||
|
||||
The helper reads the original JSON envelope, verifies using Go's producer verifier,
|
||||
and returns only the authenticated typed envelope. The Python boundary rejects
|
||||
duplicate keys, nonfinite values and invalid UTF-8 before invoking it. Unknown
|
||||
typed fields refuse. The unsigned producer mode is never a production fallback.
|
||||
|
||||
The Check API defaults to mandatory authentication. Its deliberately named
|
||||
`allow_unsigned_test_fixture` argument is used only by synthetic transport tests;
|
||||
production `authorize_action` never passes it. The helper runs with an empty
|
||||
environment, so ambient backend and identity credentials cannot reach it.
|
||||
|
||||
For the complete reproducible CLI build and offline fresh-prefix installation,
|
||||
use [the release bundle contract](../docs/cli-release-bundle.md). This replaces
|
||||
manual helper copying for packaged deployments; package and key admission remain
|
||||
separate owner decisions.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// Package sign attaches a detached Ed25519 signature to a decision envelope
|
||||
// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with
|
||||
// the signature field omitted.
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
const AlgorithmEd25519 = "ed25519"
|
||||
|
||||
// CanonicalBytes is the exact payload that is signed and verified: the
|
||||
// envelope as emitted, with signature absent.
|
||||
func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) {
|
||||
envelope.Signature = nil
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal canonical envelope: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error {
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("envelope is required")
|
||||
}
|
||||
if len(private) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("ed25519 private key has length %d", len(private))
|
||||
}
|
||||
if kid == "" {
|
||||
return fmt.Errorf("kid is required")
|
||||
}
|
||||
payload, err := CanonicalBytes(*envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig := ed25519.Sign(private, payload)
|
||||
envelope.Signature = &api.EnvelopeSignature{
|
||||
Mode: api.EnvelopeSignatureSigned,
|
||||
Alg: AlgorithmEd25519,
|
||||
Kid: kid,
|
||||
Value: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error {
|
||||
sig := envelope.Signature
|
||||
if sig == nil || sig.Mode != api.EnvelopeSignatureSigned {
|
||||
return fmt.Errorf("envelope is not signed")
|
||||
}
|
||||
if sig.Alg != AlgorithmEd25519 {
|
||||
return fmt.Errorf("unsupported signature alg %q", sig.Alg)
|
||||
}
|
||||
pub, ok := keys[sig.Kid]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown signature kid %q", sig.Kid)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
payload, err := CanonicalBytes(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, raw) {
|
||||
return fmt.Errorf("signature does not match envelope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Decision verification uses the unchanged Access Engine producer implementation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type input struct {
|
||||
Envelope json.RawMessage `json:"envelope"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var in input
|
||||
if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
decoder := json.NewDecoder(bytes.NewReader(in.Envelope))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
for kid, encoded := range in.Keys {
|
||||
raw, err := base64.RawURLEncoding.Strict().DecodeString(encoded)
|
||||
if err != nil || len(raw) != ed25519.PublicKeySize || kid == "" {
|
||||
return fmt.Errorf("invalid admitted key")
|
||||
}
|
||||
keys[kid] = ed25519.PublicKey(raw)
|
||||
}
|
||||
if err := sign.Verify(envelope, keys); err != nil {
|
||||
return err
|
||||
}
|
||||
// Return the exact typed interpretation authenticated by the producer; Python
|
||||
// must never act on a second interpretation of unverified response fields.
|
||||
return json.NewEncoder(os.Stdout).Encode(envelope)
|
||||
}
|
||||
func main() {
|
||||
if run() != nil {
|
||||
fmt.Fprintln(os.Stderr, "decision signature verification refused")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtectedSystemManifest describes a system that delegates authorization to
|
||||
// flex-auth.
|
||||
type ProtectedSystemManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"`
|
||||
ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"`
|
||||
Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ClientBinding names the key-cape client a protected system's users sign in
|
||||
// through. It is descriptive only: the readback registry projection lists it,
|
||||
// and it never decides an allow (FLEX-DEC-2026-019).
|
||||
type ClientBinding struct {
|
||||
Issuer string `json:"issuer" yaml:"issuer"`
|
||||
ClientID string `json:"client_id" yaml:"client_id"`
|
||||
}
|
||||
|
||||
// ResourceType describes a resource namespace entry owned by a protected system.
|
||||
type ResourceType struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ParentTypes []string `json:"parent_types,omitempty" yaml:"parent_types,omitempty"`
|
||||
ScopeLevel ScopeLevel `json:"scope_level,omitempty" yaml:"scope_level,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ActionDefinition maps a protected-system action to CARING capabilities.
|
||||
type ActionDefinition struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectManifest declares subjects, groups, teams, and tenants for local
|
||||
// registry loading.
|
||||
type SubjectManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Subjects []Subject `json:"subjects,omitempty" yaml:"subjects,omitempty"`
|
||||
Groups []Group `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Teams []Team `json:"teams,omitempty" yaml:"teams,omitempty"`
|
||||
Tenants []Tenant `json:"tenants,omitempty" yaml:"tenants,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Subject is a human, service, automation, agent, or other acting identity.
|
||||
type Subject struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type" yaml:"type"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
OrganizationRelation OrganizationRelation `json:"organization_relation,omitempty" yaml:"organization_relation,omitempty"`
|
||||
Roles []CanonicalRole `json:"roles,omitempty" yaml:"roles,omitempty"`
|
||||
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Claims map[string]any `json:"claims,omitempty" yaml:"claims,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Group is an assignment convenience, not a canonical role.
|
||||
type Group struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Team is a group-like ownership unit used by protected systems.
|
||||
type Team struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Tenant is a structural isolation boundary.
|
||||
type Tenant struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipFact records a relation between subjects, groups, teams, tenants,
|
||||
// and resources.
|
||||
type RelationshipFact struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Subject string `json:"subject" yaml:"subject"`
|
||||
Relation string `json:"relation" yaml:"relation"`
|
||||
Object string `json:"object" yaml:"object"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyPackageMetadata is the frontmatter contract for Rego-in-Markdown
|
||||
// policy packages.
|
||||
type PolicyPackageMetadata struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
||||
Version string `json:"version" yaml:"version"`
|
||||
Status string `json:"status,omitempty" yaml:"status,omitempty"`
|
||||
Package string `json:"package" yaml:"package"`
|
||||
Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
|
||||
Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"`
|
||||
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
|
||||
Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
// AllowTTL is a Go duration (for example "15m") that bounds every allow
|
||||
// this package produces. Omit to use DefaultAllowTTL. "none" or "0s"
|
||||
// means no stated end; the engine denies those allows (§9.7.1).
|
||||
AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"`
|
||||
}
|
||||
|
||||
// CaringPolicyMetadata declares the CARING envelope a policy governs.
|
||||
type CaringPolicyMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"`
|
||||
CanonicalRoles []CanonicalRole `json:"canonical_roles,omitempty" yaml:"canonical_roles,omitempty"`
|
||||
OrganizationRelations []OrganizationRelation `json:"organization_relations,omitempty" yaml:"organization_relations,omitempty"`
|
||||
Scopes []CaringScope `json:"scopes,omitempty" yaml:"scopes,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyFixture binds a check request to an expected decision.
|
||||
type PolicyFixture struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Request CheckRequest `json:"request" yaml:"request"`
|
||||
Expect DecisionExpectation `json:"expect" yaml:"expect"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionExpectation is the compact fixture expectation for policy tests.
|
||||
type DecisionExpectation struct {
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
}
|
||||
|
||||
// CheckRequest is the stable protected-system-facing decision request.
|
||||
type CheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
|
||||
// Purpose is empty on every enforcement request. The readback pin sets it
|
||||
// to PurposeReadback, and it is hashed into every request digest, so an
|
||||
// advisory readback decision can never pass a PEP's §6.4.2 replay test for
|
||||
// an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check
|
||||
// refuse a request that carries it.
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// PurposeReadback marks an advisory application-access readback decision.
|
||||
const PurposeReadback = "readback"
|
||||
|
||||
// BatchCheckRequest evaluates one subject/action against multiple resources.
|
||||
type BatchCheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resources []ResourceRef `json:"resources" yaml:"resources"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectRef is a normalized subject reference in request and decision shapes.
|
||||
type SubjectRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceRef is a normalized resource reference in request and decision shapes.
|
||||
type ResourceRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionEffect is the stable decision outcome vocabulary.
|
||||
type DecisionEffect string
|
||||
|
||||
const (
|
||||
DecisionEffectAllow DecisionEffect = "allow"
|
||||
DecisionEffectDeny DecisionEffect = "deny"
|
||||
DecisionEffectRedact DecisionEffect = "redact"
|
||||
DecisionEffectAuditOnly DecisionEffect = "audit_only"
|
||||
DecisionEffectNotApplicable DecisionEffect = "not_applicable"
|
||||
)
|
||||
|
||||
// DecisionEffects returns the complete effect vocabulary. Every effect is an
|
||||
// emission class in cadence.yaml; a test fails if a constant above is missing
|
||||
// here, so a new effect cannot ship unclassified (FLEX-WP-0031-T03).
|
||||
func DecisionEffects() []DecisionEffect {
|
||||
return []DecisionEffect{
|
||||
DecisionEffectAllow,
|
||||
DecisionEffectDeny,
|
||||
DecisionEffectRedact,
|
||||
DecisionEffectAuditOnly,
|
||||
DecisionEffectNotApplicable,
|
||||
}
|
||||
}
|
||||
|
||||
// Restricts reports whether an effect withholds authority. This classification
|
||||
// does not exempt an effect from required durable emission (GH-DEC-2026-024).
|
||||
func (e DecisionEffect) Restricts() bool {
|
||||
switch e {
|
||||
case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecisionRecordContractV1 is the published decision-record contract identifier.
|
||||
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
|
||||
|
||||
// DecisionEnvelope is the stable response produced by standalone and delegated
|
||||
// evaluators. It is flex-auth's published decision-record contract (§17).
|
||||
type DecisionEnvelope struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"`
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
MatchedPolicyVersion string `json:"matched_policy_version,omitempty" yaml:"matched_policy_version,omitempty"`
|
||||
MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
|
||||
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
|
||||
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
|
||||
Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"`
|
||||
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeSignatureMode is how the responder authenticates the envelope.
|
||||
type EnvelopeSignatureMode string
|
||||
|
||||
const (
|
||||
EnvelopeSignatureSigned EnvelopeSignatureMode = "signed"
|
||||
EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned"
|
||||
)
|
||||
|
||||
// EnvelopeSignature is a detached signature over the canonical envelope with
|
||||
// this field omitted (FLEX-WP-0024). It is not decision material and is not
|
||||
// part of request_digest.
|
||||
type EnvelopeSignature struct {
|
||||
Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"`
|
||||
Alg string `json:"alg,omitempty" yaml:"alg,omitempty"`
|
||||
Kid string `json:"kid,omitempty" yaml:"kid,omitempty"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
}
|
||||
|
||||
func UnsignedEnvelopeSignature() *EnvelopeSignature {
|
||||
return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned}
|
||||
}
|
||||
|
||||
// DecisionLifetimeKind identifies how an allow ends.
|
||||
type DecisionLifetimeKind string
|
||||
|
||||
const (
|
||||
DecisionLifetimeTTL DecisionLifetimeKind = "ttl"
|
||||
)
|
||||
|
||||
// DefaultAllowTTL is the engine default when a policy package omits allow_ttl.
|
||||
const DefaultAllowTTL = 15 * time.Minute
|
||||
|
||||
// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end.
|
||||
const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated"
|
||||
|
||||
// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept,
|
||||
// so the first honest shape is a policy-package-declared TTL.
|
||||
type DecisionLifetime struct {
|
||||
Kind DecisionLifetimeKind `json:"kind" yaml:"kind"`
|
||||
TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// DecisionBinding is the exact normalized authorization request evaluated by
|
||||
// a decision. It lets a consumer verify structured action, target, actor, and
|
||||
// context fields without parsing reason or diagnostic prose.
|
||||
type DecisionBinding struct {
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
// Purpose is "readback" on an advisory readback decision and absent on an
|
||||
// enforcement decision (FLEX-DEC-2026-019).
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
RequestDigest string `json:"request_digest" yaml:"request_digest"`
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest computed with the approval claim
|
||||
// removed from context. It exists to break a circularity: an approval's
|
||||
// pdp_digest is recorded at issue time, but the request that later carries
|
||||
// the claim inside its hashed context has a different RequestDigest by
|
||||
// construction, so a claim can never name the request that carries it.
|
||||
//
|
||||
// This digest is stable across attaching the claim, so it is the value
|
||||
// claim.binding.pdp_digest must equal (GH-DEC-2026-008). It is NOT a replay
|
||||
// identity and must not be used as one: two requests differing only in
|
||||
// which approval was presented share it, and the decision does not.
|
||||
ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"`
|
||||
|
||||
// SubmittedRequestDigest is RequestDigest over the request exactly as the
|
||||
// consumer sent it, before the evaluator overlays registry facts onto the
|
||||
// subject and resource.
|
||||
//
|
||||
// It exists because RequestDigest is not consumer-computable and was
|
||||
// published as though it were. The evaluator hashes the ENRICHED request —
|
||||
// subject.tenant, subject.attributes, and resource attributes the registry
|
||||
// contributed — and the registry is flex-auth's, so a consumer recomputing
|
||||
// the digest over what it sent gets a different value on every real allow.
|
||||
// secrets-engine found this on its first live request (FLEX-DEC-2026-012).
|
||||
//
|
||||
// This is the digest a PEP compares for §6.4 obligation 2's replay test.
|
||||
// Nothing is lost by hashing the pre-enrichment form: enrichment is a
|
||||
// function of the request and the registry snapshot, and
|
||||
// provenance.registry_snapshot_digest already pins the snapshot, so
|
||||
// SubmittedRequestDigest together with that digest identifies the evaluated
|
||||
// request completely.
|
||||
SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalContextKey is the context key carrying an approval-engine
|
||||
// approval-claim. It is excluded from ApprovalBindingDigest and from nothing
|
||||
// else.
|
||||
const ApprovalContextKey = "approval"
|
||||
|
||||
// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request
|
||||
// id, policy version, and caring_context are excluded: id is correlation, the
|
||||
// version is provenance, and caring_context is an input-claim digest.
|
||||
type requestDigestMaterial struct {
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
Resource ResourceRef `json:"resource"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// NewDecisionBinding returns a stable structured binding for the exact request
|
||||
// an evaluator consumed.
|
||||
//
|
||||
// Prefer NewDecisionBindingFor, which also records the digest of the request as
|
||||
// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the
|
||||
// evaluator did not record one" rather than "the two are equal".
|
||||
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
||||
contextCopy := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
contextCopy[key] = value
|
||||
}
|
||||
binding := &DecisionBinding{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: contextCopy,
|
||||
Purpose: request.Purpose,
|
||||
RequestDigest: RequestDigest(request),
|
||||
}
|
||||
if _, carries := request.Context[ApprovalContextKey]; carries {
|
||||
binding.ApprovalBindingDigest = ApprovalBindingDigest(request)
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
// NewDecisionBindingFor records the binding of the evaluated request together
|
||||
// with the digest of the request as submitted.
|
||||
//
|
||||
// The two arguments are the same request before and after the evaluator
|
||||
// overlaid registry facts. Where a request named nothing the registry knows they
|
||||
// are equal, and the field is emitted anyway: a consumer that only sees it on
|
||||
// enriched decisions would build a check that passes by absence.
|
||||
func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding {
|
||||
binding := NewDecisionBinding(evaluated)
|
||||
binding.SubmittedRequestDigest = RequestDigest(submitted)
|
||||
return binding
|
||||
}
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest over the same material with the
|
||||
// approval claim removed from context.
|
||||
//
|
||||
// When a request carries no approval claim the two are identical, which is the
|
||||
// point: an approval issued against a claim-free Check records that Check's
|
||||
// request_digest, and the later claim-bearing request reproduces the same value
|
||||
// here. Comparing it to claim.binding.pdp_digest establishes correspondence by
|
||||
// identity rather than by translating between two action vocabularies
|
||||
// (GH-DEC-2026-008).
|
||||
func ApprovalBindingDigest(request CheckRequest) string {
|
||||
if _, carries := request.Context[ApprovalContextKey]; !carries {
|
||||
return RequestDigest(request)
|
||||
}
|
||||
stripped := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
if key == ApprovalContextKey {
|
||||
continue
|
||||
}
|
||||
stripped[key] = value
|
||||
}
|
||||
if len(stripped) == 0 {
|
||||
stripped = nil
|
||||
}
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: stripped,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
|
||||
// JSON of tenant, subject, action, resource, context, and purpose. Purpose is
|
||||
// omitted when empty, so enforcement digests are unchanged by its existence.
|
||||
func RequestDigest(request CheckRequest) string {
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: request.Context,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON.
|
||||
// encoding/json sorts map keys, so two equal Go values agree.
|
||||
func CanonicalDigest(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256(nil)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// InputClaimDigests hashes the request-time claim classes the evaluator joined.
|
||||
func InputClaimDigests(request CheckRequest) map[string]string {
|
||||
digests := make(map[string]string)
|
||||
if len(request.Context) > 0 {
|
||||
digests["context"] = CanonicalDigest(request.Context)
|
||||
}
|
||||
if request.CaringContext != nil {
|
||||
digests["caring_context"] = CanonicalDigest(request.CaringContext)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// DecisionCompletion carries evaluator-side inputs used to finish an envelope.
|
||||
type DecisionCompletion struct {
|
||||
AllowTTL string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CompleteDecision stamps contract version, input-claim digests, decision time,
|
||||
// and an explicit allow lifetime. An allow with no stated end becomes a deny.
|
||||
func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if envelope.ContractVersion == "" {
|
||||
envelope.ContractVersion = DecisionRecordContractV1
|
||||
}
|
||||
if envelope.Provenance.InputClaimDigests == nil {
|
||||
envelope.Provenance.InputClaimDigests = InputClaimDigests(request)
|
||||
}
|
||||
ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now)
|
||||
}
|
||||
|
||||
// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow
|
||||
// would have no stated end. Invalid strings return an error so package
|
||||
// validation can reject them.
|
||||
func ParseAllowTTL(declared string) (time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(declared)
|
||||
if trimmed == "" {
|
||||
return DefaultAllowTTL, nil
|
||||
}
|
||||
if strings.EqualFold(trimmed, "none") {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A
|
||||
// missing or zero TTL denies the allow rather than mint a standing grant.
|
||||
func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
if envelope.Provenance.DecisionTime == "" {
|
||||
envelope.Provenance.DecisionTime = now.Format(time.RFC3339)
|
||||
}
|
||||
if envelope.Effect != DecisionEffectAllow {
|
||||
return
|
||||
}
|
||||
ttl, err := ParseAllowTTL(declaredTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
if envelope.Diagnostics == nil {
|
||||
envelope.Diagnostics = map[string]any{}
|
||||
}
|
||||
if envelope.Reason != "" {
|
||||
envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason
|
||||
}
|
||||
envelope.Effect = DecisionEffectDeny
|
||||
envelope.Reason = ReasonAllowLifetimeUnstated
|
||||
envelope.MatchedRule = ReasonAllowLifetimeUnstated
|
||||
envelope.Lifetime = nil
|
||||
return
|
||||
}
|
||||
display := strings.TrimSpace(declaredTTL)
|
||||
if display == "" {
|
||||
display = "15m"
|
||||
}
|
||||
envelope.Lifetime = &DecisionLifetime{
|
||||
Kind: DecisionLifetimeTTL,
|
||||
TTL: display,
|
||||
NotBefore: now.Format(time.RFC3339),
|
||||
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ActionAuthorizationStatus is the lifecycle state of a durable authorization.
|
||||
type ActionAuthorizationStatus string
|
||||
|
||||
const (
|
||||
ActionAuthorizationPending ActionAuthorizationStatus = "pending"
|
||||
ActionAuthorizationApproved ActionAuthorizationStatus = "approved"
|
||||
ActionAuthorizationDenied ActionAuthorizationStatus = "denied"
|
||||
ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded"
|
||||
ActionAuthorizationExpired ActionAuthorizationStatus = "expired"
|
||||
ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked"
|
||||
)
|
||||
|
||||
// ActionAuthorization joins a durable approval lifecycle to one exact
|
||||
// flex-auth request and decision. Storage and approval collection remain the
|
||||
// responsibility of the organizational decision authority.
|
||||
type ActionAuthorization struct {
|
||||
SchemaVersion string `json:"schema_version" yaml:"schema_version"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Status ActionAuthorizationStatus `json:"status" yaml:"status"`
|
||||
SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"`
|
||||
Request CheckRequest `json:"request" yaml:"request"`
|
||||
Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"`
|
||||
Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"`
|
||||
Decision DecisionEnvelope `json:"decision" yaml:"decision"`
|
||||
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationValidity bounds execution of an approved action.
|
||||
type ActionAuthorizationValidity struct {
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationApprovals declares the approval threshold and evidence.
|
||||
type ActionAuthorizationApprovals struct {
|
||||
RequiredCount int `json:"required_count" yaml:"required_count"`
|
||||
Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationApprovalEntry is one authenticated approver's evidence.
|
||||
type ActionAuthorizationApprovalEntry struct {
|
||||
SubjectID string `json:"subject_id" yaml:"subject_id"`
|
||||
ApprovedAt string `json:"approved_at" yaml:"approved_at"`
|
||||
Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
// Obligation describes a follow-up behavior required by a decision.
|
||||
type Obligation struct {
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
// It is not decision material: the same request from a different authenticated
|
||||
// caller must decide identically. Mode is required. Under disabled the object
|
||||
// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009).
|
||||
type CallerProvenance struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Principal string `json:"principal,omitempty" yaml:"principal,omitempty"`
|
||||
Audience string `json:"audience,omitempty" yaml:"audience,omitempty"`
|
||||
NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"`
|
||||
}
|
||||
|
||||
// CaringDecisionMetadata carries CARING descriptor and conformance details in
|
||||
// a decision envelope.
|
||||
type CaringDecisionMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"`
|
||||
RestrictionsEvaluated []Restriction `json:"restrictions_evaluated,omitempty" yaml:"restrictions_evaluated,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent is the local log shape for decisions and exposure events.
|
||||
type AuditEvent struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
DecisionID string `json:"decision_id,omitempty" yaml:"decision_id,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Resource ResourceRef `json:"resource,omitempty" yaml:"resource,omitempty"`
|
||||
Action string `json:"action,omitempty" yaml:"action,omitempty"`
|
||||
Effect DecisionEffect `json:"effect,omitempty" yaml:"effect,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
"revision": "62ac2b4e99dec2005c7c407fe633612578a7907d",
|
||||
"files": {
|
||||
"pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
"pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f",
|
||||
"pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9",
|
||||
"internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62"
|
||||
},
|
||||
"fixtures": {
|
||||
"decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8",
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Synthetic test-only signer with the producer's published nonproduction seed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
seed := byte(0x42)
|
||||
kid := "testdata-ed25519"
|
||||
if len(os.Args) > 1 {
|
||||
seed = 0x43
|
||||
kid = "next-admitted"
|
||||
}
|
||||
var env api.DecisionEnvelope
|
||||
if json.NewDecoder(os.Stdin).Decode(&env) != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
json.NewEncoder(os.Stdout).Encode(env)
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
id: SECRETS-WP-0013
|
||||
type: workplan
|
||||
title: "Design and prove task-scoped protected OpenBao execution"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: proposed
|
||||
flavor: planning
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-11"
|
||||
updated: "2026-10-16"
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "5e38aecc-85ca-5f55-94c6-9fbb08604819"
|
||||
---
|
||||
|
||||
Execution-contract direction remains proposed; the narrow local readiness guard
|
||||
is now reviewed, published and verified in the owner checkout. No native
|
||||
execution authority or general entitlement adoption is inferred.
|
||||
Existing SECRETS-WP-0008-T06 remains open for actual adoption. CCR38 is an
|
||||
unchanged identity-only login and is not a protected execution entitlement.
|
||||
|
||||
## Define the exact mediated execution contract
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T01
|
||||
status: wait
|
||||
needs_human: false
|
||||
blocked_on: message-from:railiance-platform
|
||||
blocking_reason: "Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred."
|
||||
priority: high
|
||||
state_hub_task_id: "b2f0d462-a542-518b-a26b-b7ec07b3c7c1"
|
||||
```
|
||||
|
||||
Design SECRETS-EXEC-20261011-v1 in docs/task-scoped-openbao-execution-contract.md
|
||||
binds action, target, payload/source digests, initiating identity, exact PDP/CAS,
|
||||
owner-held ephemeral two-path token, bounded TTL/uses, durable admission and
|
||||
cleanup. Independent Platform/Secrets Engine/identity/approval/PDP owner returns
|
||||
are required. No foreign review is written by this draft.
|
||||
|
||||
2026-10-11 source-only preparation: typed execution_contract is staged through
|
||||
all four request builders. The new descriptor is restricted to execute-ccr,
|
||||
requires prod/platform/human control and binds the catalog fingerprint before
|
||||
claim. Existing apply/other actions cannot borrow this descriptor. 99 focused
|
||||
source checks verify propagation, canonical approval/full consume joins,
|
||||
immutable copies and changed-binding refusals. Detailed schema/limitations are
|
||||
in the execution-contract document. No source owner assent, initiating-principal
|
||||
admission, new policy package, ledger/mediator/backend or native acceptance is
|
||||
claimed. Independent source review remains required. Task statuses are unchanged.
|
||||
|
||||
2026-10-11: D3 response-wrapped primitive now verifies native creation path and bounded TTL/accessor, rejects overwrite/symlink/shared-parent paths, and reserves/fsyncs the private recipient before mint. Backend/write uncertainty retains a non-replayable reservation. Disposable OpenBao proves one successful unwrap and native consumed-token HTTP400 denial, not a production lane or entitlement. Separate delivery contracts cannot reuse metadata execute-ccr authority. Source design is ready for independent owner review; RPF-IN-0012 requests the Platform return. Identity/approval/PDP returns and the complete native mediated path remain open; no founder decision or foreign owner assent is fabricated.
|
||||
|
||||
## Prevent consumption without execution readiness
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4ad61917-506d-5c12-a715-3d336e6b95e0"
|
||||
```
|
||||
|
||||
Completed the narrow local implementation and source-verification obligation.
|
||||
Secrets Engine source commit 97dfa5ea8fbfe998094bf03a3581ee0c5313ac30 was published
|
||||
through the root's C17 source-publication record. Independent Platform source
|
||||
acknowledgment is published in 46f5c956b52524d9d8177002bb60020c7e558039 and
|
||||
`railiance-platform/docs/reviews/2026-10-11-secrets-execution-readiness-v2-platform-review.json`.
|
||||
The root verified all 36 focused guard/auth/consume/privileged-evidence checks in
|
||||
the actual owner checkout; Platform's independent source review passed 58 checks.
|
||||
Current published CLI/auth/test hashes match those accepted exact sources.
|
||||
|
||||
Configured auto and explicit service-jwt refuse the ten guarded backend handlers
|
||||
before claim/PDP/CAS/login/backend work because no task-execution backend exists.
|
||||
Named bootstrap/env paths retain their existing approval/stance gates; login-self
|
||||
probes remain available. The unchanged ambient route/readiness surface is outside
|
||||
this guard's scope and is not service-JWT adoption. Task completion covers only
|
||||
this local fail-closed guard, its source review/publication and source verification.
|
||||
T01 remains progress, T03 remains wait with qualified dependencies, WP13 remains
|
||||
proposed, and SECRETS-WP-0008-T06 remains open for actual protected execution
|
||||
adoption/native effect, denial/no-fallback and cleanup evidence. No task-scoped
|
||||
backend entitlement, provider default flip, founder native go or native acceptance
|
||||
is claimed.
|
||||
|
||||
## Implement and prove one native metadata action
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T03
|
||||
status: wait
|
||||
depends_on: [SECRETS-WP-0013-T01, RPF-WP-0035-T12]
|
||||
wait_reason: "Wait for independent execution-contract acceptance and Platform task-scoped entitlement design/materialization, then exact founder native go."
|
||||
priority: high
|
||||
state_hub_task_id: "5f8f7c41-5921-543f-a7c6-0b1350ea6d82"
|
||||
```
|
||||
|
||||
After T01 independent owner acceptance, implement the narrow execute-ccr surface,
|
||||
Platform task-grant mediator, bound policy package and admission ledger. Select
|
||||
an actual unexecuted approved metadata CCR, exact founder go and attended owner
|
||||
custody. Prove real protected effect, sibling/body/refusal/replay counterexamples
|
||||
and confirmed revocation/cleanup. No applied CCR38 replay or broad default flip.
|
||||
Native metadata proof does not certify provision/exec delivery or all T06 scope.
|
||||
|
||||
2026-10-11 source revision v2: qualified T03 dependencies point to the owning design and Platform execution-entitlement task. WP13 owns the missing task-scoped execution seam; WP8-T06 retains production adoption and its full workflow proof. Source publication and fixture checks do not imply native acceptance. Original v1 stage remains unchanged.
|
||||
|
||||
2026-10-11 completed bounded source preparation within T01: repaired the actual
|
||||
Python/Go canonical request digest mismatch for valid Unicode/HTML purpose and
|
||||
nested strings, optional maps and decoded numeric formatting. Full request and
|
||||
approval-free request bindings retain the opaque sha256 prefix; catalogue,
|
||||
claim-binding and body fingerprints remain distinct and unchanged. Real Access
|
||||
Go-oracle cases and unchanged canonical/replay/approval/descriptor fixtures pass.
|
||||
This is source preparation awaiting independent technical review/publication,
|
||||
not a formal owner assent, policy deployment or native execution milestone. T01
|
||||
remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations
|
||||
and retained failed test observations are in the source preparation receipt.
|
||||
|
||||
|
||||
2026-10-16 bounded source handoff preparation: `task_execution_handoff.py` uses
|
||||
the actual serving approval/mandatory signed Check path and retained detached
|
||||
artifacts to check exact descriptor/request/native approval/caller/lifetime joins,
|
||||
then performs one strict exact-response CAS only after Platform journal admission.
|
||||
It accepts no inbound boolean/mapping/authorizer callback and grants no native
|
||||
execution. Platform independently confirms durable consumption with the actual
|
||||
Approval provider client before native dispatch. Focused local HTTP/native Go
|
||||
producer-signature source checks cover altered joins/caller/grant, missing fields,
|
||||
coercion/idempotence, expiry, signer removal and replay refusal. No service binding
|
||||
propagation, policy gate flip, identity mapping admission or production trust/key
|
||||
change was made. Independent source integration/review and actual whole-provider
|
||||
proof remain required. T01/T03 and WP8-T06 retain their open native obligations.
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
id: SECRETS-WP-0014
|
||||
type: workplan
|
||||
title: "Constrain PDP transport and authenticate decision responses"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: secrets-engine
|
||||
created: "2026-10-12"
|
||||
updated: "2026-10-16"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "7252469d-d7a5-5c18-8911-b511b8c6a736"
|
||||
---
|
||||
|
||||
Native owner work for CUST-WP-0080 G13. Reuses the accepted Check contract and
|
||||
FLEX-WP-0024 producer signature surface; does not create another PDP or grant
|
||||
any catalog-lane execution authority. SECRETS-WP-0013 still owns task-scoped
|
||||
protected execution. Transport review is complete; key admission and native
|
||||
rollout remain separate gates below.
|
||||
|
||||
## Prevent the Check credential from leaving the admitted local transport
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0014-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8baba9a3-3513-5f02-bafe-ed9dce743173"
|
||||
```
|
||||
|
||||
Reproduced redirects (301/302/303), environment proxy forwarding, malformed URL
|
||||
handling, oversized-body truncation and invalid UTF-8 against the prior source.
|
||||
The bounded fix disables redirects and ambient proxies/global openers, requires
|
||||
a literal loopback bare origin before credential access, caps the complete
|
||||
response body and rejects invalid UTF-8 as a DecisionError. The approval consumer
|
||||
no longer overrides this safe default with urlopen. Synthetic local-server tests
|
||||
cover both entry points, all five redirect status codes and proxy forwarding.
|
||||
No real credentials or protected backend were touched. Source verification is
|
||||
recorded in the Custodian evidence directory for 2026-10-12, pdp-transport.json.
|
||||
This closes the source transport defects only; T02/T03 remain mandatory.
|
||||
|
||||
## Implement independently anchored responder authentication
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0014-T02
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "877782e5-045b-5186-8b95-82e25db21a5a"
|
||||
```
|
||||
|
||||
Review producer canonical signature bytes and the native pin's unsigned/signed
|
||||
mode. Establish independently admitted verification keys and exact issuer/pin
|
||||
binding with AccessEngine and Platform; an unauthenticated keys endpoint on the
|
||||
same responder is not a trust anchor. Reuse the native producer fixtures rather
|
||||
than inventing a Python canonicalization. Require valid signed envelopes on the
|
||||
production consumer path; prove forged allow, altered payload, unknown key,
|
||||
wrong issuer/pin, unsigned/downgrade, expired and rotation overlap/refusal cases.
|
||||
Track any temporary exception using all required SEC-DSM-EXC fields and actual
|
||||
approval; neither source support nor a loopback URL is that approval.
|
||||
|
||||
2026-10-16 source preparation: production `authorize_action` now requires an
|
||||
independently admitted local trust file and installed verifier before approval/PDP
|
||||
credential reads. The Go helper vendors exact producer API/signature sources,
|
||||
checks source hashes, authenticates the original response and returns only its
|
||||
verified typed interpretation. Signed evaluator/mode and exact package/version/
|
||||
artifact digest are bound to admitted public keys. Unknown/unsigned/altered
|
||||
responses refuse; existing request/approval/lifetime/CAS checks remain required.
|
||||
Native replay, real producer signing and consumer-boundary tests cover downgrade,
|
||||
issuer/pin, request joins, and distinct-key overlap/removal. Build/install and
|
||||
source update procedures are documented in `verifier/README.md`.
|
||||
|
||||
This is an isolated source implementation awaiting integration/independent review.
|
||||
No production verification keys, native pin signed-mode change, trust admission,
|
||||
owner assent or rollout has occurred. T02 stays progress because independent
|
||||
key admission remains part of its scope; T03 stays wait. WP13 execution and
|
||||
identity-only CCR38 are unchanged. Custodian source/verification handoff:
|
||||
`docs/changes/netkingdom-target-2026-10-16/decision-auth/` in the Custodian repo.
|
||||
|
||||
2026-10-16 packaging preparation: `scripts/build_release_bundle.py` and its
|
||||
separate offline installer deliver the existing wheel plus the reviewed producer
|
||||
verifier together into a new owner-controlled prefix. Source/hash drift checks,
|
||||
artifact/toolchain receipts and a narrow fixed-helper launcher remove the silent
|
||||
wheel-only deployment gap. Disposable build/install tests prove deterministic
|
||||
artifacts, signed HTTP consumption and missing/tampered artifact refusal; they do
|
||||
not admit packages, keys or a production prefix. T02 remains progress and T03
|
||||
wait; no native installation, trust change or protected entitlement is claimed.
|
||||
|
||||
## Verify the admitted native consumer and rotation path
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0014-T03
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [SECRETS-WP-0014-T02]
|
||||
blocking_reason: "Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet."
|
||||
state_hub_task_id: "85aa4c78-b66d-5201-8e31-0ed50e7eaeec"
|
||||
```
|
||||
|
||||
Under an exact reviewed rollout, prove the real pin and consumer accept the
|
||||
admitted signer, reject forgery/downgrade, rotate without accepting removed keys,
|
||||
and preserve request/approval binding, lifetime and single-use consumption.
|
||||
Retain rollback and failure evidence. Update CUST-WP-0080 G13 only with these
|
||||
native receipts; do not equate passing transport tests with closure.
|
||||
|
||||
|
||||
2026-10-16 native preparation (no rollout): `docs/proposals/native-cli-admission/`
|
||||
now scopes the existing CLI/lifecycle signer stage, exact delegated PEP denominator,
|
||||
independent key/policy admission, owner-controlled bundle/config placement and
|
||||
rotation/rollback refusal. Canonical ff1a13d (packaging plus strict handoff) was
|
||||
rebuilt in an isolated clean checkout; artifact receipts are in Custodian
|
||||
`decision-native-preparation/`. Production kid/public fingerprint/custody receipt,
|
||||
producer candidate OCI and actual runtime/config placement remain missing. The
|
||||
empty-key trust template deliberately refuses. No install, key read, caller mint,
|
||||
protected effect, policy switch or trust admission occurred. T02 remains progress,
|
||||
T03 wait; task source/live obligations remain separately in SECRETS-WP-0013.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
........................................................................ [ 69%]
|
||||
............................... [100%]
|
||||
103 passed in 14.94s
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py
|
||||
index 1cf4140..e4865bb 100644
|
||||
--- a/src/secrets_engine/approval_consume.py
|
||||
+++ b/src/secrets_engine/approval_consume.py
|
||||
@@ -382,7 +382,8 @@ def authorize_action(
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope},
|
||||
+ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope,
|
||||
+ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")},
|
||||
sort_keys=True,separators=(",",":"),allow_nan=False),
|
||||
)
|
||||
|
||||
diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py
|
||||
index 24fa8d3..d9bd0b9 100644
|
||||
--- a/src/secrets_engine/decision_signature.py
|
||||
+++ b/src/secrets_engine/decision_signature.py
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
+import math
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -24,8 +25,15 @@ def _unique_object(pairs):
|
||||
return result
|
||||
|
||||
|
||||
+def _finite_float(value):
|
||||
+ parsed = float(value)
|
||||
+ if not math.isfinite(parsed):
|
||||
+ raise ValueError("nonfinite JSON number")
|
||||
+ return parsed
|
||||
+
|
||||
+
|
||||
def strict_json(data):
|
||||
- return json.loads(data, object_pairs_hook=_unique_object,
|
||||
+ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float,
|
||||
parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number")))
|
||||
|
||||
|
||||
@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]:
|
||||
raise DecisionError("access-engine responder trust is missing or invalid") from exc
|
||||
|
||||
|
||||
+class AuthenticatedEnvelope(dict):
|
||||
+ """Verified typed values with exact producer JSON retained for reauthentication."""
|
||||
+ def __init__(self, values, authenticated_json):
|
||||
+ super().__init__(values)
|
||||
+ self.authenticated_json = authenticated_json
|
||||
+
|
||||
+
|
||||
def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict:
|
||||
if verifier is None or not Path(verifier).is_absolute():
|
||||
raise DecisionError("access-engine signature verifier is not installed")
|
||||
try:
|
||||
+ strict_json(body.decode("utf-8"))
|
||||
result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}',
|
||||
capture_output=True, env={}, timeout=3, check=False)
|
||||
if result.returncode != 0 or len(result.stdout) > 524288:
|
||||
@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) ->
|
||||
provenance = authenticated['provenance']
|
||||
if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')):
|
||||
raise ValueError("issuer/pin mismatch")
|
||||
- return authenticated
|
||||
- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc:
|
||||
+ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8"))
|
||||
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc:
|
||||
raise DecisionError("access-engine responder authentication failed") from exc
|
||||
diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py
|
||||
index 9772861..eea6a85 100644
|
||||
--- a/src/secrets_engine/task_execution_handoff.py
|
||||
+++ b/src/secrets_engine/task_execution_handoff.py
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
+import re
|
||||
from threading import Lock
|
||||
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
@@ -85,10 +86,10 @@ def _validate(cfg, action, contract):
|
||||
request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope'))
|
||||
# Rotation/removal and installed-helper changes cannot hide behind a cached
|
||||
# prepared label. Reauthenticate before each strict CAS, without networking.
|
||||
- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(),
|
||||
+ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"),
|
||||
trust=load_trust(getattr(cfg,'pdp_trust_file',None)),
|
||||
verifier=getattr(cfg,'pdp_verifier',None))
|
||||
- if authenticated != envelope:
|
||||
+ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False):
|
||||
_refuse()
|
||||
window=read_window(cfg)
|
||||
lower,upper=validity_bounds(window=window)
|
||||
@@ -140,6 +141,22 @@ def _validate(cfg, action, contract):
|
||||
or supplied.get('digest')!=claim_binding_digest(**native)
|
||||
or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})):
|
||||
_refuse()
|
||||
+ observation=envelope['provenance'].get('approval_observation')
|
||||
+ if observation is not None:
|
||||
+ # Signed producer source evidence supplements the original carried claim;
|
||||
+ # it cannot replace original context, extend freshness, or authorize CAS.
|
||||
+ if (not isinstance(observation,dict)
|
||||
+ or any(not isinstance(observation.get(k),str) or not observation[k]
|
||||
+ for k in ('source_id','source_revision'))
|
||||
+ or any(not isinstance(observation.get(k),str)
|
||||
+ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None
|
||||
+ for k in ('configuration_digest','claim_digest','native_binding_digest'))
|
||||
+ or observation['native_binding_digest']!=supplied['digest']
|
||||
+ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at'))
|
||||
+ or _stamp(observation.get('validated_at'))>upper
|
||||
+ or upper>=_stamp(observation.get('not_after'))
|
||||
+ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))):
|
||||
+ _refuse()
|
||||
return {'execution_contract':descriptor,'approval_id':action.binding.approval_id,
|
||||
'request_digest':validated.request_digest,'decision_id':validated.decision_id,
|
||||
'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'],
|
||||
diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
new file mode 100644
|
||||
index 0000000..46adb26
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
@@ -0,0 +1,210 @@
|
||||
+[
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":true}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": true
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}",
|
||||
+ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}",
|
||||
+ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}",
|
||||
+ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":false}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": false
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}",
|
||||
+ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}",
|
||||
+ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":-0.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": -0.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}",
|
||||
+ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}",
|
||||
+ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-6}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-06
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}",
|
||||
+ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e20}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+20
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}",
|
||||
+ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e21}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+21
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}",
|
||||
+ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":2.5e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 2.5e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}",
|
||||
+ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":9007199254740993}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 9007199254740993
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}",
|
||||
+ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "html": "<>&",
|
||||
+ "unicode": "é雪😀",
|
||||
+ "separator": "
"
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"
\",\"unicode\":\"é雪😀\"}}",
|
||||
+ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "b": [
|
||||
+ true,
|
||||
+ 1,
|
||||
+ 1.0,
|
||||
+ null,
|
||||
+ {
|
||||
+ "z": "x",
|
||||
+ "a": "y"
|
||||
+ }
|
||||
+ ],
|
||||
+ "a": {}
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}",
|
||||
+ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51"
|
||||
+ }
|
||||
+]
|
||||
diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py
|
||||
new file mode 100644
|
||||
index 0000000..3c47a1d
|
||||
--- /dev/null
|
||||
+++ b/tests/test_trusted_successor.py
|
||||
@@ -0,0 +1,80 @@
|
||||
+"""Owner signature interoperability, independent synthetic admission only."""
|
||||
+import copy
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+import subprocess
|
||||
+
|
||||
+import pytest
|
||||
+from secrets_engine.decision_signature import authenticate_response,load_trust
|
||||
+from secrets_engine.errors import DecisionError
|
||||
+from tests.test_decision_signature import FIXTURE,trust_file,verifier
|
||||
+from tests.signature_support import signature_tools
|
||||
+from tests.test_task_execution_handoff import lane,prepared,consume_task_execution
|
||||
+
|
||||
+VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text())
|
||||
+
|
||||
+@pytest.mark.parametrize('vector',VECTORS)
|
||||
+def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env.pop('signature',None)
|
||||
+ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'}
|
||||
+ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode()
|
||||
+ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json'])
|
||||
+ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"')
|
||||
+ assert tampered!=signed
|
||||
+ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_unknown_provenance_refuses(trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True
|
||||
+ with pytest.raises(DecisionError):
|
||||
+ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools):
|
||||
+ import tests.test_task_execution_handoff as fixtures
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def raw_sign(env,tools):
|
||||
+ env['diagnostics']={'numeric':'LEXEME'}
|
||||
+ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode()
|
||||
+ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign)
|
||||
+ authorization=prepared(lane)
|
||||
+ assert '1e-7' in authorization._action.artifacts
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+def source_evidence(envelope,claim):
|
||||
+ from datetime import datetime,timezone
|
||||
+ return {'source_id':'synthetic-source','source_revision':'synthetic-revision',
|
||||
+ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64,
|
||||
+ 'native_binding_digest':claim['binding']['digest'],
|
||||
+ 'observed_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'validated_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'not_after':envelope['lifetime']['expires_at']}
|
||||
+
|
||||
+
|
||||
+def test_signed_source_evidence_keeps_original_context_and_cas(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim']))
|
||||
+ authorization=prepared(lane)
|
||||
+ snapshot=json.loads(authorization._action.artifacts)
|
||||
+ assert snapshot['envelope']['binding']['context']==snapshot['request']['context']
|
||||
+ assert snapshot['request']['context']['approval']==state['claim']
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('field,value',[
|
||||
+ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'),
|
||||
+ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'),
|
||||
+ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')])
|
||||
+def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def change(env):
|
||||
+ evidence=source_evidence(env,state['claim']);evidence[field]=value
|
||||
+ env['provenance']['approval_observation']=evidence
|
||||
+ state['decision_change']=change
|
||||
+ with pytest.raises(DecisionError):prepared(lane)
|
||||
+ assert 'consume' not in state['calls']
|
||||
diff --git a/verifier/README.md b/verifier/README.md
|
||||
index a63b2a0..6a1b56b 100644
|
||||
--- a/verifier/README.md
|
||||
+++ b/verifier/README.md
|
||||
@@ -1,7 +1,7 @@
|
||||
# Access Engine decision verifier
|
||||
|
||||
The API types and `internal/sign/sign.go` are unchanged producer source, pinned
|
||||
-by revision and SHA-256 in `producer-source.json`. License attribution and the
|
||||
+by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the
|
||||
original license text are in `LICENSE.access-engine`. This is source vendoring
|
||||
necessitated by the producer's Go `internal` package boundary, not a separately
|
||||
maintained crypto implementation. There are no external Go dependencies.
|
||||
@@ -33,3 +33,15 @@ For the complete reproducible CLI build and offline fresh-prefix installation,
|
||||
use [the release bundle contract](../docs/cli-release-bundle.md). This replaces
|
||||
manual helper copying for packaged deployments; package and key admission remain
|
||||
separate owner decisions.
|
||||
+
|
||||
+The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal,
|
||||
+identified by its patch SHA-256 in `producer-source.json`; this is source review,
|
||||
+not a claim of published producer revision or runtime/key admission. The wrapper
|
||||
+uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in
|
||||
+signed maps. It retains exact authenticated producer JSON through strict
|
||||
+preparation and CAS reauthentication; Python does not recreate signed bytes.
|
||||
+Approval's fourteen native binding vectors are signature interoperability cases,
|
||||
+not an expansion of existing request numeric admission. Signed
|
||||
+`approval_observation` supplements the original immutable carried claim;
|
||||
+strict handoff checks its native digest and observation/deadline bounds. It never
|
||||
+replaces original context or freshness.
|
||||
diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go
|
||||
index fc607f7..011f8f7 100644
|
||||
--- a/verifier/internal/sign/sign.go
|
||||
+++ b/verifier/internal/sign/sign.go
|
||||
@@ -4,10 +4,12 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
+ "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
+ "io"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+
|
||||
+// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
|
||||
+// Legacy producer envelopes remain byte-compatible. It does not invent another
|
||||
+// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
|
||||
+func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
|
||||
+ var envelope api.DecisionEnvelope
|
||||
+ decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
+ decoder.UseNumber()
|
||||
+ decoder.DisallowUnknownFields()
|
||||
+ if err := decoder.Decode(&envelope); err != nil {
|
||||
+ return envelope, err
|
||||
+ }
|
||||
+ if _, err := decoder.Token(); err != io.EOF {
|
||||
+ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
|
||||
+ }
|
||||
+ return envelope, nil
|
||||
+}
|
||||
diff --git a/verifier/main.go b/verifier/main.go
|
||||
index 1f3f9de..7449c64 100644
|
||||
--- a/verifier/main.go
|
||||
+++ b/verifier/main.go
|
||||
@@ -2,13 +2,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
- "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
@@ -23,10 +21,8 @@ func run() error {
|
||||
if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
- var envelope api.DecisionEnvelope
|
||||
- decoder := json.NewDecoder(bytes.NewReader(in.Envelope))
|
||||
- decoder.DisallowUnknownFields()
|
||||
- if err := decoder.Decode(&envelope); err != nil {
|
||||
+ envelope, err := sign.DecodeEnvelope(in.Envelope)
|
||||
+ if err != nil {
|
||||
return err
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go
|
||||
index 3baa784..ae11a2d 100644
|
||||
--- a/verifier/pkg/api/canonical.go
|
||||
+++ b/verifier/pkg/api/canonical.go
|
||||
@@ -635,16 +635,30 @@ type Obligation struct {
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
- Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
- Mode string `json:"mode" yaml:"mode"`
|
||||
- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+ Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
+ Mode string `json:"mode" yaml:"mode"`
|
||||
+ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
+ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
+ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
+ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
+ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
+ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
+ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
+ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"`
|
||||
+ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+}
|
||||
+
|
||||
+// ApprovalObservationProvenance binds fresh server-owned fact evidence without
|
||||
+// rewriting the original carried claim/context or either PDP request digest.
|
||||
+type ApprovalObservationProvenance struct {
|
||||
+ SourceID string `json:"source_id"`
|
||||
+ SourceRevision string `json:"source_revision"`
|
||||
+ ConfigurationDigest string `json:"configuration_digest"`
|
||||
+ ClaimDigest string `json:"claim_digest"`
|
||||
+ NativeBindingDigest string `json:"native_binding_digest"`
|
||||
+ ObservedAt string `json:"observed_at"`
|
||||
+ NotAfter string `json:"not_after"`
|
||||
+ ValidatedAt string `json:"validated_at"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
diff --git a/verifier/producer-source.json b/verifier/producer-source.json
|
||||
index b6b0cba..99e651d 100644
|
||||
--- a/verifier/producer-source.json
|
||||
+++ b/verifier/producer-source.json
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d",
|
||||
+ "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
"files": {
|
||||
- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300",
|
||||
+ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
"pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f",
|
||||
"pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9",
|
||||
- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62"
|
||||
+ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde"
|
||||
},
|
||||
"fixtures": {
|
||||
"decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8",
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
+ },
|
||||
+ "source_proposal": {
|
||||
+ "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
}
|
||||
diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go
|
||||
index 2a7fecd..994ea82 100644
|
||||
--- a/verifier/testsupport/signer/main.go
|
||||
+++ b/verifier/testsupport/signer/main.go
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
+ "io"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -17,8 +17,12 @@ func main() {
|
||||
seed = 0x43
|
||||
kid = "next-admitted"
|
||||
}
|
||||
- var env api.DecisionEnvelope
|
||||
- if json.NewDecoder(os.Stdin).Decode(&env) != nil {
|
||||
+ raw, err := io.ReadAll(os.Stdin)
|
||||
+ if err != nil {
|
||||
+ os.Exit(1)
|
||||
+ }
|
||||
+ env, err := sign.DecodeEnvelope(raw)
|
||||
+ if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil {
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 28102cf..8d42c05 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -114,3 +114,18 @@ producer candidate OCI and actual runtime/config placement remain missing. The
|
||||
empty-key trust template deliberately refuses. No install, key read, caller mint,
|
||||
protected effect, policy switch or trust admission occurred. T02 remains progress,
|
||||
T03 wait; task source/live obligations remain separately in SECRETS-WP-0013.
|
||||
+
|
||||
+2026-10-16 trusted successor source preparation: refreshed exact API/sign source
|
||||
+from frozen Access B2/B3 R1 proposal (patch SHA-256
|
||||
+6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1).
|
||||
+Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and
|
||||
+reauthenticates original producer JSON. Existing request/claim/native/caller joins
|
||||
+remain mandatory; signed source observation additionally binds the native digest
|
||||
+and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures,
|
||||
+tamper/unknown provenance and source deadline refusals are locally checked.
|
||||
+Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper;
|
||||
+actual source revocation refuses. Actual builder-to-v2 strict positive execution
|
||||
+is separately blocked on missing owner-reviewed service_actor_binding mapping;
|
||||
+no caller context injection or equality relaxation hides that gap.
|
||||
+Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03
|
||||
+wait; no package/key/policy/native admission or runtime installation occurred.
|
||||
|
|
@ -0,0 +1,749 @@
|
|||
diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py
|
||||
index 1cf4140..e4865bb 100644
|
||||
--- a/src/secrets_engine/approval_consume.py
|
||||
+++ b/src/secrets_engine/approval_consume.py
|
||||
@@ -382,7 +382,8 @@ def authorize_action(
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope},
|
||||
+ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope,
|
||||
+ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")},
|
||||
sort_keys=True,separators=(",",":"),allow_nan=False),
|
||||
)
|
||||
|
||||
diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py
|
||||
index 24fa8d3..d9bd0b9 100644
|
||||
--- a/src/secrets_engine/decision_signature.py
|
||||
+++ b/src/secrets_engine/decision_signature.py
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
+import math
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -24,8 +25,15 @@ def _unique_object(pairs):
|
||||
return result
|
||||
|
||||
|
||||
+def _finite_float(value):
|
||||
+ parsed = float(value)
|
||||
+ if not math.isfinite(parsed):
|
||||
+ raise ValueError("nonfinite JSON number")
|
||||
+ return parsed
|
||||
+
|
||||
+
|
||||
def strict_json(data):
|
||||
- return json.loads(data, object_pairs_hook=_unique_object,
|
||||
+ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float,
|
||||
parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number")))
|
||||
|
||||
|
||||
@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]:
|
||||
raise DecisionError("access-engine responder trust is missing or invalid") from exc
|
||||
|
||||
|
||||
+class AuthenticatedEnvelope(dict):
|
||||
+ """Verified typed values with exact producer JSON retained for reauthentication."""
|
||||
+ def __init__(self, values, authenticated_json):
|
||||
+ super().__init__(values)
|
||||
+ self.authenticated_json = authenticated_json
|
||||
+
|
||||
+
|
||||
def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict:
|
||||
if verifier is None or not Path(verifier).is_absolute():
|
||||
raise DecisionError("access-engine signature verifier is not installed")
|
||||
try:
|
||||
+ strict_json(body.decode("utf-8"))
|
||||
result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}',
|
||||
capture_output=True, env={}, timeout=3, check=False)
|
||||
if result.returncode != 0 or len(result.stdout) > 524288:
|
||||
@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) ->
|
||||
provenance = authenticated['provenance']
|
||||
if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')):
|
||||
raise ValueError("issuer/pin mismatch")
|
||||
- return authenticated
|
||||
- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc:
|
||||
+ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8"))
|
||||
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc:
|
||||
raise DecisionError("access-engine responder authentication failed") from exc
|
||||
diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py
|
||||
index 5dc1cae..f9b2538 100644
|
||||
--- a/src/secrets_engine/execution_contract.py
|
||||
+++ b/src/secrets_engine/execution_contract.py
|
||||
@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None)
|
||||
raise DecisionError('execute-ccr requires platform tenant')
|
||||
if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']):
|
||||
raise DecisionError('execution_contract catalog target does not match')
|
||||
+ if request['subject'] != {'id':'secrets-engine','type':'service'}:
|
||||
+ raise DecisionError('execute-ccr requires secrets-engine service subject')
|
||||
+ # Match the producer's fixed tenant normalization before approval issue.
|
||||
+ request['subject']['tenant'] = 'tenant:platform'
|
||||
+ request['resource']['tenant'] = 'tenant:platform'
|
||||
+ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding;
|
||||
+ # bound before approval issuance, never an authentication assertion.
|
||||
+ request['context']['service_actor_binding'] = {
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform',
|
||||
+ }
|
||||
request['context']['execution_contract'] = value
|
||||
request['context']['human_control'] = True
|
||||
|
||||
diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py
|
||||
index 9772861..5af36be 100644
|
||||
--- a/src/secrets_engine/task_execution_handoff.py
|
||||
+++ b/src/secrets_engine/task_execution_handoff.py
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
+import re
|
||||
from threading import Lock
|
||||
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
@@ -85,10 +86,10 @@ def _validate(cfg, action, contract):
|
||||
request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope'))
|
||||
# Rotation/removal and installed-helper changes cannot hide behind a cached
|
||||
# prepared label. Reauthenticate before each strict CAS, without networking.
|
||||
- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(),
|
||||
+ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"),
|
||||
trust=load_trust(getattr(cfg,'pdp_trust_file',None)),
|
||||
verifier=getattr(cfg,'pdp_verifier',None))
|
||||
- if authenticated != envelope:
|
||||
+ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False):
|
||||
_refuse()
|
||||
window=read_window(cfg)
|
||||
lower,upper=validity_bounds(window=window)
|
||||
@@ -115,7 +116,7 @@ def _validate(cfg, action, contract):
|
||||
or action.binding.decision_id!=validated.decision_id):
|
||||
_refuse()
|
||||
subject=binding['subject']; resource=binding['resource']
|
||||
- if (request['subject']!={'id':'secrets-engine','type':'service'}
|
||||
+ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
or subject.get('tenant') not in (None,'tenant:platform')
|
||||
or 'principal' in subject.get('attributes',{})
|
||||
@@ -140,6 +141,22 @@ def _validate(cfg, action, contract):
|
||||
or supplied.get('digest')!=claim_binding_digest(**native)
|
||||
or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})):
|
||||
_refuse()
|
||||
+ observation=envelope['provenance'].get('approval_observation')
|
||||
+ if observation is not None:
|
||||
+ # Signed producer source evidence supplements the original carried claim;
|
||||
+ # it cannot replace original context, extend freshness, or authorize CAS.
|
||||
+ if (not isinstance(observation,dict)
|
||||
+ or any(not isinstance(observation.get(k),str) or not observation[k]
|
||||
+ for k in ('source_id','source_revision'))
|
||||
+ or any(not isinstance(observation.get(k),str)
|
||||
+ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None
|
||||
+ for k in ('configuration_digest','claim_digest','native_binding_digest'))
|
||||
+ or observation['native_binding_digest']!=supplied['digest']
|
||||
+ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at'))
|
||||
+ or _stamp(observation.get('validated_at'))>upper
|
||||
+ or upper>=_stamp(observation.get('not_after'))
|
||||
+ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))):
|
||||
+ _refuse()
|
||||
return {'execution_contract':descriptor,'approval_id':action.binding.approval_id,
|
||||
'request_digest':validated.request_digest,'decision_id':validated.decision_id,
|
||||
'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'],
|
||||
diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
new file mode 100644
|
||||
index 0000000..46adb26
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
@@ -0,0 +1,210 @@
|
||||
+[
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":true}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": true
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}",
|
||||
+ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}",
|
||||
+ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}",
|
||||
+ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":false}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": false
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}",
|
||||
+ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}",
|
||||
+ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":-0.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": -0.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}",
|
||||
+ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}",
|
||||
+ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-6}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-06
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}",
|
||||
+ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e20}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+20
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}",
|
||||
+ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e21}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+21
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}",
|
||||
+ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":2.5e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 2.5e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}",
|
||||
+ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":9007199254740993}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 9007199254740993
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}",
|
||||
+ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "html": "<>&",
|
||||
+ "unicode": "é雪😀",
|
||||
+ "separator": "
"
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"
\",\"unicode\":\"é雪😀\"}}",
|
||||
+ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "b": [
|
||||
+ true,
|
||||
+ 1,
|
||||
+ 1.0,
|
||||
+ null,
|
||||
+ {
|
||||
+ "z": "x",
|
||||
+ "a": "y"
|
||||
+ }
|
||||
+ ],
|
||||
+ "a": {}
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}",
|
||||
+ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51"
|
||||
+ }
|
||||
+]
|
||||
diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py
|
||||
index 23b6419..70d9415 100644
|
||||
--- a/tests/test_execution_context.py
|
||||
+++ b/tests/test_execution_context.py
|
||||
@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation):
|
||||
|
||||
@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr'])
|
||||
def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action):
|
||||
- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError
|
||||
contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e))
|
||||
with pytest.raises(DecisionError):
|
||||
@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action)
|
||||
|
||||
def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path')
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='catalog'):
|
||||
resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
|
||||
def test_expired_contract_refuses_before_claim(tmp_path):
|
||||
e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='expired'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path):
|
||||
from tests.test_consume_binding_join import admitted_native_signer
|
||||
|
||||
from tests.signature_support import signature_tools
|
||||
+
|
||||
+
|
||||
+def test_execution_service_declaration_is_fixed_before_issue():
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ built=request(e,contract)
|
||||
+ assert built['context']['service_actor_binding']=={
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['resource']['tenant']=='tenant:platform'
|
||||
+ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context']
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')])
|
||||
+def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type):
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ with pytest.raises(DecisionError,match='service subject'):
|
||||
+ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract)
|
||||
diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py
|
||||
new file mode 100644
|
||||
index 0000000..3c47a1d
|
||||
--- /dev/null
|
||||
+++ b/tests/test_trusted_successor.py
|
||||
@@ -0,0 +1,80 @@
|
||||
+"""Owner signature interoperability, independent synthetic admission only."""
|
||||
+import copy
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+import subprocess
|
||||
+
|
||||
+import pytest
|
||||
+from secrets_engine.decision_signature import authenticate_response,load_trust
|
||||
+from secrets_engine.errors import DecisionError
|
||||
+from tests.test_decision_signature import FIXTURE,trust_file,verifier
|
||||
+from tests.signature_support import signature_tools
|
||||
+from tests.test_task_execution_handoff import lane,prepared,consume_task_execution
|
||||
+
|
||||
+VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text())
|
||||
+
|
||||
+@pytest.mark.parametrize('vector',VECTORS)
|
||||
+def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env.pop('signature',None)
|
||||
+ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'}
|
||||
+ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode()
|
||||
+ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json'])
|
||||
+ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"')
|
||||
+ assert tampered!=signed
|
||||
+ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_unknown_provenance_refuses(trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True
|
||||
+ with pytest.raises(DecisionError):
|
||||
+ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools):
|
||||
+ import tests.test_task_execution_handoff as fixtures
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def raw_sign(env,tools):
|
||||
+ env['diagnostics']={'numeric':'LEXEME'}
|
||||
+ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode()
|
||||
+ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign)
|
||||
+ authorization=prepared(lane)
|
||||
+ assert '1e-7' in authorization._action.artifacts
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+def source_evidence(envelope,claim):
|
||||
+ from datetime import datetime,timezone
|
||||
+ return {'source_id':'synthetic-source','source_revision':'synthetic-revision',
|
||||
+ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64,
|
||||
+ 'native_binding_digest':claim['binding']['digest'],
|
||||
+ 'observed_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'validated_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'not_after':envelope['lifetime']['expires_at']}
|
||||
+
|
||||
+
|
||||
+def test_signed_source_evidence_keeps_original_context_and_cas(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim']))
|
||||
+ authorization=prepared(lane)
|
||||
+ snapshot=json.loads(authorization._action.artifacts)
|
||||
+ assert snapshot['envelope']['binding']['context']==snapshot['request']['context']
|
||||
+ assert snapshot['request']['context']['approval']==state['claim']
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('field,value',[
|
||||
+ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'),
|
||||
+ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'),
|
||||
+ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')])
|
||||
+def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def change(env):
|
||||
+ evidence=source_evidence(env,state['claim']);evidence[field]=value
|
||||
+ env['provenance']['approval_observation']=evidence
|
||||
+ state['decision_change']=change
|
||||
+ with pytest.raises(DecisionError):prepared(lane)
|
||||
+ assert 'consume' not in state['calls']
|
||||
diff --git a/verifier/README.md b/verifier/README.md
|
||||
index a63b2a0..c2c3bec 100644
|
||||
--- a/verifier/README.md
|
||||
+++ b/verifier/README.md
|
||||
@@ -1,7 +1,7 @@
|
||||
# Access Engine decision verifier
|
||||
|
||||
The API types and `internal/sign/sign.go` are unchanged producer source, pinned
|
||||
-by revision and SHA-256 in `producer-source.json`. License attribution and the
|
||||
+by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the
|
||||
original license text are in `LICENSE.access-engine`. This is source vendoring
|
||||
necessitated by the producer's Go `internal` package boundary, not a separately
|
||||
maintained crypto implementation. There are no external Go dependencies.
|
||||
@@ -33,3 +33,23 @@ For the complete reproducible CLI build and offline fresh-prefix installation,
|
||||
use [the release bundle contract](../docs/cli-release-bundle.md). This replaces
|
||||
manual helper copying for packaged deployments; package and key admission remain
|
||||
separate owner decisions.
|
||||
+
|
||||
+The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal,
|
||||
+identified by its patch SHA-256 in `producer-source.json`; this is source review,
|
||||
+not a claim of published producer revision or runtime/key admission. The wrapper
|
||||
+uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in
|
||||
+signed maps. It retains exact authenticated producer JSON through strict
|
||||
+preparation and CAS reauthentication; Python does not recreate signed bytes.
|
||||
+Approval's fourteen native binding vectors are signature interoperability cases,
|
||||
+not an expansion of existing request numeric admission. Signed
|
||||
+`approval_observation` supplements the original immutable carried claim;
|
||||
+strict handoff checks its native digest and observation/deadline bounds. It never
|
||||
+replaces original context or freshness.
|
||||
+
|
||||
+Successor R2 execution builder correspondence emits the producer's fixed public
|
||||
+DeclaredServiceBinding only for the typed prod/platform secrets-engine service
|
||||
+lane before approval issuance. Both reference tenants are explicit to match
|
||||
+existing producer normalization. It is not caller authentication; independently
|
||||
+signed enforced caller, exact original/native/descriptor joins and all existing
|
||||
+approval, expiry and CAS gates still apply. Actual production source-server
|
||||
+interoperability receipts are separate from native runtime admission.
|
||||
diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go
|
||||
index fc607f7..011f8f7 100644
|
||||
--- a/verifier/internal/sign/sign.go
|
||||
+++ b/verifier/internal/sign/sign.go
|
||||
@@ -4,10 +4,12 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
+ "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
+ "io"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+
|
||||
+// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
|
||||
+// Legacy producer envelopes remain byte-compatible. It does not invent another
|
||||
+// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
|
||||
+func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
|
||||
+ var envelope api.DecisionEnvelope
|
||||
+ decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
+ decoder.UseNumber()
|
||||
+ decoder.DisallowUnknownFields()
|
||||
+ if err := decoder.Decode(&envelope); err != nil {
|
||||
+ return envelope, err
|
||||
+ }
|
||||
+ if _, err := decoder.Token(); err != io.EOF {
|
||||
+ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
|
||||
+ }
|
||||
+ return envelope, nil
|
||||
+}
|
||||
diff --git a/verifier/main.go b/verifier/main.go
|
||||
index 1f3f9de..7449c64 100644
|
||||
--- a/verifier/main.go
|
||||
+++ b/verifier/main.go
|
||||
@@ -2,13 +2,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
- "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
@@ -23,10 +21,8 @@ func run() error {
|
||||
if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
- var envelope api.DecisionEnvelope
|
||||
- decoder := json.NewDecoder(bytes.NewReader(in.Envelope))
|
||||
- decoder.DisallowUnknownFields()
|
||||
- if err := decoder.Decode(&envelope); err != nil {
|
||||
+ envelope, err := sign.DecodeEnvelope(in.Envelope)
|
||||
+ if err != nil {
|
||||
return err
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go
|
||||
index 3baa784..ae11a2d 100644
|
||||
--- a/verifier/pkg/api/canonical.go
|
||||
+++ b/verifier/pkg/api/canonical.go
|
||||
@@ -635,16 +635,30 @@ type Obligation struct {
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
- Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
- Mode string `json:"mode" yaml:"mode"`
|
||||
- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+ Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
+ Mode string `json:"mode" yaml:"mode"`
|
||||
+ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
+ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
+ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
+ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
+ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
+ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
+ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
+ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"`
|
||||
+ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+}
|
||||
+
|
||||
+// ApprovalObservationProvenance binds fresh server-owned fact evidence without
|
||||
+// rewriting the original carried claim/context or either PDP request digest.
|
||||
+type ApprovalObservationProvenance struct {
|
||||
+ SourceID string `json:"source_id"`
|
||||
+ SourceRevision string `json:"source_revision"`
|
||||
+ ConfigurationDigest string `json:"configuration_digest"`
|
||||
+ ClaimDigest string `json:"claim_digest"`
|
||||
+ NativeBindingDigest string `json:"native_binding_digest"`
|
||||
+ ObservedAt string `json:"observed_at"`
|
||||
+ NotAfter string `json:"not_after"`
|
||||
+ ValidatedAt string `json:"validated_at"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
diff --git a/verifier/producer-source.json b/verifier/producer-source.json
|
||||
index b6b0cba..99e651d 100644
|
||||
--- a/verifier/producer-source.json
|
||||
+++ b/verifier/producer-source.json
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d",
|
||||
+ "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
"files": {
|
||||
- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300",
|
||||
+ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
"pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f",
|
||||
"pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9",
|
||||
- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62"
|
||||
+ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde"
|
||||
},
|
||||
"fixtures": {
|
||||
"decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8",
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
+ },
|
||||
+ "source_proposal": {
|
||||
+ "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
}
|
||||
diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go
|
||||
index 2a7fecd..994ea82 100644
|
||||
--- a/verifier/testsupport/signer/main.go
|
||||
+++ b/verifier/testsupport/signer/main.go
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
+ "io"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -17,8 +17,12 @@ func main() {
|
||||
seed = 0x43
|
||||
kid = "next-admitted"
|
||||
}
|
||||
- var env api.DecisionEnvelope
|
||||
- if json.NewDecoder(os.Stdin).Decode(&env) != nil {
|
||||
+ raw, err := io.ReadAll(os.Stdin)
|
||||
+ if err != nil {
|
||||
+ os.Exit(1)
|
||||
+ }
|
||||
+ env, err := sign.DecodeEnvelope(raw)
|
||||
+ if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil {
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 28102cf..3109752 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -114,3 +114,31 @@ producer candidate OCI and actual runtime/config placement remain missing. The
|
||||
empty-key trust template deliberately refuses. No install, key read, caller mint,
|
||||
protected effect, policy switch or trust admission occurred. T02 remains progress,
|
||||
T03 wait; task source/live obligations remain separately in SECRETS-WP-0013.
|
||||
+
|
||||
+2026-10-16 trusted successor source preparation: refreshed exact API/sign source
|
||||
+from frozen Access B2/B3 R1 proposal (patch SHA-256
|
||||
+6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1).
|
||||
+Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and
|
||||
+reauthenticates original producer JSON. Existing request/claim/native/caller joins
|
||||
+remain mandatory; signed source observation additionally binds the native digest
|
||||
+and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures,
|
||||
+tamper/unknown provenance and source deadline refusals are locally checked.
|
||||
+Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper;
|
||||
+actual source revocation refuses. Actual builder-to-v2 strict positive execution
|
||||
+is separately blocked on missing owner-reviewed service_actor_binding mapping;
|
||||
+no caller context injection or equality relaxation hides that gap.
|
||||
+Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03
|
||||
+wait; no package/key/policy/native admission or runtime installation occurred.
|
||||
+
|
||||
+2026-10-16 successor R2 source preparation resolves the preceding builder gap:
|
||||
+only typed prod/platform execute-ccr with exact secrets-engine service subject
|
||||
+emits Access's fixed five-field public service_actor_binding before approval
|
||||
+issue. Subject/resource platform tenants are explicit before issue, matching the
|
||||
+producer's existing normalization and native act; lifecycle requests are unchanged.
|
||||
+Original carried context is still immutable. Actual builder -> Approval App ->
|
||||
+Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation
|
||||
+GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/
|
||||
+native changes, stale original claim, signature tamper and replay refuse. The
|
||||
+operation deadline is explicit whole seconds within v2's 15-second allow TTL;
|
||||
+no deadline is extended or waived. This resolves source correspondence only;
|
||||
+T02 remains progress/T03 wait, with no native trust/grant/policy admission.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py
|
||||
index 5dc1cae..f9b2538 100644
|
||||
--- a/src/secrets_engine/execution_contract.py
|
||||
+++ b/src/secrets_engine/execution_contract.py
|
||||
@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None)
|
||||
raise DecisionError('execute-ccr requires platform tenant')
|
||||
if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']):
|
||||
raise DecisionError('execution_contract catalog target does not match')
|
||||
+ if request['subject'] != {'id':'secrets-engine','type':'service'}:
|
||||
+ raise DecisionError('execute-ccr requires secrets-engine service subject')
|
||||
+ # Match the producer's fixed tenant normalization before approval issue.
|
||||
+ request['subject']['tenant'] = 'tenant:platform'
|
||||
+ request['resource']['tenant'] = 'tenant:platform'
|
||||
+ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding;
|
||||
+ # bound before approval issuance, never an authentication assertion.
|
||||
+ request['context']['service_actor_binding'] = {
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform',
|
||||
+ }
|
||||
request['context']['execution_contract'] = value
|
||||
request['context']['human_control'] = True
|
||||
|
||||
diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py
|
||||
index eea6a85..5af36be 100644
|
||||
--- a/src/secrets_engine/task_execution_handoff.py
|
||||
+++ b/src/secrets_engine/task_execution_handoff.py
|
||||
@@ -116,7 +116,7 @@ def _validate(cfg, action, contract):
|
||||
or action.binding.decision_id!=validated.decision_id):
|
||||
_refuse()
|
||||
subject=binding['subject']; resource=binding['resource']
|
||||
- if (request['subject']!={'id':'secrets-engine','type':'service'}
|
||||
+ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
or subject.get('tenant') not in (None,'tenant:platform')
|
||||
or 'principal' in subject.get('attributes',{})
|
||||
diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py
|
||||
index 23b6419..70d9415 100644
|
||||
--- a/tests/test_execution_context.py
|
||||
+++ b/tests/test_execution_context.py
|
||||
@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation):
|
||||
|
||||
@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr'])
|
||||
def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action):
|
||||
- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError
|
||||
contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e))
|
||||
with pytest.raises(DecisionError):
|
||||
@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action)
|
||||
|
||||
def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path')
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='catalog'):
|
||||
resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
|
||||
def test_expired_contract_refuses_before_claim(tmp_path):
|
||||
e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='expired'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path):
|
||||
from tests.test_consume_binding_join import admitted_native_signer
|
||||
|
||||
from tests.signature_support import signature_tools
|
||||
+
|
||||
+
|
||||
+def test_execution_service_declaration_is_fixed_before_issue():
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ built=request(e,contract)
|
||||
+ assert built['context']['service_actor_binding']=={
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['resource']['tenant']=='tenant:platform'
|
||||
+ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context']
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')])
|
||||
+def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type):
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ with pytest.raises(DecisionError,match='service subject'):
|
||||
+ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract)
|
||||
diff --git a/verifier/README.md b/verifier/README.md
|
||||
index 6a1b56b..c2c3bec 100644
|
||||
--- a/verifier/README.md
|
||||
+++ b/verifier/README.md
|
||||
@@ -45,3 +45,11 @@ not an expansion of existing request numeric admission. Signed
|
||||
`approval_observation` supplements the original immutable carried claim;
|
||||
strict handoff checks its native digest and observation/deadline bounds. It never
|
||||
replaces original context or freshness.
|
||||
+
|
||||
+Successor R2 execution builder correspondence emits the producer's fixed public
|
||||
+DeclaredServiceBinding only for the typed prod/platform secrets-engine service
|
||||
+lane before approval issuance. Both reference tenants are explicit to match
|
||||
+existing producer normalization. It is not caller authentication; independently
|
||||
+signed enforced caller, exact original/native/descriptor joins and all existing
|
||||
+approval, expiry and CAS gates still apply. Actual production source-server
|
||||
+interoperability receipts are separate from native runtime admission.
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 8d42c05..3109752 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -129,3 +129,16 @@ is separately blocked on missing owner-reviewed service_actor_binding mapping;
|
||||
no caller context injection or equality relaxation hides that gap.
|
||||
Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03
|
||||
wait; no package/key/policy/native admission or runtime installation occurred.
|
||||
+
|
||||
+2026-10-16 successor R2 source preparation resolves the preceding builder gap:
|
||||
+only typed prod/platform execute-ccr with exact secrets-engine service subject
|
||||
+emits Access's fixed five-field public service_actor_binding before approval
|
||||
+issue. Subject/resource platform tenants are explicit before issue, matching the
|
||||
+producer's existing normalization and native act; lifecycle requests are unchanged.
|
||||
+Original carried context is still immutable. Actual builder -> Approval App ->
|
||||
+Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation
|
||||
+GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/
|
||||
+native changes, stale original claim, signature tamper and replay refuse. The
|
||||
+operation deadline is explicit whole seconds within v2's 15-second allow TTL;
|
||||
+no deadline is extended or waived. This resolves source correspondence only;
|
||||
+T02 remains progress/T03 wait, with no native trust/grant/policy admission.
|
||||
|
|
@ -0,0 +1,806 @@
|
|||
diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md
|
||||
index 6128f2e..b9fea01 100644
|
||||
--- a/docs/task-scoped-openbao-execution-contract.md
|
||||
+++ b/docs/task-scoped-openbao-execution-contract.md
|
||||
@@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are:
|
||||
| source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source |
|
||||
| backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity |
|
||||
| operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body |
|
||||
-| operation_deadline | Upper bound no later than approval, PDP or task-token expiry |
|
||||
+| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier |
|
||||
| backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses |
|
||||
| expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents |
|
||||
|
||||
@@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive
|
||||
Task-token use budget is declared before issuance: two before-state reads, two
|
||||
writes, two readbacks and one self-revoke (seven uses), plus at most four explicit
|
||||
metadata capability checks and one reserved cleanup use (twelve total). Failed
|
||||
-backend calls count; never retry to evade limits. Task TTL is
|
||||
-`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus
|
||||
-the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain
|
||||
-for planned requests. No renewal or remint extends the operation. CCR38 keeps its
|
||||
+backend calls count; never retry to evade limits. The immutable
|
||||
+`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s).
|
||||
+The native adapter does not dynamically recompute or shorten that declaration.
|
||||
+Before issuance, conservatively require at least the declared TTL plus the 5s
|
||||
+cleanup reserve within the remaining operation/approval/PDP authority, including
|
||||
+clock uncertainty. The requested and explicit maximum native token TTL equal
|
||||
+the declared budget; returned native TTL must be positive and at most that budget.
|
||||
+The token can expire before the operation deadline; failure or expiry stops work
|
||||
+without remint. The signed operation deadline remains no later than approval,
|
||||
+PDP, authenticated caller and both original/fresh source validity. Access's
|
||||
+trusted source path independently requires the same TTL-plus-5s fit after source
|
||||
+I/O and an operation interval no longer than 120s. Therefore a structural 120s
|
||||
+backend budget cannot fit inside a 120s authority interval with the reserve and
|
||||
+refuses. No renewal or remint extends the operation. CCR38 keeps its
|
||||
own 300s/eight-use limit; login-self checks and revoke fit its separate budget.
|
||||
|
||||
## Failure, replay and recovery
|
||||
diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py
|
||||
index 1cf4140..e4865bb 100644
|
||||
--- a/src/secrets_engine/approval_consume.py
|
||||
+++ b/src/secrets_engine/approval_consume.py
|
||||
@@ -382,7 +382,8 @@ def authorize_action(
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope},
|
||||
+ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope,
|
||||
+ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")},
|
||||
sort_keys=True,separators=(",",":"),allow_nan=False),
|
||||
)
|
||||
|
||||
diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py
|
||||
index 24fa8d3..d9bd0b9 100644
|
||||
--- a/src/secrets_engine/decision_signature.py
|
||||
+++ b/src/secrets_engine/decision_signature.py
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
+import math
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -24,8 +25,15 @@ def _unique_object(pairs):
|
||||
return result
|
||||
|
||||
|
||||
+def _finite_float(value):
|
||||
+ parsed = float(value)
|
||||
+ if not math.isfinite(parsed):
|
||||
+ raise ValueError("nonfinite JSON number")
|
||||
+ return parsed
|
||||
+
|
||||
+
|
||||
def strict_json(data):
|
||||
- return json.loads(data, object_pairs_hook=_unique_object,
|
||||
+ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float,
|
||||
parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number")))
|
||||
|
||||
|
||||
@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]:
|
||||
raise DecisionError("access-engine responder trust is missing or invalid") from exc
|
||||
|
||||
|
||||
+class AuthenticatedEnvelope(dict):
|
||||
+ """Verified typed values with exact producer JSON retained for reauthentication."""
|
||||
+ def __init__(self, values, authenticated_json):
|
||||
+ super().__init__(values)
|
||||
+ self.authenticated_json = authenticated_json
|
||||
+
|
||||
+
|
||||
def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict:
|
||||
if verifier is None or not Path(verifier).is_absolute():
|
||||
raise DecisionError("access-engine signature verifier is not installed")
|
||||
try:
|
||||
+ strict_json(body.decode("utf-8"))
|
||||
result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}',
|
||||
capture_output=True, env={}, timeout=3, check=False)
|
||||
if result.returncode != 0 or len(result.stdout) > 524288:
|
||||
@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) ->
|
||||
provenance = authenticated['provenance']
|
||||
if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')):
|
||||
raise ValueError("issuer/pin mismatch")
|
||||
- return authenticated
|
||||
- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc:
|
||||
+ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8"))
|
||||
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc:
|
||||
raise DecisionError("access-engine responder authentication failed") from exc
|
||||
diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py
|
||||
index 5dc1cae..f9b2538 100644
|
||||
--- a/src/secrets_engine/execution_contract.py
|
||||
+++ b/src/secrets_engine/execution_contract.py
|
||||
@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None)
|
||||
raise DecisionError('execute-ccr requires platform tenant')
|
||||
if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']):
|
||||
raise DecisionError('execution_contract catalog target does not match')
|
||||
+ if request['subject'] != {'id':'secrets-engine','type':'service'}:
|
||||
+ raise DecisionError('execute-ccr requires secrets-engine service subject')
|
||||
+ # Match the producer's fixed tenant normalization before approval issue.
|
||||
+ request['subject']['tenant'] = 'tenant:platform'
|
||||
+ request['resource']['tenant'] = 'tenant:platform'
|
||||
+ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding;
|
||||
+ # bound before approval issuance, never an authentication assertion.
|
||||
+ request['context']['service_actor_binding'] = {
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform',
|
||||
+ }
|
||||
request['context']['execution_contract'] = value
|
||||
request['context']['human_control'] = True
|
||||
|
||||
diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py
|
||||
index 9772861..5af36be 100644
|
||||
--- a/src/secrets_engine/task_execution_handoff.py
|
||||
+++ b/src/secrets_engine/task_execution_handoff.py
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
+import re
|
||||
from threading import Lock
|
||||
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
@@ -85,10 +86,10 @@ def _validate(cfg, action, contract):
|
||||
request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope'))
|
||||
# Rotation/removal and installed-helper changes cannot hide behind a cached
|
||||
# prepared label. Reauthenticate before each strict CAS, without networking.
|
||||
- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(),
|
||||
+ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"),
|
||||
trust=load_trust(getattr(cfg,'pdp_trust_file',None)),
|
||||
verifier=getattr(cfg,'pdp_verifier',None))
|
||||
- if authenticated != envelope:
|
||||
+ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False):
|
||||
_refuse()
|
||||
window=read_window(cfg)
|
||||
lower,upper=validity_bounds(window=window)
|
||||
@@ -115,7 +116,7 @@ def _validate(cfg, action, contract):
|
||||
or action.binding.decision_id!=validated.decision_id):
|
||||
_refuse()
|
||||
subject=binding['subject']; resource=binding['resource']
|
||||
- if (request['subject']!={'id':'secrets-engine','type':'service'}
|
||||
+ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
or subject.get('tenant') not in (None,'tenant:platform')
|
||||
or 'principal' in subject.get('attributes',{})
|
||||
@@ -140,6 +141,22 @@ def _validate(cfg, action, contract):
|
||||
or supplied.get('digest')!=claim_binding_digest(**native)
|
||||
or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})):
|
||||
_refuse()
|
||||
+ observation=envelope['provenance'].get('approval_observation')
|
||||
+ if observation is not None:
|
||||
+ # Signed producer source evidence supplements the original carried claim;
|
||||
+ # it cannot replace original context, extend freshness, or authorize CAS.
|
||||
+ if (not isinstance(observation,dict)
|
||||
+ or any(not isinstance(observation.get(k),str) or not observation[k]
|
||||
+ for k in ('source_id','source_revision'))
|
||||
+ or any(not isinstance(observation.get(k),str)
|
||||
+ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None
|
||||
+ for k in ('configuration_digest','claim_digest','native_binding_digest'))
|
||||
+ or observation['native_binding_digest']!=supplied['digest']
|
||||
+ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at'))
|
||||
+ or _stamp(observation.get('validated_at'))>upper
|
||||
+ or upper>=_stamp(observation.get('not_after'))
|
||||
+ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))):
|
||||
+ _refuse()
|
||||
return {'execution_contract':descriptor,'approval_id':action.binding.approval_id,
|
||||
'request_digest':validated.request_digest,'decision_id':validated.decision_id,
|
||||
'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'],
|
||||
diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
new file mode 100644
|
||||
index 0000000..46adb26
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/pdp-signature/approval-native-vectors.json
|
||||
@@ -0,0 +1,210 @@
|
||||
+[
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":true}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": true
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}",
|
||||
+ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}",
|
||||
+ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}",
|
||||
+ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":false}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": false
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}",
|
||||
+ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}",
|
||||
+ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":-0.0}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": -0.0
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}",
|
||||
+ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}",
|
||||
+ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e-6}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e-06
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}",
|
||||
+ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e20}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+20
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}",
|
||||
+ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":1e21}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 1e+21
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}",
|
||||
+ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":2.5e-7}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 2.5e-07
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}",
|
||||
+ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"value\":9007199254740993}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "value": 9007199254740993
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}",
|
||||
+ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "html": "<>&",
|
||||
+ "unicode": "é雪😀",
|
||||
+ "separator": "
"
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"
\",\"unicode\":\"é雪😀\"}}",
|
||||
+ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea"
|
||||
+ },
|
||||
+ {
|
||||
+ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}",
|
||||
+ "binding": {
|
||||
+ "action": "secrets.kv.destroy",
|
||||
+ "target": {
|
||||
+ "b": [
|
||||
+ true,
|
||||
+ 1,
|
||||
+ 1.0,
|
||||
+ null,
|
||||
+ {
|
||||
+ "z": "x",
|
||||
+ "a": "y"
|
||||
+ }
|
||||
+ ],
|
||||
+ "a": {}
|
||||
+ },
|
||||
+ "actor": "agt-secrets-engine",
|
||||
+ "principal": "bernd",
|
||||
+ "purpose": "rotate-exposed-key"
|
||||
+ },
|
||||
+ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}",
|
||||
+ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51"
|
||||
+ }
|
||||
+]
|
||||
diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py
|
||||
index 23b6419..70d9415 100644
|
||||
--- a/tests/test_execution_context.py
|
||||
+++ b/tests/test_execution_context.py
|
||||
@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation):
|
||||
|
||||
@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr'])
|
||||
def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action):
|
||||
- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError
|
||||
contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e))
|
||||
with pytest.raises(DecisionError):
|
||||
@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action)
|
||||
|
||||
def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path')
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='catalog'):
|
||||
resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path):
|
||||
|
||||
def test_expired_contract_refuses_before_claim(tmp_path):
|
||||
e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
|
||||
- cfg=_Cfg(_token(tmp_path)); calls=[]
|
||||
+ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[]
|
||||
with pytest.raises(DecisionError,match='expired'):
|
||||
authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim'))
|
||||
assert not calls
|
||||
@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path):
|
||||
from tests.test_consume_binding_join import admitted_native_signer
|
||||
|
||||
from tests.signature_support import signature_tools
|
||||
+
|
||||
+
|
||||
+def test_execution_service_declaration_is_fixed_before_issue():
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ built=request(e,contract)
|
||||
+ assert built['context']['service_actor_binding']=={
|
||||
+ 'version':'proposed-service-task-caller/v1',
|
||||
+ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
+ assert built['resource']['tenant']=='tenant:platform'
|
||||
+ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context']
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')])
|
||||
+def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type):
|
||||
+ e=entry();contract=ExecutionContract.parse(descriptor(e))
|
||||
+ with pytest.raises(DecisionError,match='service subject'):
|
||||
+ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract)
|
||||
diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py
|
||||
new file mode 100644
|
||||
index 0000000..3c47a1d
|
||||
--- /dev/null
|
||||
+++ b/tests/test_trusted_successor.py
|
||||
@@ -0,0 +1,80 @@
|
||||
+"""Owner signature interoperability, independent synthetic admission only."""
|
||||
+import copy
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+import subprocess
|
||||
+
|
||||
+import pytest
|
||||
+from secrets_engine.decision_signature import authenticate_response,load_trust
|
||||
+from secrets_engine.errors import DecisionError
|
||||
+from tests.test_decision_signature import FIXTURE,trust_file,verifier
|
||||
+from tests.signature_support import signature_tools
|
||||
+from tests.test_task_execution_handoff import lane,prepared,consume_task_execution
|
||||
+
|
||||
+VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text())
|
||||
+
|
||||
+@pytest.mark.parametrize('vector',VECTORS)
|
||||
+def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env.pop('signature',None)
|
||||
+ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'}
|
||||
+ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode()
|
||||
+ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json'])
|
||||
+ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"')
|
||||
+ assert tampered!=signed
|
||||
+ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_unknown_provenance_refuses(trust_file,signature_tools):
|
||||
+ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True
|
||||
+ with pytest.raises(DecisionError):
|
||||
+ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify')
|
||||
+
|
||||
+
|
||||
+def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools):
|
||||
+ import tests.test_task_execution_handoff as fixtures
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def raw_sign(env,tools):
|
||||
+ env['diagnostics']={'numeric':'LEXEME'}
|
||||
+ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode()
|
||||
+ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout
|
||||
+ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign)
|
||||
+ authorization=prepared(lane)
|
||||
+ assert '1e-7' in authorization._action.artifacts
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+def source_evidence(envelope,claim):
|
||||
+ from datetime import datetime,timezone
|
||||
+ return {'source_id':'synthetic-source','source_revision':'synthetic-revision',
|
||||
+ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64,
|
||||
+ 'native_binding_digest':claim['binding']['digest'],
|
||||
+ 'observed_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'validated_at':datetime.now(timezone.utc).isoformat(),
|
||||
+ 'not_after':envelope['lifetime']['expires_at']}
|
||||
+
|
||||
+
|
||||
+def test_signed_source_evidence_keeps_original_context_and_cas(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim']))
|
||||
+ authorization=prepared(lane)
|
||||
+ snapshot=json.loads(authorization._action.artifacts)
|
||||
+ assert snapshot['envelope']['binding']['context']==snapshot['request']['context']
|
||||
+ assert snapshot['request']['context']['approval']==state['claim']
|
||||
+ assert consume_task_execution(cfg,authorization).idempotent is False
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('field,value',[
|
||||
+ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'),
|
||||
+ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'),
|
||||
+ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')])
|
||||
+def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def change(env):
|
||||
+ evidence=source_evidence(env,state['claim']);evidence[field]=value
|
||||
+ env['provenance']['approval_observation']=evidence
|
||||
+ state['decision_change']=change
|
||||
+ with pytest.raises(DecisionError):prepared(lane)
|
||||
+ assert 'consume' not in state['calls']
|
||||
diff --git a/verifier/README.md b/verifier/README.md
|
||||
index a63b2a0..fe7ec93 100644
|
||||
--- a/verifier/README.md
|
||||
+++ b/verifier/README.md
|
||||
@@ -1,7 +1,7 @@
|
||||
# Access Engine decision verifier
|
||||
|
||||
The API types and `internal/sign/sign.go` are unchanged producer source, pinned
|
||||
-by revision and SHA-256 in `producer-source.json`. License attribution and the
|
||||
+by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the
|
||||
original license text are in `LICENSE.access-engine`. This is source vendoring
|
||||
necessitated by the producer's Go `internal` package boundary, not a separately
|
||||
maintained crypto implementation. There are no external Go dependencies.
|
||||
@@ -33,3 +33,23 @@ For the complete reproducible CLI build and offline fresh-prefix installation,
|
||||
use [the release bundle contract](../docs/cli-release-bundle.md). This replaces
|
||||
manual helper copying for packaged deployments; package and key admission remain
|
||||
separate owner decisions.
|
||||
+
|
||||
+The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage,
|
||||
+identified by its patch SHA-256 in `producer-source.json`; this is source review,
|
||||
+not a claim of published producer revision or runtime/key admission. The wrapper
|
||||
+uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in
|
||||
+signed maps. It retains exact authenticated producer JSON through strict
|
||||
+preparation and CAS reauthentication; Python does not recreate signed bytes.
|
||||
+Approval's fourteen native binding vectors are signature interoperability cases,
|
||||
+not an expansion of existing request numeric admission. Signed
|
||||
+`approval_observation` supplements the original immutable carried claim;
|
||||
+strict handoff checks its native digest and observation/deadline bounds. It never
|
||||
+replaces original context or freshness.
|
||||
+
|
||||
+Successor R2 execution builder correspondence emits the producer's fixed public
|
||||
+DeclaredServiceBinding only for the typed prod/platform secrets-engine service
|
||||
+lane before approval issuance. Both reference tenants are explicit to match
|
||||
+existing producer normalization. It is not caller authentication; independently
|
||||
+signed enforced caller, exact original/native/descriptor joins and all existing
|
||||
+approval, expiry and CAS gates still apply. Actual production source-server
|
||||
+interoperability receipts are separate from native runtime admission.
|
||||
diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go
|
||||
index fc607f7..011f8f7 100644
|
||||
--- a/verifier/internal/sign/sign.go
|
||||
+++ b/verifier/internal/sign/sign.go
|
||||
@@ -4,10 +4,12 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
+ "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
+ "io"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+
|
||||
+// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
|
||||
+// Legacy producer envelopes remain byte-compatible. It does not invent another
|
||||
+// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
|
||||
+func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
|
||||
+ var envelope api.DecisionEnvelope
|
||||
+ decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
+ decoder.UseNumber()
|
||||
+ decoder.DisallowUnknownFields()
|
||||
+ if err := decoder.Decode(&envelope); err != nil {
|
||||
+ return envelope, err
|
||||
+ }
|
||||
+ if _, err := decoder.Token(); err != io.EOF {
|
||||
+ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
|
||||
+ }
|
||||
+ return envelope, nil
|
||||
+}
|
||||
diff --git a/verifier/main.go b/verifier/main.go
|
||||
index 1f3f9de..7449c64 100644
|
||||
--- a/verifier/main.go
|
||||
+++ b/verifier/main.go
|
||||
@@ -2,13 +2,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
- "bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
@@ -23,10 +21,8 @@ func run() error {
|
||||
if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
- var envelope api.DecisionEnvelope
|
||||
- decoder := json.NewDecoder(bytes.NewReader(in.Envelope))
|
||||
- decoder.DisallowUnknownFields()
|
||||
- if err := decoder.Decode(&envelope); err != nil {
|
||||
+ envelope, err := sign.DecodeEnvelope(in.Envelope)
|
||||
+ if err != nil {
|
||||
return err
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go
|
||||
index 3baa784..ae11a2d 100644
|
||||
--- a/verifier/pkg/api/canonical.go
|
||||
+++ b/verifier/pkg/api/canonical.go
|
||||
@@ -635,16 +635,30 @@ type Obligation struct {
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
- Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
- Mode string `json:"mode" yaml:"mode"`
|
||||
- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+ Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
+ Mode string `json:"mode" yaml:"mode"`
|
||||
+ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
+ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
+ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
+ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
+ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
+ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
+ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
+ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"`
|
||||
+ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
+}
|
||||
+
|
||||
+// ApprovalObservationProvenance binds fresh server-owned fact evidence without
|
||||
+// rewriting the original carried claim/context or either PDP request digest.
|
||||
+type ApprovalObservationProvenance struct {
|
||||
+ SourceID string `json:"source_id"`
|
||||
+ SourceRevision string `json:"source_revision"`
|
||||
+ ConfigurationDigest string `json:"configuration_digest"`
|
||||
+ ClaimDigest string `json:"claim_digest"`
|
||||
+ NativeBindingDigest string `json:"native_binding_digest"`
|
||||
+ ObservedAt string `json:"observed_at"`
|
||||
+ NotAfter string `json:"not_after"`
|
||||
+ ValidatedAt string `json:"validated_at"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
diff --git a/verifier/producer-source.json b/verifier/producer-source.json
|
||||
index b6b0cba..9a4d5a3 100644
|
||||
--- a/verifier/producer-source.json
|
||||
+++ b/verifier/producer-source.json
|
||||
@@ -1,15 +1,21 @@
|
||||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d",
|
||||
+ "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
"files": {
|
||||
- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300",
|
||||
+ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
"pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f",
|
||||
"pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9",
|
||||
- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62"
|
||||
+ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde"
|
||||
},
|
||||
"fixtures": {
|
||||
"decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8",
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
+ },
|
||||
+ "source_proposal": {
|
||||
+ "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4",
|
||||
+ "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "api_sign_bytes_unchanged": true,
|
||||
+ "status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
}
|
||||
diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go
|
||||
index 2a7fecd..994ea82 100644
|
||||
--- a/verifier/testsupport/signer/main.go
|
||||
+++ b/verifier/testsupport/signer/main.go
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
- "github.com/netkingdom/access-engine/pkg/api"
|
||||
+ "io"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -17,8 +17,12 @@ func main() {
|
||||
seed = 0x43
|
||||
kid = "next-admitted"
|
||||
}
|
||||
- var env api.DecisionEnvelope
|
||||
- if json.NewDecoder(os.Stdin).Decode(&env) != nil {
|
||||
+ raw, err := io.ReadAll(os.Stdin)
|
||||
+ if err != nil {
|
||||
+ os.Exit(1)
|
||||
+ }
|
||||
+ env, err := sign.DecodeEnvelope(raw)
|
||||
+ if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil {
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 28102cf..60f9e18 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -114,3 +114,48 @@ producer candidate OCI and actual runtime/config placement remain missing. The
|
||||
empty-key trust template deliberately refuses. No install, key read, caller mint,
|
||||
protected effect, policy switch or trust admission occurred. T02 remains progress,
|
||||
T03 wait; task source/live obligations remain separately in SECRETS-WP-0013.
|
||||
+
|
||||
+2026-10-16 trusted successor source preparation: refreshed exact API/sign source
|
||||
+from frozen Access B2/B3 R1 proposal (patch SHA-256
|
||||
+6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1).
|
||||
+Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and
|
||||
+reauthenticates original producer JSON. Existing request/claim/native/caller joins
|
||||
+remain mandatory; signed source observation additionally binds the native digest
|
||||
+and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures,
|
||||
+tamper/unknown provenance and source deadline refusals are locally checked.
|
||||
+Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper;
|
||||
+actual source revocation refuses. Actual builder-to-v2 strict positive execution
|
||||
+is separately blocked on missing owner-reviewed service_actor_binding mapping;
|
||||
+no caller context injection or equality relaxation hides that gap.
|
||||
+Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03
|
||||
+wait; no package/key/policy/native admission or runtime installation occurred.
|
||||
+
|
||||
+2026-10-16 successor R2 source preparation resolves the preceding builder gap:
|
||||
+only typed prod/platform execute-ccr with exact secrets-engine service subject
|
||||
+emits Access's fixed five-field public service_actor_binding before approval
|
||||
+issue. Subject/resource platform tenants are explicit before issue, matching the
|
||||
+producer's existing normalization and native act; lifecycle requests are unchanged.
|
||||
+Original carried context is still immutable. Actual builder -> Approval App ->
|
||||
+Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation
|
||||
+GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/
|
||||
+native changes, stale original claim, signature tamper and replay refuse. The
|
||||
+operation deadline is explicit whole seconds within v2's 15-second allow TTL;
|
||||
+no deadline is extended or waived. This resolves source correspondence only;
|
||||
+T02 remains progress/T03 wait, with no native trust/grant/policy admission.
|
||||
+
|
||||
+2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier
|
||||
+15s lifetime with the exact immutable operation deadline, bounded by its 120s
|
||||
+hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s
|
||||
+fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets
|
||||
+runtime and strict entire-operation deadline checks are unchanged. Contract prose
|
||||
+now states the actual fixed backend TTL and conservative fit rather than dynamic
|
||||
+TTL recomputation, and permits native token expiry earlier than operation deadline.
|
||||
+The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/
|
||||
+operationdeadline60, without a consumer timing waiver or native admission.
|
||||
+
|
||||
+Temporal proof precision observation: Approval consumption receipts use whole
|
||||
+seconds while Access R2 not_before preserves subsecond precision. Fast CAS can
|
||||
+therefore refuse receipt-before-start even though live calls are correctly
|
||||
+ordered. The positive proof explicitly waits real time until the next whole
|
||||
+second before CAS, without injected clocks or relaxed checks. Fast-CAS failure
|
||||
+is retained for owner resolution; no Secrets runtime change conceals the seam.
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md
|
||||
index 6128f2e..b9fea01 100644
|
||||
--- a/docs/task-scoped-openbao-execution-contract.md
|
||||
+++ b/docs/task-scoped-openbao-execution-contract.md
|
||||
@@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are:
|
||||
| source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source |
|
||||
| backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity |
|
||||
| operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body |
|
||||
-| operation_deadline | Upper bound no later than approval, PDP or task-token expiry |
|
||||
+| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier |
|
||||
| backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses |
|
||||
| expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents |
|
||||
|
||||
@@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive
|
||||
Task-token use budget is declared before issuance: two before-state reads, two
|
||||
writes, two readbacks and one self-revoke (seven uses), plus at most four explicit
|
||||
metadata capability checks and one reserved cleanup use (twelve total). Failed
|
||||
-backend calls count; never retry to evade limits. Task TTL is
|
||||
-`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus
|
||||
-the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain
|
||||
-for planned requests. No renewal or remint extends the operation. CCR38 keeps its
|
||||
+backend calls count; never retry to evade limits. The immutable
|
||||
+`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s).
|
||||
+The native adapter does not dynamically recompute or shorten that declaration.
|
||||
+Before issuance, conservatively require at least the declared TTL plus the 5s
|
||||
+cleanup reserve within the remaining operation/approval/PDP authority, including
|
||||
+clock uncertainty. The requested and explicit maximum native token TTL equal
|
||||
+the declared budget; returned native TTL must be positive and at most that budget.
|
||||
+The token can expire before the operation deadline; failure or expiry stops work
|
||||
+without remint. The signed operation deadline remains no later than approval,
|
||||
+PDP, authenticated caller and both original/fresh source validity. Access's
|
||||
+trusted source path independently requires the same TTL-plus-5s fit after source
|
||||
+I/O and an operation interval no longer than 120s. Therefore a structural 120s
|
||||
+backend budget cannot fit inside a 120s authority interval with the reserve and
|
||||
+refuses. No renewal or remint extends the operation. CCR38 keeps its
|
||||
own 300s/eight-use limit; login-self checks and revoke fit its separate budget.
|
||||
|
||||
## Failure, replay and recovery
|
||||
diff --git a/verifier/README.md b/verifier/README.md
|
||||
index c2c3bec..fe7ec93 100644
|
||||
--- a/verifier/README.md
|
||||
+++ b/verifier/README.md
|
||||
@@ -34,7 +34,7 @@ use [the release bundle contract](../docs/cli-release-bundle.md). This replaces
|
||||
manual helper copying for packaged deployments; package and key admission remain
|
||||
separate owner decisions.
|
||||
|
||||
-The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal,
|
||||
+The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage,
|
||||
identified by its patch SHA-256 in `producer-source.json`; this is source review,
|
||||
not a claim of published producer revision or runtime/key admission. The wrapper
|
||||
uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in
|
||||
diff --git a/verifier/producer-source.json b/verifier/producer-source.json
|
||||
index 99e651d..9a4d5a3 100644
|
||||
--- a/verifier/producer-source.json
|
||||
+++ b/verifier/producer-source.json
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
- "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
+ "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
"files": {
|
||||
"pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
@@ -13,7 +13,9 @@
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
},
|
||||
"source_proposal": {
|
||||
- "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4",
|
||||
+ "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "api_sign_bytes_unchanged": true,
|
||||
"status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
}
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 3109752..60f9e18 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -142,3 +142,20 @@ native changes, stale original claim, signature tamper and replay refuse. The
|
||||
operation deadline is explicit whole seconds within v2's 15-second allow TTL;
|
||||
no deadline is extended or waived. This resolves source correspondence only;
|
||||
T02 remains progress/T03 wait, with no native trust/grant/policy admission.
|
||||
+
|
||||
+2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier
|
||||
+15s lifetime with the exact immutable operation deadline, bounded by its 120s
|
||||
+hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s
|
||||
+fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets
|
||||
+runtime and strict entire-operation deadline checks are unchanged. Contract prose
|
||||
+now states the actual fixed backend TTL and conservative fit rather than dynamic
|
||||
+TTL recomputation, and permits native token expiry earlier than operation deadline.
|
||||
+The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/
|
||||
+operationdeadline60, without a consumer timing waiver or native admission.
|
||||
+
|
||||
+Temporal proof precision observation: Approval consumption receipts use whole
|
||||
+seconds while Access R2 not_before preserves subsecond precision. Fast CAS can
|
||||
+therefore refuse receipt-before-start even though live calls are correctly
|
||||
+ordered. The positive proof explicitly waits real time until the next whole
|
||||
+second before CAS, without injected clocks or relaxed checks. Fast-CAS failure
|
||||
+is retained for owner resolution; no Secrets runtime change conceals the seam.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,365 @@
|
|||
diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md
|
||||
index 5821175..c62dbe4 100644
|
||||
--- a/docs/task-execution-handoff.md
|
||||
+++ b/docs/task-execution-handoff.md
|
||||
@@ -38,7 +38,9 @@ ServiceAccount principal, `flex-auth` audience and live expiry bounding the
|
||||
operation deadline. This consumes the signed PDP's caller attestation; it does
|
||||
not independently perform TokenReview or promote the proposed server mapping.
|
||||
Human/delegation/projected-identity flags cannot substitute for this service seam.
|
||||
-No service_actor_binding request propagation or source-policy activation is added.
|
||||
+The later typed execution builder now propagates the fixed Access public
|
||||
+service_actor_binding before approval issue; it is not caller authentication.
|
||||
+No source-policy activation is added.
|
||||
|
||||
`as_owner_handoff()` returns a detached non-secret mapping with:
|
||||
|
||||
@@ -76,3 +78,36 @@ confirm original durable consumption using Approval's real provider client,
|
||||
journal it, then recheck its exact Plan, owner sources, grant and deadlines before
|
||||
any native operation. Neither a dataclass nor a caller-supplied boolean admits
|
||||
execution. Existing generic CLI CAS behavior is otherwise unchanged.
|
||||
+
|
||||
+
|
||||
+2026-10-16 retained-registry preapproval source preparation adds
|
||||
+`prepare_task_approval(cfg, entry, execution_contract=contract)`. It calls the
|
||||
+existing signed Check client with the original claim-free typed proposal and
|
||||
+accepts only a signed DENY normalization record. This neither authorizes nor
|
||||
+consumes an operation. No registry algorithm is copied and no registry is emptied.
|
||||
+
|
||||
+The detached `as_owner_handoff()` returns `original_request`,
|
||||
+`submitted_request_digest`, authoritative `native_binding`,
|
||||
+`approval_binding_digest`, normalization `decision_id`, `execution_contract`, and
|
||||
+signed `registry_snapshot_digest`. Exact original context (including JSON kinds),
|
||||
+submitted digest, recomputed evaluated digest, explicit approval-free digest,
|
||||
+policy/key admission, service/caller identity, decision time and deadline are
|
||||
+checked. Registry digest is evidence from the admitted signer; this method does
|
||||
+not independently grant registry/config admission. The caller uses the returned
|
||||
+native act and approval-free digest for normal Approval issuance and human entry.
|
||||
+It preserves the original proposal; final authorize_task_execution builds that
|
||||
+same proposal and attaches only the original issued claim. Access normalizes
|
||||
+again and independently joins current source facts. Changed evaluated facts or
|
||||
+native target refuse before CAS. A normalization DENY object/mapping is never
|
||||
+accepted by consume_task_execution.
|
||||
+
|
||||
+Normalization decision_time must be valid and nonfuture; there is deliberately
|
||||
+no invented standalone normalization TTL. The DENY receipt is not permission and
|
||||
+cannot discharge final signed ALLOW or source validation. Expired original task/
|
||||
+caller bounds refuse at preparation; a stale normalization's changed evaluated
|
||||
+facts refuse when Access independently checks the original issued claim against
|
||||
+the current retained registry. Unchanged historical normalization can only supply
|
||||
+public issue metadata; it cannot itself produce a CAS/native admission. The actual
|
||||
+source-server drift proof restarts Access with changed authoritative subject
|
||||
+roles after preapproval and observes final refusal with no new ALLOW record, no
|
||||
+CAS and unconsumed Approval state.
|
||||
diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py
|
||||
index 5af36be..aa998ab 100644
|
||||
--- a/src/secrets_engine/task_execution_handoff.py
|
||||
+++ b/src/secrets_engine/task_execution_handoff.py
|
||||
@@ -15,10 +15,11 @@ from threading import Lock
|
||||
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim
|
||||
-from secrets_engine.approval_consume import authorize_action, consume_approval
|
||||
+from secrets_engine.approval_consume import authorize_action, consume_approval, _expected_request
|
||||
from secrets_engine.application_time import read_window, validity_bounds
|
||||
-from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest
|
||||
+from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest, CONTRACT_VERSION
|
||||
from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json
|
||||
+from secrets_engine.decision_check import check_decision
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline
|
||||
|
||||
@@ -78,6 +79,93 @@ class TaskExecutionConsumption:
|
||||
idempotent: bool
|
||||
|
||||
|
||||
+def _same_json(left, right):
|
||||
+ # Type-sensitive semantic correspondence only; never signature material.
|
||||
+ return json.dumps(left,sort_keys=True,allow_nan=False)==json.dumps(right,sort_keys=True,allow_nan=False)
|
||||
+
|
||||
+
|
||||
+def _task_identity(request,envelope,descriptor,upper):
|
||||
+ binding=envelope['binding']
|
||||
+ subject=binding['subject']; resource=binding['resource']
|
||||
+ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
+ or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
+ or subject.get('tenant') not in (None,'tenant:platform')
|
||||
+ or 'principal' in subject.get('attributes',{})
|
||||
+ or not _same_json(envelope['subject'],subject) or not _same_json(envelope['resource'],resource)
|
||||
+ or descriptor['execution_actor']!='service:secrets-engine'
|
||||
+ or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system'))
|
||||
+ or resource.get('tenant') not in (None,'tenant:platform')
|
||||
+ or any(not _same_json(resource.get('attributes',{}).get(key),value)
|
||||
+ for key,value in request['resource']['attributes'].items())
|
||||
+ or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))):
|
||||
+ _refuse()
|
||||
+ caller=envelope['provenance'].get('caller')
|
||||
+ if (not isinstance(caller,dict) or caller.get('mode')!='enforce'
|
||||
+ or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth'
|
||||
+ or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline'])
|
||||
+ or upper >= _stamp(caller.get('not_after'))):
|
||||
+ _refuse()
|
||||
+
|
||||
+
|
||||
+@dataclass(frozen=True)
|
||||
+class TaskApprovalPreparation:
|
||||
+ """Detached public normalization facts; never authorization or consumption."""
|
||||
+ _serialized: str
|
||||
+
|
||||
+ def as_owner_handoff(self):
|
||||
+ return strict_json(self._serialized)
|
||||
+
|
||||
+
|
||||
+def prepare_task_approval(cfg, entry, *, execution_contract):
|
||||
+ """Read signed DENY normalization before Approval issue; never permit an act.
|
||||
+
|
||||
+ This read-only normalization record has no independent permission window.
|
||||
+ decision_time must be a valid nonfuture instant; original operation/caller
|
||||
+ deadlines remain live. Final source Check independently revalidates current
|
||||
+ registry/source facts; stale normalized facts cannot replace that Check.
|
||||
+
|
||||
+ Use returned authoritative native_binding and approval_binding_digest for
|
||||
+ issue, preserving original_request. Final Check carries only the original
|
||||
+ issued claim; producer renormalization independently refuses registry drift.
|
||||
+ """
|
||||
+ if type(execution_contract) is not ExecutionContract:
|
||||
+ _refuse()
|
||||
+ request=_expected_request(cfg,entry,'execute-ccr',execution_contract=execution_contract)
|
||||
+ window=read_window(cfg)
|
||||
+ require_execution_deadline(request,time_window=window)
|
||||
+ envelope=check_decision(base_url=str(cfg.pdp_url),token_file=cfg.pdp_token_file,request=request,
|
||||
+ trust_file=cfg.pdp_trust_file,verifier=cfg.pdp_verifier)
|
||||
+ try:
|
||||
+ window=read_window(cfg);lower,upper=validity_bounds(window=window)
|
||||
+ descriptor=execution_contract.as_dict();binding=envelope['binding'];provenance=envelope['provenance']
|
||||
+ if (envelope.get('contract_version')!=CONTRACT_VERSION or envelope.get('effect')!='deny' or not isinstance(envelope.get('id'),str) or not envelope['id']
|
||||
+ or provenance.get('policy_package')!=str(cfg.authorization_policy_package)
|
||||
+ or provenance.get('policy_version')!=str(cfg.authorization_policy_version)
|
||||
+ or not isinstance(provenance.get('registry_snapshot_digest'),str)
|
||||
+ or re.fullmatch(r'sha256:[0-9a-f]{64}',provenance['registry_snapshot_digest']) is None
|
||||
+ or request['action']!='execute-ccr' or binding.get('action')!='execute-ccr'
|
||||
+ or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform'
|
||||
+ or 'approval' in request['context'] or 'approval' in binding['context']
|
||||
+ or not _same_json(binding.get('context'),request['context'])
|
||||
+ or binding.get('submitted_request_digest')!=request_digest(request)
|
||||
+ or binding.get('request_digest')!=request_digest(binding)
|
||||
+ or binding.get('approval_binding_digest')!=binding['request_digest']
|
||||
+ or not _same_json(envelope.get('subject'),binding['subject']) or not _same_json(envelope.get('resource'),binding['resource'])
|
||||
+ or _stamp(provenance.get('decision_time'))>upper):
|
||||
+ _refuse()
|
||||
+ _task_identity(request,envelope,descriptor,upper)
|
||||
+ require_execution_deadline(request,time_window=window,bounds=(provenance['caller']['not_after'],))
|
||||
+ native=binding_from_check_request(binding)
|
||||
+ claim_binding_digest(**native) # Require native JSON profile, not a translated digest.
|
||||
+ result={'original_request':request,'submitted_request_digest':binding['submitted_request_digest'],
|
||||
+ 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'],
|
||||
+ 'decision_id':envelope['id'],'execution_contract':descriptor,
|
||||
+ 'registry_snapshot_digest':provenance['registry_snapshot_digest']}
|
||||
+ return TaskApprovalPreparation(json.dumps(result,sort_keys=True,separators=(',',':'),allow_nan=False))
|
||||
+ except (ValueError,KeyError,TypeError,RecursionError,AttributeError) as exc:
|
||||
+ raise DecisionError('task approval preparation malformed') from exc
|
||||
+
|
||||
+
|
||||
def _validate(cfg, action, contract):
|
||||
if type(contract) is not ExecutionContract or not action.artifacts:
|
||||
_refuse()
|
||||
@@ -108,31 +196,14 @@ def _validate(cfg, action, contract):
|
||||
_refuse()
|
||||
if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr'
|
||||
or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform'
|
||||
- or binding.get('context')!=request['context']
|
||||
- or request['context'].get('execution_contract')!=descriptor
|
||||
+ or not _same_json(binding.get('context'),request['context'])
|
||||
+ or not _same_json(request['context'].get('execution_contract'),descriptor)
|
||||
or binding.get('request_digest')!=request_digest(binding)
|
||||
or binding.get('approval_binding_digest')!=approval_binding_digest(binding)
|
||||
or action.binding.request_digest!=validated.request_digest
|
||||
or action.binding.decision_id!=validated.decision_id):
|
||||
_refuse()
|
||||
- subject=binding['subject']; resource=binding['resource']
|
||||
- if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'}
|
||||
- or subject.get('id')!='secrets-engine' or subject.get('type')!='service'
|
||||
- or subject.get('tenant') not in (None,'tenant:platform')
|
||||
- or 'principal' in subject.get('attributes',{})
|
||||
- or envelope['subject']!=subject or envelope['resource']!=resource
|
||||
- or descriptor['execution_actor']!='service:secrets-engine'
|
||||
- or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system'))
|
||||
- or resource.get('tenant') not in (None,'tenant:platform')
|
||||
- or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items())
|
||||
- or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))):
|
||||
- _refuse()
|
||||
- caller=envelope['provenance'].get('caller')
|
||||
- if (not isinstance(caller,dict) or caller.get('mode')!='enforce'
|
||||
- or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth'
|
||||
- or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline'])
|
||||
- or upper >= _stamp(caller.get('not_after'))):
|
||||
- _refuse()
|
||||
+ _task_identity(request,envelope,descriptor,upper)
|
||||
native=binding_from_check_request(binding)
|
||||
supplied=claim['binding']
|
||||
# Recompute the served native act too: Python equality alone conflates
|
||||
diff --git a/tests/test_task_approval_preparation.py b/tests/test_task_approval_preparation.py
|
||||
new file mode 100644
|
||||
index 0000000..96b254f
|
||||
--- /dev/null
|
||||
+++ b/tests/test_task_approval_preparation.py
|
||||
@@ -0,0 +1,89 @@
|
||||
+"""Signed DENY supplies preapproval facts; it never supplies execution authority."""
|
||||
+import copy
|
||||
+import pytest
|
||||
+from secrets_engine.authorization import request_digest
|
||||
+from secrets_engine.errors import DecisionError
|
||||
+from secrets_engine.task_execution_handoff import prepare_task_approval,consume_task_execution
|
||||
+from tests.test_task_execution_handoff import lane,prepared
|
||||
+from tests.signature_support import signature_tools
|
||||
+
|
||||
+
|
||||
+def normalize(envelope):
|
||||
+ envelope['effect']='deny'
|
||||
+ envelope['provenance']['registry_snapshot_digest']='sha256:'+'4'*64
|
||||
+ envelope['binding']['subject']['attributes']={'roles':['Operator'],'display_name':'registry service'}
|
||||
+ envelope['binding']['request_digest']=request_digest(envelope['binding'])
|
||||
+ envelope['binding']['approval_binding_digest']=envelope['binding']['request_digest']
|
||||
+
|
||||
+
|
||||
+def test_signed_registry_normalization_is_detached_and_cannot_consume(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ state['decision_change']=normalize
|
||||
+ result=prepare_task_approval(cfg,e,execution_contract=contract)
|
||||
+ output=result.as_owner_handoff()
|
||||
+ assert state['calls']==['check']
|
||||
+ assert 'approval' not in output['original_request']['context']
|
||||
+ assert output['approval_binding_digest']!=output['submitted_request_digest']
|
||||
+ assert 'attributes' not in output['original_request']['subject']
|
||||
+ assert output['execution_contract']==contract.as_dict()
|
||||
+ assert output['native_binding']['target']==output['original_request']['resource']
|
||||
+ output['original_request']['context']['purpose']='mutated'
|
||||
+ assert result.as_owner_handoff()['original_request']['context']['purpose']!='mutated'
|
||||
+ with pytest.raises(DecisionError):consume_task_execution(cfg,result)
|
||||
+ assert state['calls']==['check']
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('case',['allow','context','descriptor-type','submitted','evaluated','approval-digest','caller','native','future-time','policy'])
|
||||
+def test_signed_normalization_refuses_changed_correspondence(lane,case):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def change(envelope):
|
||||
+ normalize(envelope)
|
||||
+ if case=='allow':envelope['effect']='allow'
|
||||
+ elif case=='context':envelope['binding']['context']['purpose']='other'
|
||||
+ elif case=='descriptor-type':envelope['binding']['context']['execution_contract']['backend_budget']['ttl_seconds']=120.0
|
||||
+ elif case=='submitted':envelope['binding']['submitted_request_digest']='sha256:'+'0'*64
|
||||
+ elif case=='evaluated':envelope['binding']['request_digest']='sha256:'+'0'*64
|
||||
+ elif case=='approval-digest':envelope['binding']['approval_binding_digest']='sha256:'+'0'*64
|
||||
+ elif case=='caller':envelope['provenance']['caller']['mode']='disabled'
|
||||
+ elif case=='native':envelope['binding']['resource']['system']='other'
|
||||
+ elif case=='future-time':envelope['provenance']['decision_time']='2099-01-01T00:00:00Z'
|
||||
+ else:envelope['provenance']['policy_version']='other'
|
||||
+ state['decision_change']=change
|
||||
+ with pytest.raises(DecisionError):prepare_task_approval(cfg,e,execution_contract=contract)
|
||||
+ assert 'claim' not in state['calls'] and 'consume' not in state['calls']
|
||||
+
|
||||
+
|
||||
+def test_signed_context_numeric_kind_cannot_change(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ state['decision_change']=lambda envelope:envelope['binding']['context']['execution_contract']['backend_budget'].__setitem__('ttl_seconds',120.0)
|
||||
+ with pytest.raises(DecisionError):prepared(lane)
|
||||
+ assert 'consume' not in state['calls']
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('original,served',[(1,True),(1,1.0),(1.0,1),(False,0)])
|
||||
+def test_shared_original_target_subset_is_type_sensitive(lane,original,served):
|
||||
+ from datetime import datetime,timedelta,timezone
|
||||
+ from secrets_engine.task_execution_handoff import _task_identity
|
||||
+ from secrets_engine.approval_consume import _expected_request
|
||||
+ cfg,e,contract,state=lane
|
||||
+ request=_expected_request(cfg,e,'execute-ccr',execution_contract=contract)
|
||||
+ request['resource']['attributes']['requested_number']=original
|
||||
+ binding=copy.deepcopy(request);binding['resource']['attributes']['requested_number']=served
|
||||
+ envelope={'binding':binding,'subject':binding['subject'],'resource':binding['resource'],
|
||||
+ 'provenance':{'caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine',
|
||||
+ 'audience':'flex-auth','not_after':(datetime.now(timezone.utc)+timedelta(minutes=20)).isoformat()}}}
|
||||
+ with pytest.raises(DecisionError):_task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc))
|
||||
+ binding['resource']['attributes']['requested_number']=original
|
||||
+ binding['resource']['attributes']['legitimate_extra_registry_fact']=1.0
|
||||
+ _task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc))
|
||||
+
|
||||
+
|
||||
+def test_old_normalization_record_has_no_permission_window(lane):
|
||||
+ cfg,e,contract,state=lane
|
||||
+ def old(envelope):
|
||||
+ normalize(envelope)
|
||||
+ envelope['provenance']['decision_time']='2000-01-01T00:00:00Z'
|
||||
+ state['decision_change']=old
|
||||
+ normalization=prepare_task_approval(cfg,e,execution_contract=contract)
|
||||
+ with pytest.raises(DecisionError):consume_task_execution(cfg,normalization)
|
||||
+ assert state['calls']==['check']
|
||||
diff --git a/verifier/producer-source.json b/verifier/producer-source.json
|
||||
index 9a4d5a3..d34b5a6 100644
|
||||
--- a/verifier/producer-source.json
|
||||
+++ b/verifier/producer-source.json
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
- "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
+ "revision": "B2/B3 R3 retained-registry source proposal over B1 R2; canonical native-prep base 919ce25",
|
||||
"files": {
|
||||
"pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
@@ -13,8 +13,8 @@
|
||||
"decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527"
|
||||
},
|
||||
"source_proposal": {
|
||||
- "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4",
|
||||
- "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1",
|
||||
+ "patch_sha256": "46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6",
|
||||
+ "predecessor_patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4",
|
||||
"api_sign_bytes_unchanged": true,
|
||||
"status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md
|
||||
index a8a3845..a7e1e3a 100644
|
||||
--- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md
|
||||
+++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md
|
||||
@@ -124,3 +124,19 @@ coercion/idempotence, expiry, signer removal and replay refusal. No service bind
|
||||
propagation, policy gate flip, identity mapping admission or production trust/key
|
||||
change was made. Independent source integration/review and actual whole-provider
|
||||
proof remain required. T01/T03 and WP8-T06 retain their open native obligations.
|
||||
+
|
||||
+2026-10-16 retained-registry successor source preparation adds only the existing
|
||||
+signed Check/DENY preapproval flow. The original typed service proposal is sent
|
||||
+unchanged; normalized native act and evaluated approval-free digest from the
|
||||
+admitted producer record are used for normal Approval issuance. The original
|
||||
+proposal then receives only the original issued claim. Final Check re-normalizes
|
||||
+against retained owner registry/source facts, so authoritative drift refuses
|
||||
+before CAS. No local registry algorithm, new endpoint, empty-registry workaround
|
||||
+or consumer digest waiver is used. Signed DENY preparation has no independent
|
||||
+permission/freshness window and cannot consume or execute; current caller and
|
||||
+operation bounds are checked, and final signed ALLOW/source checks remain
|
||||
+mandatory. Exact original JSON kinds and requested-target subsets refuse
|
||||
+bool/int/float substitution while allowing extra authoritative registry facts.
|
||||
+Actual provider proof preparation is separately waiting on frozen Access source
|
||||
+and Approval's timestamp/transaction-clock repair; source native/trust/custody and
|
||||
+actual task admission remain open. T01/T03 and WP8-T06 are not closed.
|
||||
diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
index 60f9e18..f65ac96 100644
|
||||
--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
+++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md
|
||||
@@ -159,3 +159,19 @@ therefore refuse receipt-before-start even though live calls are correctly
|
||||
ordered. The positive proof explicitly waits real time until the next whole
|
||||
second before CAS, without injected clocks or relaxed checks. Fast-CAS failure
|
||||
is retained for owner resolution; no Secrets runtime change conceals the seam.
|
||||
+
|
||||
+2026-10-16 retained-registry successor source preparation adds only the existing
|
||||
+signed Check/DENY preapproval flow. The original typed service proposal is sent
|
||||
+unchanged; normalized native act and evaluated approval-free digest from the
|
||||
+admitted producer record are used for normal Approval issuance. The original
|
||||
+proposal then receives only the original issued claim. Final Check re-normalizes
|
||||
+against retained owner registry/source facts, so authoritative drift refuses
|
||||
+before CAS. No local registry algorithm, new endpoint, empty-registry workaround
|
||||
+or consumer digest waiver is used. Signed DENY preparation has no independent
|
||||
+permission/freshness window and cannot consume or execute; current caller and
|
||||
+operation bounds are checked, and final signed ALLOW/source checks remain
|
||||
+mandatory. Exact original JSON kinds and requested-target subsets refuse
|
||||
+bool/int/float substitution while allowing extra authoritative registry facts.
|
||||
+Actual provider proof preparation is separately waiting on frozen Access source
|
||||
+and Approval's timestamp/transaction-clock repair; source native/trust/custody and
|
||||
+actual task admission remain open. T02 remains progress and T03 wait; no native adoption is claimed.
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# PostgreSQL group journal source candidate
|
||||
|
||||
Prepared against NetKingdom `6cb43c0cc55212363a5406b02db046e2384a7c40` for
|
||||
NK-WP-0048-T14. No canonical change, native database/credential access, package
|
||||
installation, Docker call or production admission occurred in preparation.
|
||||
`source.patch`, exact postimages and `source-manifest.json` bind the source.
|
||||
|
||||
The existing reserve/get/claim-revoke/save/check seam is now explicit as
|
||||
`GroupJournalStore`. The new injection-only PostgreSQL implementation preserves
|
||||
prestate, intents, confirmed creation inventories and active fences using the
|
||||
same provisioner paths. SQL updates require the exact context/key/request and
|
||||
receipt version, plus exactly one affected row; the journal head and exact
|
||||
transition digest also advance conditionally under a metadata-row lock. Each
|
||||
new digest binds the prior marker and canonical body/context/key/version. An
|
||||
independent checkpoint compares and CAS-advances both sequence and content,
|
||||
refusing a divergent clone even if it reaches the same counter. SQL errors, stale CAS and ambiguous
|
||||
commit/checkpoint acknowledgment refuse rather than permitting the next LDAP
|
||||
effect. No exception handler can automatically turn those failures into success.
|
||||
|
||||
A frozen explicit tenant/directory/case/source/principal/epoch binding is required;
|
||||
there is no request-derived authority or default platform-root scope. The owner
|
||||
must additionally supply independent durable continuity checkpoint callbacks and
|
||||
renewable connection facts. These are injection seams, not new authorization or
|
||||
evidence services, and no native implementation or grant is invented. The
|
||||
ordinary server has no Pg environment switch: missing native callbacks keep the
|
||||
production Pg group path disabled. The existing SQLite path remains local/test
|
||||
only under accepted ADR-0015.
|
||||
|
||||
The migration creates tables inside the existing consumer schema. It performs
|
||||
no provisioning, initializes no epoch and admits/clears no quarantine. A journal
|
||||
restored behind the separately retained checkpoint is refused. Admission must
|
||||
still bind native restore/continuity, record retention, owner scope and runtime
|
||||
credentials; an injected callback that merely asserts success proves none of
|
||||
those boundaries. SQL locking fences only cooperating journal callers and does
|
||||
not close LLDAP external-writer/ABA races.
|
||||
|
||||
The Containerfile includes the module/migration and hash-pinned psycopg3 closure
|
||||
selected from User Engine's existing lockfile (Linux amd64 CPython3.12 only).
|
||||
No image was built and no package was fetched. Immutable rebuild, dependency
|
||||
scanning, platform/cell/consumer acceptance and native storage/backup/lease/TLS
|
||||
proof remain owner work. The native fixture uses the exact cached User Engine
|
||||
image's bundled psycopg version and records it; this is compatibility proof,
|
||||
not verification of a future rebuilt image.
|
||||
|
||||
Source checks cover explicit binding/no default, disabled server routing, stale
|
||||
checkpoint/quarantine/epoch refusal, exact row CAS, commit-before-checkpoint
|
||||
ordering, missing acknowledgment, cross-context/version import refusal,
|
||||
active-fence/capacity and connection failure sanitization. Scripted DB-API unit
|
||||
checks do not prove PostgreSQL semantics. The separately review-gated
|
||||
`native-fixture` uses real disposable PostgreSQL SQL and the existing synthetic
|
||||
LDAP Directory, with actual separate-process reopen and pg_dump/pg_restore.
|
||||
No PostgreSQL fixture execution or native acceptance is counted yet.
|
||||
|
||||
The counter-only predecessor and coordinator finding remain under `review-r0`;
|
||||
it is superseded and never admitted or executed. The successor adds content-bound
|
||||
continuity rather than accepting a same-counter result.
|
||||
|
||||
The source deliverable does not finish T14: runtime package/operating assignment,
|
||||
CNPG/rapp-postgres consumer/capacity and lease/custody approval, independently
|
||||
retained native checkpoints, accepted case/source/principal and protected
|
||||
session/evidence callbacks, LDAP writer fencing/CAS and production rollout all
|
||||
remain open. Existing native-storage-proposal-r1 records those owner routes.
|
||||
|
||||
|
||||
## R4 native failure refinement
|
||||
|
||||
R3 reached `bootstrap_ensure` then failed safely with RuntimeError and no SQLSTATE;
|
||||
initdb/readiness/migration/runtime grants and checkpoint initialization completed.
|
||||
Its exact internal exception text was never collected. Frozen fixture/receipt and
|
||||
source503e3 remain in `native-fixture/revisions/r3` and `review-r3`.
|
||||
|
||||
Local diagnosis demonstrated a PostgreSQL save contract defect: save replaced the
|
||||
nested receipt dictionaries while the real provisioner retained a group-state
|
||||
reference across its intent save. The returned created ID therefore updated a
|
||||
detached object; final receipt postimage checking refused. R4 updates only the
|
||||
acknowledged `_pg_version` after successful commit and independent checkpoint;
|
||||
it retains nested caller references, with no change to SQL/CAS, digest, ownership
|
||||
or ambiguous-commit behavior. A regression fails against frozen source, and a
|
||||
second runs the actual synthetic Directory/provisioner with actual PostgreSQL
|
||||
save/write/transaction code and mocked SQL transport to verify complete ensure.
|
||||
82 source tests pass. Actual native compatibility remains unproved until reviewed
|
||||
R4 execution; this fix does not imply production or tenant admission.
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
{
|
||||
"schema": "nk48.synthetic-postgres-journal-result.v1",
|
||||
"status": "synthetic_postgres_journal_compatibility_passed",
|
||||
"manifest_sha256": "d51f90816616848b93bcdc482e10a98d07061deb01eeed9e50700fc6e68b6051",
|
||||
"production_contacted": false,
|
||||
"actual_credentials_used": false,
|
||||
"native_acceptance_claimed": false,
|
||||
"session": {
|
||||
"run_id": "492a7afaec6b43ef92b98eb76025362f",
|
||||
"containers": [
|
||||
"nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-postgres",
|
||||
"nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-probe"
|
||||
],
|
||||
"label": "net-kingdom.recovery-run=492a7afaec6b43ef92b98eb76025362f"
|
||||
},
|
||||
"driver_stage": "cleanup",
|
||||
"rpc_trace": [
|
||||
{
|
||||
"stage": "anchor_launch",
|
||||
"operation": "run",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "anchor_containment",
|
||||
"operation": "context",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "anchor_containment",
|
||||
"operation": "info",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "anchor_containment",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "anchor_containment",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "postgres_private_import",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "postgres_initdb",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "postgres_launch",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "anchor_identity",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "helper_launch",
|
||||
"operation": "run",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "full_containment",
|
||||
"operation": "context",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "full_containment",
|
||||
"operation": "info",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "full_containment",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "full_containment",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "full_containment",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "helper_interpreter",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "candidate_import",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "bootstrap",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "database_dump",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "restart",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "concurrency",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "fork",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "lost-ack",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "database_restore",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "restored",
|
||||
"operation": "exec",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "rm",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "rm",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"stage": "cleanup",
|
||||
"operation": "container",
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"postgres_initdb_complete": true,
|
||||
"containment_verified_before_generated_private_import": true,
|
||||
"helper_interpreter_verified_before_candidate_import": true,
|
||||
"helper_psycopg_version": "3.3.6",
|
||||
"checks": {
|
||||
"only_loopback_no_external_routes": true,
|
||||
"actual_postgresql_journal_ensure": true,
|
||||
"explicit_scope_context_retained": true,
|
||||
"confirmed_inventory_excludes_preexisting": true,
|
||||
"runtime_ddl_denied": true,
|
||||
"separate_process_postgres_restart_and_revoke": true,
|
||||
"preexisting_shared_state_preserved": true,
|
||||
"foreign_context_receipt_refused": true,
|
||||
"concurrent_original_reservation_exactly_once": true,
|
||||
"stale_version_update_refused_without_head_advance": true,
|
||||
"concurrent_divergent_clones_same_counter_verified": true,
|
||||
"independent_exact_transition_cas_selects_one_winner": true,
|
||||
"reopened_loser_clone_refused_at_same_counter": true,
|
||||
"lost_checkpoint_ack_retains_pending_reservation": true,
|
||||
"reopened_uncertainty_refuses_ldap_effects": true,
|
||||
"active_fence_survives_process_reopen": true,
|
||||
"lost_synthetic_directory_response_retains_postgres_intent": true,
|
||||
"lost_sql_commit_reply_retains_committed_head": true,
|
||||
"reopened_stale_checkpoint_refuses_continuation": true,
|
||||
"actual_pg_restore_behind_checkpoint_detected": true,
|
||||
"restored_store_cannot_gain_replay_authority": true
|
||||
},
|
||||
"helper_diagnostics": {
|
||||
"bootstrap": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"bootstrap_connect_wait",
|
||||
"bootstrap_connected",
|
||||
"bootstrap_schema",
|
||||
"bootstrap_migration",
|
||||
"bootstrap_epoch_insert",
|
||||
"bootstrap_role_create",
|
||||
"bootstrap_runtime_grants",
|
||||
"bootstrap_checkpoint_init",
|
||||
"bootstrap_directory_init",
|
||||
"bootstrap_ensure",
|
||||
"bootstrap_ensure_readback",
|
||||
"bootstrap_runtime_ddl_refusal",
|
||||
"bootstrap_complete",
|
||||
"phase_complete"
|
||||
]
|
||||
},
|
||||
"restart": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"restart_load_snapshot",
|
||||
"restart_reopen_store",
|
||||
"restart_replay_ensure",
|
||||
"restart_revoke",
|
||||
"restart_revoke_readback",
|
||||
"restart_prestate_equality",
|
||||
"restart_foreign_context",
|
||||
"restart_complete",
|
||||
"phase_complete"
|
||||
]
|
||||
},
|
||||
"concurrency": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"phase_complete"
|
||||
]
|
||||
},
|
||||
"fork": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"phase_complete"
|
||||
]
|
||||
},
|
||||
"lost-ack": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"phase_complete"
|
||||
]
|
||||
},
|
||||
"restored": {
|
||||
"status": "passed",
|
||||
"stages": [
|
||||
"probe_entry",
|
||||
"loopback_check",
|
||||
"phase_dispatch",
|
||||
"phase_complete"
|
||||
]
|
||||
}
|
||||
},
|
||||
"psycopg_version": "3.3.6",
|
||||
"container_cleanup_obligations": [
|
||||
{
|
||||
"container": "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-probe",
|
||||
"status": "absence_verified"
|
||||
},
|
||||
{
|
||||
"container": "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-postgres",
|
||||
"status": "absence_verified"
|
||||
}
|
||||
],
|
||||
"container_cleanup_verified": true
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"schema": "nk48.postgres-source-integration-review.v1",
|
||||
"status": "prepared_for_coordinator_integration",
|
||||
"canonical_head_observed": "6cb43c0cc55212363a5406b02db046e2384a7c40",
|
||||
"base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40",
|
||||
"source_patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d",
|
||||
"source_paths": [
|
||||
"docs/service-group-provenance.md",
|
||||
"identity-provisioner/Containerfile",
|
||||
"identity-provisioner/group_provenance.py",
|
||||
"identity-provisioner/group_provenance_postgres.py",
|
||||
"identity-provisioner/migrations/group_journal_postgres_v1.sql",
|
||||
"identity-provisioner/provisioner.py",
|
||||
"identity-provisioner/requirements-postgres.txt",
|
||||
"identity-provisioner/tests/test_group_postgres.py"
|
||||
],
|
||||
"source_hashes": "../source-manifest.json exact pre/postimages verified",
|
||||
"source_paths_count": 8,
|
||||
"workplan_patch_sha256": "af9f720fba1b931679f866a29e8b3bc7d08f24a7f653db48daf485e35cb16eea",
|
||||
"workplan_preimage_sha256": "08215f6dc91a89706bf456a897d77de4032d47a21cd0252d202249619def135d",
|
||||
"combined_source_workplan_apply_check": true,
|
||||
"bounded_SQL_compatibility": "passed_R5",
|
||||
"raw_safe_receipt_sha256": "d72cc5e0788b7be69fc40c641e20680d29a175181334396b5f7fcecc0c0763d9",
|
||||
"source_tests": 82,
|
||||
"fixture_plan_tests": 13,
|
||||
"snapshot_tests": 2,
|
||||
"native_admission_claimed": false,
|
||||
"source_backend_unchanged_since_reviewed_R4": true,
|
||||
"canonical_worker_mutations": false,
|
||||
"remaining_gate": "T14progress: actual owner/package, CNPG+lease/TLS/capacity/recovery admission; authoritative protected binding and independently retained continuity callbacks; session/evidence/revocation; all LDAP writers fenced/CAS/ABA; production activation",
|
||||
"publication_projection_cleanup_followthrough": "coordinator separate receipts"
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"schema": "nk48.postgres-journal-compatibility-evidence.v1",
|
||||
"status": "bounded_disposable_compatibility_passed",
|
||||
"source_patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d",
|
||||
"manifest_sha256": "d51f90816616848b93bcdc482e10a98d07061deb01eeed9e50700fc6e68b6051",
|
||||
"raw_safe_receipt_sha256": "d72cc5e0788b7be69fc40c641e20680d29a175181334396b5f7fcecc0c0763d9",
|
||||
"original_safe_receipt": "/tmp/nk48-postgres-native-review-sguj7t8p/result.json",
|
||||
"phase_oracle_sets_exact": true,
|
||||
"all_declared_oracles_true": true,
|
||||
"fixture_reported_cleanup_absence": true,
|
||||
"independent_coordinator_cleanup_verification": "pending separate coordinator evidence",
|
||||
"source_checks": {
|
||||
"passed": 82,
|
||||
"seconds": 8.46
|
||||
},
|
||||
"fixture_plan_checks": {
|
||||
"passed": 13,
|
||||
"seconds": 0.019
|
||||
},
|
||||
"snapshot_regressions": {
|
||||
"passed": 2,
|
||||
"seconds": 0.236
|
||||
},
|
||||
"counts_are_native_acceptance": false,
|
||||
"helper_bundled_psycopg": "3.3.6",
|
||||
"production_packaged_psycopg": "3.3.5 (pinned closure, not image built here)",
|
||||
"failed_predecessors": [
|
||||
"native-fixture/revisions/r2",
|
||||
"native-fixture/revisions/r3",
|
||||
"native-fixture/revisions/r4"
|
||||
],
|
||||
"refinements": [
|
||||
"R1 helper cache preflight failed before execute; no raw stderr byte file exists",
|
||||
"R2 failed helper outcome not retained by old driver; precise failure unknown",
|
||||
"R3 diagnostics localized ensure failure; local source regression proved detached nested receipt state in Pg save, fixed version-only acknowledgment",
|
||||
"R4 actual bootstrap verified source fix; restart failed; local fixture regression proved group map alias and empty orphan backing sets; corrected snapshot retains complete current visible prestate and refuses nonempty orphans"
|
||||
],
|
||||
"not_claimed": [
|
||||
"CNPG native storage/admission/HA/backup/lease/TLS/placement",
|
||||
"authoritative tenant/case/source/principal bindings and independently retained continuity callbacks",
|
||||
"PDP/session/evidence/revocation owner acceptance",
|
||||
"all LDAP writer fencing/CAS/ABA exclusion",
|
||||
"production endpoint activation or owner assent"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue