Verify normalized task approval facts and preserve signed source types
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
5a2099f400
commit
4dd004a225
18 changed files with 756 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@ 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
|
||||
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
|
||||
|
||||
|
|
@ -77,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()
|
||||
|
|
@ -85,10 +174,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)
|
||||
|
|
@ -107,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'}
|
||||
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
|
||||
|
|
@ -140,6 +212,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'],
|
||||
|
|
|
|||
210
tests/fixtures/pdp-signature/approval-native-vectors.json
vendored
Normal file
210
tests/fixtures/pdp-signature/approval-native-vectors.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
89
tests/test_task_approval_preparation.py
Normal file
89
tests/test_task_approval_preparation.py
Normal file
|
|
@ -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']
|
||||
80
tests/test_trusted_successor.py
Normal file
80
tests/test_trusted_successor.py
Normal file
|
|
@ -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']
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
{
|
||||
"upstream": "https://github.com/netkingdom/access-engine",
|
||||
"revision": "62ac2b4e99dec2005c7c407fe633612578a7907d",
|
||||
"revision": "B2/B3 R3 retained-registry 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": "46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6",
|
||||
"predecessor_patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4",
|
||||
"api_sign_bytes_unchanged": true,
|
||||
"status": "source proposal; no runtime or key admission"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -114,3 +114,64 @@ 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.
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue