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
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue