fix(auth): authenticate producer-signed decisions before authorization
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
6bd7129eac
commit
c80a944105
30 changed files with 2059 additions and 32 deletions
118
tests/fixtures/pdp-signature/decision_rotate_signed.json
vendored
Normal file
118
tests/fixtures/pdp-signature/decision_rotate_signed.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
118
tests/fixtures/pdp-signature/decision_rotate_signed_tampered.json
vendored
Normal file
118
tests/fixtures/pdp-signature/decision_rotate_signed_tampered.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "deny",
|
||||
"reason": "tampered_after_signing",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
35
tests/signature_support.py
Normal file
35
tests/signature_support.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Native producer signing fixtures, never an authentication bypass."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
PUBLIC='IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def signature_tools(tmp_path_factory):
|
||||
directory=tmp_path_factory.mktemp('signature-tools')
|
||||
# Private writable build cache keeps tests runnable in the workspace sandbox.
|
||||
env={**os.environ,'GOCACHE':os.environ.get('GOCACHE',str(directory/'cache'))}
|
||||
root=Path(__file__).parents[1]/'verifier'
|
||||
for name,package in [('verify','.'),('sign','./testsupport/signer')]:
|
||||
subprocess.run(['go','build','-o',str(directory/name),package],cwd=root,env=env,check=True,timeout=120)
|
||||
return directory
|
||||
|
||||
|
||||
def admit_tools(cfg, directory, tools):
|
||||
trust={'schema':'secrets-engine.pdp-trust.v1','evaluator':'stub/local','mode':'standalone',
|
||||
'policy_package':cfg.authorization_policy_package,'policy_version':cfg.authorization_policy_version,
|
||||
'policy_package_digest':'sha256:'+'1'*64,'keys':{'testdata-ed25519':PUBLIC}}
|
||||
path=directory/'pdp-trust.json';path.write_text(json.dumps(trust))
|
||||
from dataclasses import is_dataclass, replace
|
||||
if is_dataclass(cfg):
|
||||
return replace(cfg,pdp_trust_file=path,pdp_verifier=tools/'verify')
|
||||
cfg.pdp_trust_file=path;cfg.pdp_verifier=tools/'verify'
|
||||
return cfg
|
||||
|
||||
|
||||
def sign_envelope(envelope,tools,next_key=False):
|
||||
result=subprocess.run([str(tools/'sign')]+(['next'] if next_key else []),input=json.dumps(envelope),text=True,capture_output=True,check=True,timeout=3)
|
||||
return json.loads(result.stdout)
|
||||
|
|
@ -194,8 +194,11 @@ def test_environment_selects_separate_approval_secret(monkeypatch):
|
|||
assert cfg.keycape_client_secret_file == Path("/protected/openbao.secret")
|
||||
|
||||
|
||||
from tests.signature_support import signature_tools, admit_tools, sign_envelope
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, "exchange", "conflict", "wrong-action"])
|
||||
def test_cli_gate_exchanges_for_claim_and_consume(tmp_path, monkeypatch, failure):
|
||||
def test_cli_gate_exchanges_for_claim_and_consume(tmp_path, monkeypatch, failure, signature_tools):
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw["stage"] = "prod"
|
||||
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "approval-test", "purpose": "synthetic proof"}
|
||||
|
|
@ -205,6 +208,9 @@ def test_cli_gate_exchanges_for_claim_and_consume(tmp_path, monkeypatch, failure
|
|||
cfg.authorization_subject_type = "service"
|
||||
cfg.authorization_policy_package = "secrets-engine.catalog-lane.lifecycle"
|
||||
cfg.authorization_policy_version = "v2"
|
||||
admit_tools(cfg,tmp_path,signature_tools)
|
||||
original_decision=AuthorizationStub.decision
|
||||
monkeypatch.setattr(AuthorizationStub,"decision",lambda self,request:sign_envelope(original_decision(self,request),signature_tools))
|
||||
cfg.evidence_dir = tmp_path / "evidence"
|
||||
cfg.pdp_token_file = tmp_path / "pdp.token"
|
||||
cfg.pdp_token_file.write_text("synthetic-pdp-token")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,19 @@ from secrets_engine.catalog import validate_entry
|
|||
from secrets_engine.errors import DecisionError
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
||||
from tests.signature_support import signature_tools, admit_tools, sign_envelope
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admitted_native_signer(monkeypatch, tmp_path, signature_tools):
|
||||
original = _Cfg.__init__
|
||||
def configured(self, *a, **kw):
|
||||
original(self,*a,**kw)
|
||||
admit_tools(self,tmp_path,signature_tools)
|
||||
monkeypatch.setattr(_Cfg,'__init__',configured)
|
||||
original_decision=AuthorizationStub.decision
|
||||
monkeypatch.setattr(AuthorizationStub,'decision',lambda self,request:sign_envelope(original_decision(self,request),signature_tools))
|
||||
|
||||
APPROVAL_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
||||
|
||||
|
||||
class _Cfg:
|
||||
|
|
@ -45,7 +57,7 @@ class _Cfg:
|
|||
def _entry():
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw["approval"] = dict(raw.get("approval") or {})
|
||||
raw["approval"]["authorization_id"] = AUTH_ID
|
||||
raw["approval"]["authorization_id"] = APPROVAL_ID
|
||||
raw["approval"]["purpose"] = "contract-test"
|
||||
return validate_entry(raw)
|
||||
|
||||
|
|
@ -76,7 +88,7 @@ def _served(entry=None, action="deactivate", fields=("api_token",), **over):
|
|||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": AUTH_ID,
|
||||
"approval_id": APPROVAL_ID,
|
||||
"state": "valid",
|
||||
"valid_now": True,
|
||||
"consumed": False,
|
||||
|
|
@ -116,7 +128,7 @@ def _opener(envelope, status=200):
|
|||
|
||||
|
||||
def _resolve(cfg, entry, envelope, action="deactivate"):
|
||||
pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
|
||||
pdp = AuthorizationStub(approval_id=APPROVAL_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
|
||||
def check(request, timeout=None):
|
||||
submitted = json.loads(request.data)
|
||||
return _opener(pdp.decision(submitted))(request)
|
||||
|
|
@ -136,7 +148,7 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
|
|||
cfg = _Cfg(_token(tmp_path))
|
||||
binding = _resolve(cfg, entry, _served())
|
||||
assert binding is not None
|
||||
assert binding.binding.approval_id == AUTH_ID
|
||||
assert binding.binding.approval_id == APPROVAL_ID
|
||||
assert binding.binding.request_digest != request_digest(_expected_request(entry))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ def test_the_guard_runs_before_the_token_is_read(tmp_path):
|
|||
"""
|
||||
missing = tmp_path / "never-read.token"
|
||||
with pytest.raises(DecisionError, match="in-cluster Service name"):
|
||||
check_decision(
|
||||
check_decision(allow_unsigned_test_fixture=True,
|
||||
base_url="http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080",
|
||||
token_file=missing,
|
||||
request={},
|
||||
|
|
|
|||
110
tests/test_decision_signature.py
Normal file
110
tests/test_decision_signature.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Producer-signed native replay and real consumer boundary regression checks."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine import approval_consume
|
||||
from secrets_engine.authorization import validate_decision_envelope
|
||||
from secrets_engine.decision_check import check_decision
|
||||
from secrets_engine.decision_signature import load_trust
|
||||
from secrets_engine.errors import DecisionError
|
||||
from tests.test_decision_transport import server, credential, clear_proxies
|
||||
|
||||
FIXTURE = Path(__file__).parent / 'fixtures/pdp-signature/decision_rotate_signed.json'
|
||||
PUBLIC = 'IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'
|
||||
|
||||
from tests.signature_support import signature_tools
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def verifier(signature_tools):
|
||||
return signature_tools/'verify'
|
||||
|
||||
@pytest.fixture
|
||||
def trust_file(tmp_path):
|
||||
envelope = json.loads(FIXTURE.read_bytes())
|
||||
provenance = envelope['provenance']
|
||||
trust = {k:provenance[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')}
|
||||
trust.update(schema='secrets-engine.pdp-trust.v1', keys={'testdata-ed25519':PUBLIC})
|
||||
path = tmp_path/'trust.json'; path.write_text(json.dumps(trust)); return path
|
||||
|
||||
|
||||
def checked(body, credential, trust_file, verifier):
|
||||
with server(body=body) as (origin, received):
|
||||
result = check_decision(base_url=origin, token_file=credential, request={},
|
||||
trust_file=trust_file, verifier=verifier)
|
||||
assert received == [True]
|
||||
return result
|
||||
|
||||
|
||||
def test_genuine_native_signature_at_consumer_boundary(credential, trust_file, verifier):
|
||||
result = checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
|
||||
assert result['effect']=='allow'
|
||||
assert result['binding']==json.loads(FIXTURE.read_bytes())['binding']
|
||||
|
||||
@pytest.mark.parametrize('change', ['allow_forgery','tampered','unknown_key','unsigned','absent','algorithm','unknown_field','duplicate','null','case_alias'])
|
||||
def test_response_authentication_refuses(credential, trust_file, verifier, change):
|
||||
env=json.loads(FIXTURE.read_bytes())
|
||||
if change=='allow_forgery': env['signature']['value']='A'*86
|
||||
elif change=='tampered': env['binding']['action']='destroy'
|
||||
elif change=='unknown_key': env['signature']['kid']='new-key'
|
||||
elif change=='unsigned': env['signature']={'mode':'unsigned'}
|
||||
elif change=='absent': del env['signature']
|
||||
elif change=='algorithm': env['signature']['alg']='none'
|
||||
elif change=='unknown_field': env['unbound_authority']='allow'
|
||||
elif change=='null': env['binding']=None
|
||||
elif change=='case_alias': env['Effect']='deny'
|
||||
body=json.dumps(env).encode()
|
||||
if change=='duplicate': body=body[:-1]+b',"effect":"deny"}'
|
||||
with pytest.raises(DecisionError): checked(body,credential,trust_file,verifier)
|
||||
|
||||
@pytest.mark.parametrize('field',['evaluator','mode','policy_package','policy_version','policy_package_digest'])
|
||||
def test_independent_issuer_and_pin_refusal(credential, trust_file, verifier, field):
|
||||
trust=json.loads(trust_file.read_text()); trust[field]='wrong'; trust_file.write_text(json.dumps(trust))
|
||||
with pytest.raises(DecisionError): checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
|
||||
|
||||
|
||||
def test_rotation_overlap_then_removal(credential, trust_file, verifier, signature_tools):
|
||||
trust=json.loads(trust_file.read_text()); trust['keys']['next-admitted']='Ivwpd5Lwtv_Av8_bftsMCqFOAlo2XsDjQuhuOCnLdLY'
|
||||
trust_file.write_text(json.dumps(trust)); checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
|
||||
from tests.signature_support import sign_envelope
|
||||
next_body=json.dumps(sign_envelope(json.loads(FIXTURE.read_bytes()),signature_tools,next_key=True)).encode()
|
||||
checked(next_body,credential,trust_file,verifier)
|
||||
del trust['keys']['testdata-ed25519']; trust_file.write_text(json.dumps(trust))
|
||||
with pytest.raises(DecisionError): checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
|
||||
checked(next_body,credential,trust_file,verifier)
|
||||
|
||||
|
||||
def test_missing_trust_precedes_credential_read(tmp_path):
|
||||
with pytest.raises(DecisionError,match='trust'):
|
||||
check_decision(base_url='http://127.0.0.1:1',token_file=tmp_path/'must-not-read',request={})
|
||||
|
||||
|
||||
def test_production_authorization_has_no_unsigned_path(monkeypatch, credential, trust_file, verifier):
|
||||
observation=SimpleNamespace(request={})
|
||||
monkeypatch.setattr(approval_consume,'resolve_approval_observation',lambda *a,**kw:observation)
|
||||
with server(body=b'{"effect":"allow"}') as (origin,received):
|
||||
cfg=SimpleNamespace(pdp_url=origin,pdp_token_file=credential,pdp_trust_file=trust_file,pdp_verifier=verifier,
|
||||
authorization_policy_package='package',authorization_policy_version='v1')
|
||||
with pytest.raises(DecisionError,match='authentication'):
|
||||
approval_consume.authorize_action(cfg,None,'rotate')
|
||||
assert received == [True]
|
||||
|
||||
|
||||
def test_missing_trust_precedes_approval_observation(monkeypatch):
|
||||
def forbidden(*a,**kw): raise AssertionError('approval credential path reached')
|
||||
monkeypatch.setattr(approval_consume,'resolve_approval_observation',forbidden)
|
||||
with pytest.raises(DecisionError,match='trust'):
|
||||
approval_consume.authorize_action(SimpleNamespace(pdp_url='http://127.0.0.1:1',pdp_token_file=Path('/must-not-read')),None,'rotate')
|
||||
|
||||
@pytest.mark.parametrize('body', [b'{"effect":NaN}',b'{"effect":Infinity}',b'['*2000+b'0'+b']'*2000,b'{"x":1,"x":2}',b'\xff'])
|
||||
def test_malformed_body_refuses_as_decision_error(credential,trust_file,verifier,body):
|
||||
with pytest.raises(DecisionError): checked(body,credential,trust_file,verifier)
|
||||
|
||||
|
||||
def test_default_check_requires_authentication_before_credential_read(tmp_path):
|
||||
with pytest.raises(DecisionError,match='trust'):
|
||||
check_decision(base_url='http://127.0.0.1:1',token_file=tmp_path/'must-not-read',request={})
|
||||
|
|
@ -67,7 +67,7 @@ def test_redirect_never_contacts_another_origin(credential, status):
|
|||
with server() as (destination, received):
|
||||
with server(status=status, location=destination + '/sink') as (source, sent):
|
||||
with pytest.raises(DecisionError):
|
||||
check_decision(base_url=source, token_file=credential, request={})
|
||||
check_decision(allow_unsigned_test_fixture=True, base_url=source, token_file=credential, request={})
|
||||
assert sent == [True]
|
||||
assert received == []
|
||||
|
||||
|
|
@ -77,18 +77,22 @@ def test_environment_proxy_never_receives_request(credential, monkeypatch):
|
|||
with server() as (source, direct):
|
||||
monkeypatch.setenv('http_proxy', proxy)
|
||||
monkeypatch.setenv('no_proxy', '')
|
||||
assert check_decision(base_url=source, token_file=credential, request={}) == {}
|
||||
assert check_decision(allow_unsigned_test_fixture=True, base_url=source, token_file=credential, request={}) == {}
|
||||
assert direct == [True]
|
||||
assert proxied == []
|
||||
|
||||
|
||||
def test_approval_flow_uses_the_same_safe_transport(credential, monkeypatch):
|
||||
from tests.signature_support import signature_tools, admit_tools
|
||||
|
||||
|
||||
def test_approval_flow_uses_the_same_safe_transport(credential, monkeypatch, signature_tools):
|
||||
observation = SimpleNamespace(request={})
|
||||
monkeypatch.setattr(approval_consume, 'resolve_approval_observation', lambda *a, **kw: observation)
|
||||
with server() as (destination, received):
|
||||
with server(status=302, location=destination + '/sink') as (source, sent):
|
||||
cfg = SimpleNamespace(pdp_url=source, pdp_token_file=credential,
|
||||
authorization_policy_package='test', authorization_policy_version='v1')
|
||||
admit_tools(cfg, credential.parent, signature_tools)
|
||||
with pytest.raises(DecisionError):
|
||||
approval_consume.authorize_action(cfg, object(), 'rotate', None)
|
||||
assert sent == [True]
|
||||
|
|
@ -105,7 +109,7 @@ def test_approval_flow_uses_the_same_safe_transport(credential, monkeypatch):
|
|||
])
|
||||
def test_invalid_address_refused_before_credential_read(url, tmp_path):
|
||||
with pytest.raises(DecisionError, match='URL'):
|
||||
check_decision(base_url=url, token_file=tmp_path / 'missing', request={})
|
||||
check_decision(allow_unsigned_test_fixture=True, base_url=url, token_file=tmp_path / 'missing', request={})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body,reason', [
|
||||
|
|
@ -115,4 +119,4 @@ def test_invalid_address_refused_before_credential_read(url, tmp_path):
|
|||
def test_response_limits_and_encoding_fail_closed(credential, body, reason):
|
||||
with server(body=body) as (source, _):
|
||||
with pytest.raises(DecisionError, match=reason):
|
||||
check_decision(base_url=source, token_file=credential, request={})
|
||||
check_decision(allow_unsigned_test_fixture=True, base_url=source, token_file=credential, request={})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ 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, AUTH_ID
|
||||
from tests.test_consume_binding_join import _Cfg, _token, _opener, APPROVAL_ID
|
||||
from tests.authorization_stub import AuthorizationStub
|
||||
|
||||
H = 'sha256:' + 'a' * 64
|
||||
|
|
@ -21,7 +21,7 @@ H = 'sha256:' + 'a' * 64
|
|||
def entry():
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw['stage'] = 'prod'
|
||||
raw['approval'] = {'model': 'bootstrap-only', 'authorization_id': AUTH_ID, 'purpose': 'metadata-test'}
|
||||
raw['approval'] = {'model': 'bootstrap-only', 'authorization_id': APPROVAL_ID, 'purpose': 'metadata-test'}
|
||||
return validate_entry(raw)
|
||||
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ def test_four_builders_preserve_context_and_binding(tmp_path):
|
|||
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=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
|
||||
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
|
||||
|
|
@ -76,7 +76,7 @@ def test_four_builders_preserve_context_and_binding(tmp_path):
|
|||
consume_bodies = []
|
||||
def consume(req, timeout=None):
|
||||
value = json.loads(req.data); consume_bodies.append(value)
|
||||
return _Response(200, {'status':'consumed', 'approval_id':AUTH_ID,
|
||||
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,
|
||||
|
|
@ -92,7 +92,7 @@ def test_four_builders_preserve_context_and_binding(tmp_path):
|
|||
@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=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
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'
|
||||
|
|
@ -164,7 +164,7 @@ def test_expired_contract_refuses_before_claim(tmp_path):
|
|||
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=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
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'})
|
||||
|
|
@ -190,8 +190,13 @@ def test_raw_dictionary_and_direct_invalid_type_cannot_bypass_validation():
|
|||
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=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from secrets_engine.errors import CatalogError, DecisionError
|
|||
from tests.authorization_stub import AuthorizationStub
|
||||
from tests.test_catalog import VALID
|
||||
from tests.test_consume_binding_join import (
|
||||
AUTH_ID, _Cfg, _entry, _opener, _resolve, _served, _token,
|
||||
APPROVAL_ID, _Cfg, _entry, _opener, _resolve, _served, _token,
|
||||
)
|
||||
from tests.test_exec_owner import bound
|
||||
from tests.test_privileged_cli_evidence import _config, _records
|
||||
|
|
@ -98,7 +98,7 @@ def test_consume_requires_observed_human_control_even_with_demo_enabled(tmp_path
|
|||
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
require_production_consume(_config(tmp_path), entry,
|
||||
binding=ConsumeBinding(AUTH_ID, "sha256:"+"a"*64),
|
||||
binding=ConsumeBinding(APPROVAL_ID, "sha256:"+"a"*64),
|
||||
opener=lambda *a, **k: pytest.fail("must not consume"))
|
||||
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ def test_declared_control_still_consumes_with_demo_enabled(tmp_path, monkeypatch
|
|||
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||
cfg = replace(_config(tmp_path), approval_url="https://approval.example",
|
||||
approval_token_file=_token(tmp_path))
|
||||
binding = ConsumeBinding(AUTH_ID, "sha256:"+"a"*64, human_control=True)
|
||||
binding = ConsumeBinding(APPROVAL_ID, "sha256:"+"a"*64, human_control=True)
|
||||
calls = []
|
||||
def consume(request, **kwargs):
|
||||
calls.append(request.full_url)
|
||||
|
|
@ -116,8 +116,8 @@ def test_declared_control_still_consumes_with_demo_enabled(tmp_path, monkeypatch
|
|||
response.getcode = lambda: 200
|
||||
return response
|
||||
result = require_production_consume(cfg, entry, binding=binding, opener=consume)
|
||||
assert result.approval_id == AUTH_ID
|
||||
assert calls == [f"https://approval.example/v1/approvals/{AUTH_ID}/consume"]
|
||||
assert result.approval_id == APPROVAL_ID
|
||||
assert calls == [f"https://approval.example/v1/approvals/{APPROVAL_ID}/consume"]
|
||||
|
||||
|
||||
def test_human_declaration_is_rechecked_after_pdp(tmp_path, monkeypatch):
|
||||
|
|
@ -127,7 +127,7 @@ def test_human_declaration_is_rechecked_after_pdp(tmp_path, monkeypatch):
|
|||
claim["binding"]["human_control"] = True
|
||||
monkeypatch.setattr(approval_consume, "fetch_approval_claim", lambda **k: claim)
|
||||
cfg = _Cfg(_token(tmp_path))
|
||||
pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package,
|
||||
pdp = AuthorizationStub(approval_id=APPROVAL_ID, package=cfg.authorization_policy_package,
|
||||
version=cfg.authorization_policy_version)
|
||||
original_validate = approval_consume.validate_decision_envelope
|
||||
def invalidate_after_check(*args, **kwargs):
|
||||
|
|
@ -145,7 +145,7 @@ def test_human_declaration_is_rechecked_after_pdp(tmp_path, monkeypatch):
|
|||
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=AUTH_ID,
|
||||
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",
|
||||
|
|
@ -169,3 +169,8 @@ def test_configured_factory_catalog_still_requires_human_control():
|
|||
assert entry.approval["human_control"] is True
|
||||
assert entry.delivery_config["exec_owner"]["status"] == "configured"
|
||||
assert not entry.approval.get("authorization_id")
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -234,3 +234,15 @@ def test_unconfigured_engine_still_fails_closed(bao_dev, lane, tmp_path):
|
|||
)
|
||||
with pytest.raises(DecisionError, match="live production remains disabled"):
|
||||
cli._require_lane_approval(cfg, entry, "apply")
|
||||
|
||||
|
||||
from tests.signature_support import signature_tools, admit_tools, sign_envelope
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admitted_native_signer(monkeypatch,signature_tools):
|
||||
original_cfg=_cfg
|
||||
def configured(tmp_path,*args,**kwargs):
|
||||
return admit_tools(original_cfg(tmp_path,*args,**kwargs),tmp_path,signature_tools)
|
||||
monkeypatch.setattr(__import__(__name__,fromlist=['_cfg']),'_cfg',configured)
|
||||
original_decision=AuthorizationStub.decision
|
||||
monkeypatch.setattr(AuthorizationStub,'decision',lambda self,request:sign_envelope(original_decision(self,request),signature_tools))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue