443 lines
26 KiB
Diff
443 lines
26 KiB
Diff
|
|
diff --git a/tenancy.yaml b/tenancy.yaml
|
||
|
|
--- a/tenancy.yaml
|
||
|
|
+++ b/tenancy.yaml
|
||
|
|
@@ -11,8 +11,16 @@
|
||
|
|
workload_identity:
|
||
|
|
name: flex-auth
|
||
|
|
kind: platform-service
|
||
|
|
- responsible_repo: flex-auth
|
||
|
|
+ responsible_repo: access-engine
|
||
|
|
identity_bindings:
|
||
|
|
+ # Existing retained Secrets PDP pin; identity source, not key admission.
|
||
|
|
+ - scheme: kubernetes-service-account
|
||
|
|
+ authority: railiance01
|
||
|
|
+ subject: system:serviceaccount:flex-auth:flex-auth-secrets-engine
|
||
|
|
+ principal_type: service
|
||
|
|
+ environment: prod
|
||
|
|
+ evidence:
|
||
|
|
+ - docs/native-signer-workload.json
|
||
|
|
- scheme: kubernetes-service-account
|
||
|
|
authority: railiance01
|
||
|
|
subject: system:serviceaccount:flex-auth:flex-auth-ops-warden
|
||
|
|
diff --git a/values/secrets-engine.yaml b/values/secrets-engine.yaml
|
||
|
|
--- a/values/secrets-engine.yaml
|
||
|
|
+++ b/values/secrets-engine.yaml
|
||
|
|
@@ -13,6 +13,8 @@
|
||
|
|
- /opt/flex-auth/examples/secrets-engine/policy_package.md
|
||
|
|
callerAuth:
|
||
|
|
mode: enforce
|
||
|
|
+ # Retained runtime audience is explicit; signer preparation does not rename it.
|
||
|
|
+ audience: flex-auth
|
||
|
|
kubernetesURL: https://10.43.0.1
|
||
|
|
binding: secrets-engine=system:serviceaccount:secrets-engine:secrets-engine
|
||
|
|
consumer:
|
||
|
|
diff --git a/charts/flex-auth/templates/deployment.yaml b/charts/flex-auth/templates/deployment.yaml
|
||
|
|
--- a/charts/flex-auth/templates/deployment.yaml
|
||
|
|
+++ b/charts/flex-auth/templates/deployment.yaml
|
||
|
|
@@ -83,6 +83,8 @@
|
||
|
|
{{- if not (and .Values.readback .Values.readback.enabled) }}
|
||
|
|
{{- $binding = required "callerAuth.binding is required when callerAuth.mode is warn or enforce" .Values.callerAuth.binding }}
|
||
|
|
{{- end }}
|
||
|
|
+ - --caller-audience
|
||
|
|
+ - {{ required "callerAuth.audience is required" .Values.callerAuth.audience | quote }}
|
||
|
|
- --caller-auth-mode
|
||
|
|
- {{ include "flex-auth.callerAuth.mode" . | quote }}
|
||
|
|
- --caller-kubernetes-url
|
||
|
|
diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml
|
||
|
|
--- a/.forgejo/workflows/image.yaml
|
||
|
|
+++ b/.forgejo/workflows/image.yaml
|
||
|
|
@@ -51,7 +51,7 @@
|
||
|
|
SHORT="${REF:0:7}"
|
||
|
|
mkdir -p buildctx "${HOME}/bin"
|
||
|
|
wget -qO /tmp/repo.tar.gz \
|
||
|
|
- "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
|
||
|
|
+ "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${REF}.tar.gz"
|
||
|
|
tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1
|
||
|
|
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
|
||
|
|
| tar xz --strip-components=1 -C "${HOME}/bin" docker/docker
|
||
|
|
diff --git a/tools/verify_native_signer_receipt.py b/tools/verify_native_signer_receipt.py
|
||
|
|
new file mode 100644
|
||
|
|
--- /dev/null
|
||
|
|
+++ b/tools/verify_native_signer_receipt.py
|
||
|
|
@@ -0,0 +1,135 @@
|
||
|
|
+#!/usr/bin/env python3
|
||
|
|
+"""Verify captured native decision receipts with the installed Secrets verifier.
|
||
|
|
+
|
||
|
|
+Offline only: no token input, network request, key generation, build or effect.
|
||
|
|
+Use an independently reviewed admission file; success never admits that file.
|
||
|
|
+"""
|
||
|
|
+from __future__ import annotations
|
||
|
|
+import argparse
|
||
|
|
+import copy
|
||
|
|
+from datetime import datetime, timezone
|
||
|
|
+import hashlib
|
||
|
|
+import json
|
||
|
|
+from pathlib import Path
|
||
|
|
+import re
|
||
|
|
+
|
||
|
|
+# These are the consumer's existing verifier/binding functions, not another PDP
|
||
|
|
+# or signature/canonicalization implementation. Install the reviewed bundle first.
|
||
|
|
+from secrets_engine.authorization import request_digest, validate_decision_envelope
|
||
|
|
+from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json
|
||
|
|
+from secrets_engine.errors import DecisionError
|
||
|
|
+
|
||
|
|
+FIELDS = {'schema','trust_file','verifier','verifier_sha256','allow_request','allow_envelope','allow_record','deny_request','deny_envelope','deny_record','expected_caller'}
|
||
|
|
+
|
||
|
|
+def read(path: str) -> bytes:
|
||
|
|
+ target = Path(path)
|
||
|
|
+ if not target.is_absolute():
|
||
|
|
+ raise ValueError('absolute input path required')
|
||
|
|
+ with target.open("rb") as stream:
|
||
|
|
+ data = stream.read(524289)
|
||
|
|
+ if len(data) > 524288:
|
||
|
|
+ raise ValueError('receipt input too large')
|
||
|
|
+ return data
|
||
|
|
+
|
||
|
|
+def same_json_types(left, right) -> bool:
|
||
|
|
+ """JSON value correspondence only; never signature canonicalization."""
|
||
|
|
+ if type(left) is not type(right):
|
||
|
|
+ return False
|
||
|
|
+ if isinstance(left, dict):
|
||
|
|
+ return left.keys() == right.keys() and all(same_json_types(value,right[key]) for key,value in left.items())
|
||
|
|
+ if isinstance(left, list):
|
||
|
|
+ return len(left) == len(right) and all(same_json_types(a,b) for a,b in zip(left,right))
|
||
|
|
+ return left == right
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+def stamp(value: str) -> datetime:
|
||
|
|
+ parsed = datetime.fromisoformat(value.replace('Z','+00:00'))
|
||
|
|
+ if parsed.tzinfo is None:
|
||
|
|
+ raise ValueError('timezone required')
|
||
|
|
+ return parsed.astimezone(timezone.utc)
|
||
|
|
+
|
||
|
|
+def verify(config: dict, *, now: datetime | None = None) -> dict:
|
||
|
|
+ if not isinstance(config,dict) or set(config) != FIELDS or config['schema'] != 'access-engine.signer-receipt-check.v1':
|
||
|
|
+ raise ValueError('unsupported admission fields')
|
||
|
|
+ current = now or datetime.now(timezone.utc)
|
||
|
|
+ caller = config['expected_caller']
|
||
|
|
+ if caller != {'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine','audience':'flex-auth'}:
|
||
|
|
+ raise ValueError('retained native caller admission required')
|
||
|
|
+ executable = Path(config['verifier'])
|
||
|
|
+ if not executable.is_absolute() or not re.fullmatch('[a-f0-9]{64}',config['verifier_sha256']) or hashlib.sha256(executable.read_bytes()).hexdigest() != config['verifier_sha256']:
|
||
|
|
+ raise ValueError('installed verifier digest mismatch')
|
||
|
|
+ trust_bytes = read(config['trust_file'])
|
||
|
|
+ trust = load_trust(Path(config['trust_file']))
|
||
|
|
+ if not same_json_types(trust,strict_json(trust_bytes)):
|
||
|
|
+ raise ValueError('trust input changed during verification')
|
||
|
|
+ if (trust['evaluator'],trust['mode'],trust['policy_package'],trust['policy_version']) != ('flex-auth/local','standalone','secrets-engine.catalog-lane.lifecycle','v2'):
|
||
|
|
+ raise ValueError('signer-only lifecycle package required')
|
||
|
|
+ checks = {}
|
||
|
|
+ envelopes = {}
|
||
|
|
+ inputs = {}
|
||
|
|
+ requests = {}
|
||
|
|
+ for label in ('allow','deny'):
|
||
|
|
+ blobs = {suffix:read(config[label+'_'+suffix]) for suffix in ('request','envelope','record')}
|
||
|
|
+ body = blobs['envelope']
|
||
|
|
+ # Strict decode before helper; authenticated typed return alone is acted on.
|
||
|
|
+ strict_json(body.decode('utf-8'))
|
||
|
|
+ envelope = authenticate_response(body,trust=trust,verifier=executable)
|
||
|
|
+ request = strict_json(blobs['request'])
|
||
|
|
+ record = strict_json(blobs['record'])
|
||
|
|
+ if not same_json_types(record,envelope):
|
||
|
|
+ raise ValueError('captured original source record differs from authenticated response')
|
||
|
|
+ provenance = envelope['provenance']['caller']
|
||
|
|
+ if any(provenance.get(key) != value for key,value in caller.items()) or stamp(provenance['not_after']) <= current:
|
||
|
|
+ raise ValueError('native caller provenance missing or expired')
|
||
|
|
+ if label == 'allow':
|
||
|
|
+ validate_decision_envelope(envelope,request,accepted_policy_packages={trust['policy_package']},accepted_policy_versions={trust['policy_version']},now=current)
|
||
|
|
+ else:
|
||
|
|
+ if envelope['effect'] != 'deny' or envelope['binding']['submitted_request_digest'] != request_digest(request):
|
||
|
|
+ raise ValueError('deny receipt does not bind exact negative request')
|
||
|
|
+ envelopes[label] = envelope
|
||
|
|
+ requests[label] = request
|
||
|
|
+ for suffix in ('request','envelope','record'):
|
||
|
|
+ inputs[label+'_'+suffix] = hashlib.sha256(blobs[suffix]).hexdigest()
|
||
|
|
+ checks[label+'_authenticated_bound_and_record_matched'] = True
|
||
|
|
+ # These are local refusals against the captured real producer envelope. They
|
||
|
|
+ # do not claim a rejected caller was exercised on the native HTTP endpoint.
|
||
|
|
+ for label in ('altered','unsigned','unknown_kid','wrong_policy_pin'):
|
||
|
|
+ bad = copy.deepcopy(envelopes['allow']); pins = copy.deepcopy(trust)
|
||
|
|
+ if label == 'altered': bad['binding']['action'] = 'forged-action'
|
||
|
|
+ elif label == 'unsigned': bad['signature'] = {'mode':'unsigned'}
|
||
|
|
+ elif label == 'unknown_kid': bad['signature']['kid'] = 'unadmitted-key'
|
||
|
|
+ else: pins['policy_package_digest'] = 'sha256:'+'0'*64
|
||
|
|
+ try:
|
||
|
|
+ authenticate_response(json.dumps(bad).encode(),trust=pins,verifier=executable)
|
||
|
|
+ except DecisionError:
|
||
|
|
+ checks[label+'_refused'] = True
|
||
|
|
+ else:
|
||
|
|
+ raise ValueError('negative authentication case accepted')
|
||
|
|
+ try:
|
||
|
|
+ expiry=stamp(envelopes['allow']['lifetime']['expires_at'])
|
||
|
|
+ request=requests['allow']
|
||
|
|
+ validate_decision_envelope(envelopes['allow'],request,accepted_policy_packages={trust['policy_package']},accepted_policy_versions={trust['policy_version']},now=expiry)
|
||
|
|
+ except DecisionError:
|
||
|
|
+ checks['expiry_boundary_refused'] = True
|
||
|
|
+ else:
|
||
|
|
+ raise ValueError('expired allow accepted')
|
||
|
|
+ return {'schema':'access-engine.signer-receipt-result.v1','checks':checks,'input_sha256':inputs,'verifier_sha256':config['verifier_sha256'],'trust_file_sha256':hashlib.sha256(trust_bytes).hexdigest(),'boundary':'offline captured signed receipt checks only; no native caller-denial, record durability, receiver custody, trust admission, rotation or protected effect inferred'}
|
||
|
|
+
|
||
|
|
+def main() -> int:
|
||
|
|
+ parser=argparse.ArgumentParser(description=__doc__)
|
||
|
|
+ parser.add_argument('--admission',type=Path,required=True)
|
||
|
|
+ parser.add_argument('--output',type=Path,required=True)
|
||
|
|
+ args=parser.parse_args()
|
||
|
|
+ try:
|
||
|
|
+ config=strict_json(read(str(args.admission)))
|
||
|
|
+ result=verify(config)
|
||
|
|
+ args.output.write_text(json.dumps(result,indent=2)+'\n')
|
||
|
|
+ except (OSError,ValueError,TypeError,KeyError,RecursionError,DecisionError):
|
||
|
|
+ # Never dump source objects, unexpected key material or verifier stderr.
|
||
|
|
+ print('signer receipt verification refused')
|
||
|
|
+ return 1
|
||
|
|
+ print('captured signer receipt checks passed')
|
||
|
|
+ return 0
|
||
|
|
+
|
||
|
|
+if __name__ == '__main__':
|
||
|
|
+ raise SystemExit(main())
|
||
|
|
diff --git a/tests/test_native_signer_receipt.py b/tests/test_native_signer_receipt.py
|
||
|
|
new file mode 100644
|
||
|
|
--- /dev/null
|
||
|
|
+++ b/tests/test_native_signer_receipt.py
|
||
|
|
@@ -0,0 +1,100 @@
|
||
|
|
+"""Offline tool regression using the producer's existing synthetic signer only."""
|
||
|
|
+import copy
|
||
|
|
+from datetime import datetime,timedelta,timezone
|
||
|
|
+import hashlib
|
||
|
|
+import importlib.util
|
||
|
|
+import json
|
||
|
|
+import os
|
||
|
|
+from pathlib import Path
|
||
|
|
+import subprocess
|
||
|
|
+import tempfile
|
||
|
|
+import unittest
|
||
|
|
+from unittest import mock
|
||
|
|
+
|
||
|
|
+ROOT=Path(__file__).resolve().parents[1]
|
||
|
|
+@unittest.skipUnless(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT'), 'requires reviewed Secrets consumer source/bundle for integration checks')
|
||
|
|
+class NativeSignerReceiptTests(unittest.TestCase):
|
||
|
|
+ @classmethod
|
||
|
|
+ def setUpClass(cls):
|
||
|
|
+ global MODULE
|
||
|
|
+ spec=importlib.util.spec_from_file_location('signer_receipt',ROOT/'tools/verify_native_signer_receipt.py')
|
||
|
|
+ MODULE=importlib.util.module_from_spec(spec);spec.loader.exec_module(MODULE)
|
||
|
|
+ consumer=Path(os.environ['SECRETS_ENGINE_SOURCE_ROOT'])
|
||
|
|
+ cls.scratch=tempfile.TemporaryDirectory();cls.base=Path(cls.scratch.name)
|
||
|
|
+ env={**os.environ,'GOFLAGS':'-buildvcs=false'}
|
||
|
|
+ cls.verifier=cls.base/'verify';cls.signer=cls.base/'synthetic-sign'
|
||
|
|
+ for binary,package in [(cls.verifier,'.'),(cls.signer,'./testsupport/signer')]:
|
||
|
|
+ subprocess.run(['go','build','-p','2','-o',str(binary),package],cwd=consumer/'verifier',env=env,check=True)
|
||
|
|
+ cls.now=datetime.now(timezone.utc)
|
||
|
|
+ env=json.loads((consumer/'tests/fixtures/pdp-signature/decision_rotate_signed.json').read_text())
|
||
|
|
+ env['provenance']['caller']={'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine','audience':'flex-auth','not_after':(cls.now+timedelta(minutes=20)).isoformat()}
|
||
|
|
+ env['lifetime']['not_before']=(cls.now-timedelta(seconds=1)).isoformat();env['lifetime']['expires_at']=(cls.now+timedelta(minutes=15)).isoformat()
|
||
|
|
+ def sign(envelope):
|
||
|
|
+ return json.loads(subprocess.check_output([str(cls.signer)],input=json.dumps(envelope).encode()))
|
||
|
|
+ env['diagnostics']['target']={'phase':{'uses':1}}
|
||
|
|
+ cls.allow=sign(env)
|
||
|
|
+ req=json.loads((ROOT/'examples/secrets-engine/check_request_allow_rotate.json').read_text())
|
||
|
|
+ (cls.base/'allow-request.json').write_text(json.dumps(req))
|
||
|
|
+ denied=copy.deepcopy(req);denied['action']='execute-ccr'
|
||
|
|
+ cls.deny=copy.deepcopy(env);cls.deny['effect']='deny';cls.deny['binding']['action']='execute-ccr';cls.deny['binding']['submitted_request_digest']=MODULE.request_digest(denied);cls.deny=sign(cls.deny)
|
||
|
|
+ (cls.base/'deny-request.json').write_text(json.dumps(denied))
|
||
|
|
+ provenance=cls.allow['provenance'];cls.trust={key:provenance[key] for key in ('evaluator','mode','policy_package','policy_version','policy_package_digest')}
|
||
|
|
+ cls.trust.update(schema='secrets-engine.pdp-trust.v1',keys={'testdata-ed25519':'IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'})
|
||
|
|
+ @classmethod
|
||
|
|
+ def tearDownClass(cls):cls.scratch.cleanup()
|
||
|
|
+ def setUp(self):
|
||
|
|
+ for label,envelope in [('allow',self.allow),('deny',self.deny)]:
|
||
|
|
+ for kind in ('envelope','record'):(self.base/f'{label}-{kind}.json').write_text(json.dumps(envelope))
|
||
|
|
+ (self.base/'trust.json').write_text(json.dumps(self.trust))
|
||
|
|
+ self.config={'schema':'access-engine.signer-receipt-check.v1','trust_file':str(self.base/'trust.json'),'verifier':str(self.verifier),'verifier_sha256':hashlib.sha256(self.verifier.read_bytes()).hexdigest(),'expected_caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine','audience':'flex-auth'}}
|
||
|
|
+ for label in ('allow','deny'):
|
||
|
|
+ for kind in ('request','envelope','record'):self.config[f'{label}_{kind}']=str(self.base/f'{label}-{kind}.json')
|
||
|
|
+ def test_genuine_signed_receipt_and_refusal_matrix(self):
|
||
|
|
+ result=MODULE.verify(self.config,now=self.now)
|
||
|
|
+ self.assertTrue(all(result['checks'].values()));self.assertEqual(len(result['checks']),7)
|
||
|
|
+ def test_changed_source_record_refuses(self):
|
||
|
|
+ record=copy.deepcopy(self.allow);record['effect']='deny';(self.base/'allow-record.json').write_text(json.dumps(record))
|
||
|
|
+ with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
|
||
|
|
+ def test_nested_record_number_type_change_refuses(self):
|
||
|
|
+ for replacement in (True,1.0):
|
||
|
|
+ with self.subTest(replacement_type=type(replacement).__name__):
|
||
|
|
+ record=copy.deepcopy(self.allow)
|
||
|
|
+ record['diagnostics']['target']['phase']['uses']=replacement
|
||
|
|
+ (self.base/'allow-record.json').write_text(json.dumps(record))
|
||
|
|
+ with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
|
||
|
|
+
|
||
|
|
+ def test_receipt_hashes_use_the_once_read_verified_bytes(self):
|
||
|
|
+ actual_read=MODULE.read
|
||
|
|
+ seen={}
|
||
|
|
+ original={str(self.base/f'{label}-{suffix}.json'):actual_read(str(self.base/f'{label}-{suffix}.json')) for label in ('allow','deny') for suffix in ('request','envelope','record')}
|
||
|
|
+ def once(path):
|
||
|
|
+ if path in original:
|
||
|
|
+ seen[path]=seen.get(path,0)+1
|
||
|
|
+ self.assertEqual(seen[path],1,'receipt was reopened after verification')
|
||
|
|
+ return actual_read(path)
|
||
|
|
+ with mock.patch.object(MODULE,'read',side_effect=once):
|
||
|
|
+ result=MODULE.verify(self.config,now=self.now)
|
||
|
|
+ self.assertEqual(len(seen),6)
|
||
|
|
+ for label in ('allow','deny'):
|
||
|
|
+ for suffix in ('request','envelope','record'):
|
||
|
|
+ self.assertEqual(result['input_sha256'][label+'_'+suffix],hashlib.sha256(original[str(self.base/f'{label}-{suffix}.json')]).hexdigest())
|
||
|
|
+
|
||
|
|
+ def test_oversized_file_read_is_bounded(self):
|
||
|
|
+ stream=mock.MagicMock();stream.__enter__.return_value=stream;stream.read.return_value=b'x'*524289
|
||
|
|
+ with mock.patch.object(Path,'open',return_value=stream):
|
||
|
|
+ with self.assertRaises(ValueError):MODULE.read('/absolute/oversized.json')
|
||
|
|
+ stream.read.assert_called_once_with(524289)
|
||
|
|
+
|
||
|
|
+ def test_unadmitted_helper_digest_refuses(self):
|
||
|
|
+ self.config['verifier_sha256']='0'*64
|
||
|
|
+ with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
|
||
|
|
+ def test_disabled_caller_admission_refuses(self):
|
||
|
|
+ self.config['expected_caller']['mode']='disabled'
|
||
|
|
+ with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
|
||
|
|
+ def test_expired_captured_allow_refuses(self):
|
||
|
|
+ with self.assertRaises(MODULE.DecisionError):MODULE.verify(self.config,now=self.now+timedelta(minutes=16))
|
||
|
|
+ def test_wrong_independent_policy_pin_refuses(self):
|
||
|
|
+ wrong=copy.deepcopy(self.trust);wrong['policy_package_digest']='sha256:'+'0'*64;(self.base/'trust.json').write_text(json.dumps(wrong))
|
||
|
|
+ with self.assertRaises(MODULE.DecisionError):MODULE.verify(self.config,now=self.now)
|
||
|
|
+
|
||
|
|
+if __name__=='__main__':unittest.main()
|
||
|
|
diff --git a/docs/native-signer-workload.json b/docs/native-signer-workload.json
|
||
|
|
new file mode 100644
|
||
|
|
--- /dev/null
|
||
|
|
+++ b/docs/native-signer-workload.json
|
||
|
|
@@ -0,0 +1,68 @@
|
||
|
|
+{
|
||
|
|
+ "schema": "access-engine.signer-workload-reference.v1",
|
||
|
|
+ "owner_repo": "access-engine",
|
||
|
|
+ "existing_workload": "flex-auth",
|
||
|
|
+ "authoritative_owner_declaration": "tenancy.yaml#/workload_identity",
|
||
|
|
+ "exact_signer_identity_binding": "system:serviceaccount:flex-auth:flex-auth-secrets-engine",
|
||
|
|
+ "declaration_scope": "Existing retained Secrets lifecycle PDP pin; declaration of identity source only. Not a new managed workload, credential grant, zone promotion or live admission.",
|
||
|
|
+ "observed_runtime": {
|
||
|
|
+ "cluster": "railiance01",
|
||
|
|
+ "node_internal_ip": "92.205.62.239",
|
||
|
|
+ "namespace": "flex-auth",
|
||
|
|
+ "release": "flex-auth-secrets-engine",
|
||
|
|
+ "deployment": "flex-auth-secrets-engine",
|
||
|
|
+ "reviewer_service_account": "flex-auth-secrets-engine",
|
||
|
|
+ "source_evidence": "the-custodian/docs/changes/netkingdom-target-2026-10-16/access-native-preparation/live-metadata-r1.json",
|
||
|
|
+ "evidence_sha256": "bafce991e02083ac30c8d6ce15f037dc22087bbb5dc8185c472149b46bca47c2"
|
||
|
|
+ },
|
||
|
|
+ "identity_roles": {
|
||
|
|
+ "signer_workload": "system:serviceaccount:flex-auth:flex-auth-secrets-engine",
|
||
|
|
+ "requesting_consumer": "system:serviceaccount:secrets-engine:secrets-engine",
|
||
|
|
+ "retained_caller_audience": "flex-auth",
|
||
|
|
+ "rule": "Signer custody binds the PDP workload, not the consumer or a human approver. Existing TokenReview reviewer projection does not grant signing-key custody."
|
||
|
|
+ },
|
||
|
|
+ "custody_reference": {
|
||
|
|
+ "catalog_id": "flex-auth-decision-envelope-signing-key",
|
||
|
|
+ "owner_repo": "railiance-platform",
|
||
|
|
+ "current_state": "draft/nonresolvable",
|
||
|
|
+ "proposed_source_path": "platform/workloads/flex-auth/envelope-signing",
|
||
|
|
+ "proposed_private_field": "ED25519_PRIVATE_KEY",
|
||
|
|
+ "required_encoding": "unpadded base64url 32-byte Ed25519 seed; mount0440 allowed, group-write and world permissions refused",
|
||
|
|
+ "public_fields": [
|
||
|
|
+ "ED25519_PUBLIC_KEY",
|
||
|
|
+ "kid"
|
||
|
|
+ ],
|
||
|
|
+ "proposed_catalog_workload_reference": {
|
||
|
|
+ "name": "flex-auth",
|
||
|
|
+ "declaration_ref": "access-engine/tenancy.yaml#/workload_identity",
|
||
|
|
+ "exact_binding": "system:serviceaccount:flex-auth:flex-auth-secrets-engine",
|
||
|
|
+ "resolution": "unknown until owner custody and managed-package admission; do not mark resolvable merely from source binding"
|
||
|
|
+ },
|
||
|
|
+ "key_created_or_admitted": false
|
||
|
|
+ },
|
||
|
|
+ "managed_package_limit": {
|
||
|
|
+ "rapp_declaration_ref": null,
|
||
|
|
+ "status": "Existing managed-deployable migration debt remains unknown under security-zones_v0.1 \u00a76; no matching authoritative rapp declaration was established. Existing source tenancy declaration supplies owner identity binding but cannot substitute for managed-package admission.",
|
||
|
|
+ "no_fiction": "Do not invent a rapp tuple or describe this Helm-managed Deployment as an independent operational unit to bypass the rule."
|
||
|
|
+ },
|
||
|
|
+ "unchanged_limits": [
|
||
|
|
+ "tenancy and zones grades are unchanged",
|
||
|
|
+ "service-pilot literal false gate unchanged",
|
||
|
|
+ "human variant unchanged",
|
||
|
|
+ "legacy twelve lifecycle actions remain the selected policy package",
|
||
|
|
+ "no signer Secret name, kid, public fingerprint, custody receipt or rollout permission is supplied"
|
||
|
|
+ ],
|
||
|
|
+ "consumer_admission": "Secrets owner independently admits public keys/pins and exact installed verifier; this file never bootstraps trust from PDP discovery.",
|
||
|
|
+ "native_tool": {
|
||
|
|
+ "path": "tools/verify_native_signer_receipt.py",
|
||
|
|
+ "requires": "Installed reviewed Secrets Engine package/helper and independent public admission; source request, authenticated response and original source record for one allow and one deny.",
|
||
|
|
+ "operation": "Offline captured receipt checks only, no network/token input/key build or protected effect. Current clock validation; no CLI backdating option.",
|
||
|
|
+ "result_limits": "Does not prove record durability, real rejected HTTP caller, audit receiver custody, rotation, or authorization to run effect."
|
||
|
|
+ },
|
||
|
|
+ "ci": {
|
||
|
|
+ "trigger": "Existing ordinary push to main when .forgejo/workflows/image.yaml, Containerfile, cmd/**, internal/**, pkg/**, examples/**, go.mod/go.sum or tools/publish-runtime-images.sh changes; workflow_dispatch also exists. This patch changes the workflow, so an authorized push would trigger it.",
|
||
|
|
+ "scope": "Existing container-build runner and already admitted forgejo.coulomb.social/coulomb/flex-auth package only in legacy mode; paired access-engine package publication requires separate existing dispatch authority and is excluded.",
|
||
|
|
+ "provenance_preparation": "Download archive/${REF}.tar.gz using full validated GITHUB_SHA; existing publisher labels org.opencontainers.image.revision and reads back registry manifest digest.",
|
||
|
|
+ "not_invoked": "No push, workflow dispatch, Docker build, image publication or paid resource allocation in this source preparation."
|
||
|
|
+ }
|
||
|
|
+}
|
||
|
|
diff --git a/examples/secrets-engine/signer-receipt-admission.template.json b/examples/secrets-engine/signer-receipt-admission.template.json
|
||
|
|
new file mode 100644
|
||
|
|
--- /dev/null
|
||
|
|
+++ b/examples/secrets-engine/signer-receipt-admission.template.json
|
||
|
|
@@ -0,0 +1,17 @@
|
||
|
|
+{
|
||
|
|
+ "schema": "access-engine.signer-receipt-check.v1",
|
||
|
|
+ "trust_file": "/absolute/owner-admitted/public-trust.json",
|
||
|
|
+ "verifier": "/absolute/installed/secrets-engine-pdp-verify",
|
||
|
|
+ "verifier_sha256": "REPLACE_WITH_INDEPENDENTLY_REVIEWED_INSTALLED_HELPER_SHA256",
|
||
|
|
+ "allow_request": "/absolute/sanitized-native-receipts/allow-request.json",
|
||
|
|
+ "allow_envelope": "/absolute/sanitized-native-receipts/allow-envelope.json",
|
||
|
|
+ "allow_record": "/absolute/sanitized-native-receipts/allow-source-record.json",
|
||
|
|
+ "deny_request": "/absolute/sanitized-native-receipts/deny-request.json",
|
||
|
|
+ "deny_envelope": "/absolute/sanitized-native-receipts/deny-envelope.json",
|
||
|
|
+ "deny_record": "/absolute/sanitized-native-receipts/deny-source-record.json",
|
||
|
|
+ "expected_caller": {
|
||
|
|
+ "mode": "enforce",
|
||
|
|
+ "principal": "system:serviceaccount:secrets-engine:secrets-engine",
|
||
|
|
+ "audience": "flex-auth"
|
||
|
|
+ }
|
||
|
|
+}
|
||
|
|
diff --git a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md
|
||
|
|
--- a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md
|
||
|
|
+++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md
|
||
|
|
@@ -184,7 +184,7 @@
|
||
|
|
|
||
|
|
```task
|
||
|
|
id: FLEX-WP-0024-T06
|
||
|
|
-status: wait
|
||
|
|
+status: progress
|
||
|
|
priority: high
|
||
|
|
```
|
||
|
|
|
||
|
|
@@ -197,3 +197,32 @@
|
||
|
|
source review. This task owns the producer-side native gate; SECRETS-WP-0014 owns
|
||
|
|
consumer admission/enforcement. The old fixture completion never established this
|
||
|
|
gate, so the workplan is reopened rather than claiming it already passed.
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+2026-10-16 owner native preparation: T06 is `progress` because Access still owns
|
||
|
|
+signer workload declaration, artifact/provenance and source/provider-join preparation;
|
||
|
|
+no unqualified cross-owner wait is claimed. Read-only actual release metadata and
|
||
|
|
+bounded proposal are in the Custodian
|
||
|
|
+`docs/changes/netkingdom-target-2026-10-16/access-native-preparation/`.
|
||
|
|
+The real Platform custody route `flex-auth-decision-envelope-signing-key` exists but
|
||
|
|
+is draft/nonresolvable; no production kid/public fingerprint, Secret projection or
|
||
|
|
+custody receipt is admitted. Coordinate that named commitment through CUST-WP-0080
|
||
|
|
+instead of inventing a Platform task/approval. SECRETS-WP-0014-T03 owns installed
|
||
|
|
+consumer native verification and must not form a circular blocking dependency.
|
||
|
|
+
|
||
|
|
+The first proposed native trial only signs the unchanged lifecycle package on the
|
||
|
|
+existing retained release; `execute-ccr` remains denied. Current service-pilot
|
||
|
|
+`runtime_admission := false` remains mandatory: the source ServicePilotInputs is
|
||
|
|
+not yet wired into HTTP, and public service_actor_binding never authenticates the
|
||
|
|
+caller. Later activation needs a complete reviewed provider join, exact policy and
|
||
|
|
+operation admission, artifact digests, owner execution/rollback proposal and native
|
||
|
|
+receipts. Preparation/local synthetic success grants none of those permissions.
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+2026-10-16 source-only native preparation adds the existing Secrets PDP ServiceAccount
|
||
|
|
+to `tenancy.yaml` with evidence in `docs/native-signer-workload.json`, makes the
|
||
|
|
+retained `flex-auth` audience explicit, pins CI archive fetch to the full source
|
||
|
|
+revision, and adds offline native signer receipt verification using the existing
|
||
|
|
+Secrets verifier. No key, trust, image publication or live activation is admitted.
|
||
|
|
+The managed workload's missing rapp declaration remains explicit migration debt;
|
||
|
|
+this source binding does not fabricate a package declaration or raise its grade.
|