docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
4735d6755f
commit
19011a9516
50 changed files with 6053 additions and 0 deletions
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pass one private bootstrap credential to the admitted pod over stdin."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def private(info, directory=False):
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused()
|
||||
if not directory and info.st_nlink != 1:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def read_password(root):
|
||||
root = Path(os.path.abspath(root))
|
||||
for ancestor in (root, *root.parents):
|
||||
if ancestor.is_symlink(): raise Refused()
|
||||
descriptors = []
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
root_fd = os.open(root, flags); descriptors.append(root_fd)
|
||||
private(os.fstat(root_fd), True)
|
||||
parent_fd = os.open('privacyidea', flags, dir_fd=root_fd); descriptors.append(parent_fd)
|
||||
private(os.fstat(parent_fd), True)
|
||||
fd = os.open('secrets.env', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
|
||||
descriptors.append(fd); private(os.fstat(fd))
|
||||
raw = os.read(fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
values = [line.split('=', 1)[1] for line in raw.decode().splitlines()
|
||||
if line.startswith('PI_ADMIN_PASSWORD=')]
|
||||
# This entry point consumes gen-secrets.sh's format, never shell code.
|
||||
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]): raise Refused()
|
||||
return values[0]
|
||||
finally:
|
||||
for fd in reversed(descriptors): os.close(fd)
|
||||
|
||||
|
||||
def run(args, payload=None):
|
||||
completed = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=60, check=False)
|
||||
if completed.returncode or len(completed.stdout) > 4096: raise Refused()
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2: raise Refused()
|
||||
here = Path(__file__).resolve().parent
|
||||
password = read_password(sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets')
|
||||
pod = run(['kubectl', 'get', 'pod', '-n', 'mfa', '-l', 'app.kubernetes.io/name=privacyidea',
|
||||
'--field-selector=status.phase=Running', '-o', 'jsonpath={.items[*].metadata.name}']).decode().strip()
|
||||
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', pod): raise Refused()
|
||||
script = (here / 'bootstrap-admin-remote.py').read_text()
|
||||
response = run(['kubectl', 'exec', '-i', '-n', 'mfa', pod, '--', 'python3', '-c', script],
|
||||
json.dumps({'password': password}).encode())
|
||||
receipt = json.loads(response)
|
||||
if (not isinstance(receipt, dict) or set(receipt) != {'result', 'mfa_verified'}
|
||||
or receipt['result'] not in ('created', 'verified')
|
||||
or receipt['mfa_verified'] is not False): raise Refused()
|
||||
print('pi-admin bootstrap credential and scoped bootstrap policy verified.')
|
||||
print('MFA enrollment, effective-policy review and a fresh sign-in remain required; this is not MFA acceptance.')
|
||||
print('Use the approved custody entry for pi-admin; no credential is printed or exported.')
|
||||
print('Legacy trigger accounts and Secrets require a separate inventory/revocation review.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
print('ERROR: admin bootstrap refused or failed; use approved metadata-only diagnostics. No credential was printed.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
# First-bootstrap/verification entry point for the current KeyCape profile.
|
||||
# No legacy Keycloak trigger credential is generated or published.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/bootstrap-admin.py" "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit private input; preview by default. Add --apply for the reviewed change.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "$0")/realm_bootstrap.py" "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Scoped read-only diagnostic; see README.md. No automatic credential retrieval.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "$0")/mfa_state.py" "$@"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Private creation or exact-match verification; rotations require --rotate-key.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" privacyidea "$@"
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"""Deploy the private P04 service using existing provider custody and CAS guards."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
PROVIDER='58c7f96d-61cb-4dd4-bca2-54661c0ac375'
|
||||
PORTAL='e1c4274a-4b6d-4ea2-b619-e09630b745b0'
|
||||
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
|
||||
|
||||
def kube(*args, body=None):
|
||||
r=subprocess.run(['kubectl',*args],input=json.dumps(body).encode() if body is not None else None,
|
||||
capture_output=True,timeout=45)
|
||||
if r.returncode:raise RuntimeError('Kubernetes operation failed; inspect resource state')
|
||||
return json.loads(r.stdout) if r.stdout.strip().startswith(b'{') else None
|
||||
|
||||
|
||||
def peer(namespace,name):
|
||||
return {'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':namespace}},
|
||||
'podSelector':{'matchLabels':{'app.kubernetes.io/name':name}}}
|
||||
|
||||
|
||||
def resources(source):
|
||||
data={name:(source/name).read_text() for name in ('factor_recovery.py','recovery_service.py')}
|
||||
digest=hashlib.sha256(json.dumps(data,sort_keys=True).encode()).hexdigest()[:16]
|
||||
name='factor-recovery-'+digest
|
||||
docs=[{'apiVersion':'v1','kind':'ConfigMap','metadata':{'name':name,'namespace':'mfa'},'immutable':True,'data':data},
|
||||
{'apiVersion':'v1','kind':'Service','metadata':{'name':'factor-recovery','namespace':'mfa'},
|
||||
'spec':{'selector':{'app.kubernetes.io/name':'privacyidea'},'ports':[{'name':'recovery','port':8091,'targetPort':8091}]}}]
|
||||
for ns,selector,direction,remote,port in [
|
||||
('mfa','privacyidea','ingress',peer('user-engine','user-engine'),8091),
|
||||
('user-engine','user-engine','egress',peer('mfa','privacyidea'),8091),
|
||||
('mfa','privacyidea','egress',peer('sso','keycape'),8080),
|
||||
('sso','keycape','ingress',peer('mfa','privacyidea'),8080)]:
|
||||
docs.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy',
|
||||
'metadata':{'name':'factor-recovery-'+direction+'-'+str(port),'namespace':ns},
|
||||
'spec':{'podSelector':{'matchLabels':{'app.kubernetes.io/name':selector}},
|
||||
'policyTypes':[direction.title()],direction:[{'from' if direction=='ingress' else 'to':[remote],
|
||||
'ports':[{'protocol':'TCP','port':port}]}]}})
|
||||
return name,docs
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('--source',type=Path,required=True)
|
||||
p.add_argument('--apply',action='store_true')
|
||||
p.add_argument('--portal-image')
|
||||
a=p.parse_args()
|
||||
if kube('get','ns','kube-system','-o','json')['metadata']['uid']!=CLUSTER:raise RuntimeError('wrong cluster')
|
||||
provider=kube('-n','mfa','get','deploy','privacyidea','-o','json')
|
||||
if provider['metadata']['uid']!=PROVIDER:raise RuntimeError('provider identity changed')
|
||||
name,docs=resources(a.source)
|
||||
spec=provider['spec']['template']['spec']
|
||||
main=next(c for c in spec['containers'] if c['name']=='privacyidea')
|
||||
sidecar={'name':'factor-recovery','image':IMAGE,'command':['python3','/recovery/recovery_service.py'],
|
||||
'env':[{'name':'PRIVACYIDEA_CONFIGFILE','value':'/etc/privacyidea/pi.cfg'},
|
||||
{'name':'PYTHONDONTWRITEBYTECODE','value':'1'},
|
||||
{'name':'RECOVERY_ISSUER','value':'https://kc.coulomb.social'},
|
||||
{'name':'RECOVERY_AUDIENCE','value':'user-engine-portal'},
|
||||
{'name':'RECOVERY_JWKS_URL','value':'http://keycape.sso.svc.cluster.local:8080/jwks'}],
|
||||
'envFrom':main['envFrom'],
|
||||
'volumeMounts':[dict(m,readOnly=m['name']!='logs') for m in main['volumeMounts']]+[{'name':'factor-recovery-code','mountPath':'/recovery','readOnly':True}],
|
||||
'securityContext':{'runAsUser':65534,'runAsGroup':999,'runAsNonRoot':True,'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}},
|
||||
'ports':[{'name':'recovery','containerPort':8091}],
|
||||
'resources':{'requests':{'cpu':'10m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}},
|
||||
'readinessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':10,'periodSeconds':10},
|
||||
'livenessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':60,'periodSeconds':20}}
|
||||
sidecar['env'] += [entry for entry in main.get('env',[]) if entry['name']=='PYTHONPATH']
|
||||
containers=[c for c in spec['containers'] if c['name']!='factor-recovery']+[sidecar]
|
||||
volumes=[v for v in spec['volumes'] if v['name']!='factor-recovery-code']+[{'name':'factor-recovery-code','configMap':{'name':name}}]
|
||||
patch=[{'op':'test','path':'/metadata/resourceVersion','value':provider['metadata']['resourceVersion']},
|
||||
{'op':'replace','path':'/spec/template/spec/containers','value':containers},
|
||||
{'op':'replace','path':'/spec/template/spec/volumes','value':volumes}]
|
||||
print(json.dumps({'config':name,'resources':[(d['kind'],d['metadata']['name']) for d in docs],'provider_sidecar':True,'portal_update':bool(a.portal_image),'apply':a.apply}))
|
||||
if not a.apply:return
|
||||
for doc in docs:kube('apply','-f','-',body=doc)
|
||||
kube('-n','mfa','patch','deploy','privacyidea','--type=json','-p',json.dumps(patch))
|
||||
if a.portal_image:
|
||||
if not a.portal_image.startswith('forgejo.coulomb.social/coulomb/user-engine@sha256:') or len(a.portal_image.rsplit(':',1)[1])!=64:raise ValueError('digest-pinned portal required')
|
||||
portal=kube('-n','user-engine','get','deploy','user-engine','-o','json')
|
||||
if portal['metadata']['uid']!=PORTAL:raise RuntimeError('portal identity changed')
|
||||
items=portal['spec']['template']['spec']['containers']
|
||||
c=next(c for c in items if c['name']=='portal');c['image']=a.portal_image
|
||||
names={'USER_ENGINE_FACTOR_RECOVERY_URL','USER_ENGINE_MFA_MANAGEMENT_URL'}
|
||||
c['env']=[e for e in c['env'] if e['name'] not in names]+[
|
||||
{'name':'USER_ENGINE_FACTOR_RECOVERY_URL','value':'http://factor-recovery.mfa.svc.cluster.local:8091'},
|
||||
{'name':'USER_ENGINE_MFA_MANAGEMENT_URL','value':'https://pink-account.coulomb.social'}]
|
||||
kube('-n','user-engine','patch','deploy','user-engine','--type=json','-p',json.dumps([
|
||||
{'op':'test','path':'/metadata/resourceVersion','value':portal['metadata']['resourceVersion']},
|
||||
{'op':'replace','path':'/spec/template/spec/containers','value':items}]))
|
||||
print('Recovery deployment applied; verify rollout and authenticated acceptance.')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deploy the reviewed provider enrollment hook while preserving runtime secrets."""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
PROVIDER='58c7f96d-61cb-4dd4-bca2-54661c0ac375'
|
||||
CONFIG='423d857b-6b10-40ad-aba8-2cda2d3645ef'
|
||||
SUFFIX='\n# P06: fail startup if the enrollment guard is unavailable.\nfrom keycape_onboarding_guard import check as _p06_onboarding_check\nPI_INIT_CHECK_HOOK = "keycape_onboarding_guard.check"\n'
|
||||
|
||||
def kube(args,payload=None):
|
||||
result=subprocess.run(['kubectl','--request-timeout=20s',*args],input=json.dumps(payload) if payload is not None else None,capture_output=True,text=True,timeout=30)
|
||||
if result.returncode:raise RuntimeError('kubernetes_operation_failed')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def plan(source,config,deployment):
|
||||
code=(source/'scripts/keycape_onboarding_guard.py').read_text()
|
||||
digest=hashlib.sha256(code.encode()).hexdigest();name='keycape-onboarding-'+digest[:16]
|
||||
cm={'apiVersion':'v1','kind':'ConfigMap','metadata':{'name':name,'namespace':'mfa'},'immutable':True,'data':{'keycape_onboarding_guard.py':code}}
|
||||
original=config['data']['pi.cfg']
|
||||
if 'PI_INIT_CHECK_HOOK' in original and not original.endswith(SUFFIX):raise RuntimeError('existing_hook_requires_review')
|
||||
updated=original if original.endswith(SUFFIX) else original+SUFFIX
|
||||
spec=copy.deepcopy(deployment['spec']['template']['spec'])
|
||||
for container in spec['containers']:
|
||||
if container['name'] not in {'privacyidea','factor-recovery'}:continue
|
||||
env=container.setdefault('env',[])
|
||||
path=next((e for e in env if e['name']=='PYTHONPATH'),None)
|
||||
if path and path.get('value') not in {'/opt/keycape-onboarding'}:raise RuntimeError('existing_python_path_requires_review')
|
||||
if not path:env.append({'name':'PYTHONPATH','value':'/opt/keycape-onboarding'})
|
||||
mounts=container.setdefault('volumeMounts',[])
|
||||
existing=next((m for m in mounts if m['name']=='onboarding-guard'),None)
|
||||
desired={'name':'onboarding-guard','mountPath':'/opt/keycape-onboarding','readOnly':True}
|
||||
if existing and existing!=desired:raise RuntimeError('existing_guard_mount_differs')
|
||||
if not existing:mounts.append(desired)
|
||||
spec['volumes']=[v for v in spec['volumes'] if v['name']!='onboarding-guard']+[{'name':'onboarding-guard','configMap':{'name':name}}]
|
||||
return cm,updated,spec
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--source',type=Path,required=True);parser.add_argument('--apply',action='store_true');args=parser.parse_args()
|
||||
if kube(['get','namespace','kube-system','-o','json'])['metadata']['uid']!=CLUSTER:raise RuntimeError('cluster_changed')
|
||||
config=kube(['-n','mfa','get','configmap','privacyidea-cfg','-o','json']);deployment=kube(['-n','mfa','get','deployment','privacyidea','-o','json'])
|
||||
if config['metadata']['uid']!=CONFIG or deployment['metadata']['uid']!=PROVIDER:raise RuntimeError('target_identity_changed')
|
||||
cm,updated,spec=plan(args.source,config,deployment)
|
||||
print(json.dumps({'apply':args.apply,'guard_config':cm['metadata']['name'],'target':'mfa/privacyidea','config_uid':CONFIG,'provider_uid':PROVIDER,'secret_values_emitted':False}),flush=True)
|
||||
if not args.apply:return
|
||||
committed=subprocess.run(['git','show','HEAD:scripts/keycape_onboarding_guard.py'],cwd=args.source,capture_output=True,text=True)
|
||||
if committed.returncode or committed.stdout!=cm['data']['keycape_onboarding_guard.py']:raise RuntimeError('guard_source_must_be_committed')
|
||||
kube(['apply','-f','-','-o','json'],cm)
|
||||
if updated!=config['data']['pi.cfg']:
|
||||
kube(['-n','mfa','patch','configmap','privacyidea-cfg','--type=json','--patch-file=/dev/stdin','-o','json'],[
|
||||
{'op':'test','path':'/metadata/uid','value':CONFIG},{'op':'test','path':'/metadata/resourceVersion','value':config['metadata']['resourceVersion']},
|
||||
{'op':'replace','path':'/data/pi.cfg','value':updated}])
|
||||
kube(['-n','mfa','patch','deployment','privacyidea','--type=json','--patch-file=/dev/stdin','-o','json'],[
|
||||
{'op':'test','path':'/metadata/uid','value':PROVIDER},{'op':'test','path':'/metadata/resourceVersion','value':deployment['metadata']['resourceVersion']},
|
||||
{'op':'replace','path':'/spec/template/spec','value':spec}])
|
||||
after=kube(['-n','mfa','get','configmap','privacyidea-cfg','-o','json'])
|
||||
if after['data']!=dict(config['data'],**{'pi.cfg':updated}):raise RuntimeError('configuration_readback_changed')
|
||||
print(json.dumps({'guard_deployed':True,'wait_for_rollout_and_policy_activation':True}))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""Enable possession proof for newly enrolled TOTP factors; never touch existing factors."""
|
||||
import json,subprocess
|
||||
def metadata(*args):
|
||||
r=subprocess.run(['kubectl',*args,'-o','json'],capture_output=True,check=True,timeout=20)
|
||||
return json.loads(r.stdout)['metadata']
|
||||
assert metadata('get','ns','kube-system')['uid']=='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
assert metadata('-n','mfa','get','deploy','privacyidea')['uid']=='58c7f96d-61cb-4dd4-bca2-54661c0ac375'
|
||||
code='''import contextlib,io,json,logging
|
||||
result={"success":False}
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
try:
|
||||
logging.disable(logging.CRITICAL)
|
||||
from privacyidea.app import create_app
|
||||
from privacyidea.lib.policy import set_policy,PolicyClass
|
||||
app=create_app(config_name="production",silent=True)
|
||||
with app.app_context():
|
||||
name="keycape-confirm-totp-enrollment"
|
||||
set_policy(name=name,scope="enrollment",action="verify_enrollment=totp",realm="coulomb")
|
||||
rows=PolicyClass().list_policies(name=name)
|
||||
result={"success":len(rows)==1 and rows[0]["active"] and rows[0]["action"].get("verify_enrollment")=="totp","policy":name,"existing_factors_changed":False}
|
||||
except Exception:pass
|
||||
print(json.dumps(result))
|
||||
raise SystemExit(0 if result["success"] else 1)
|
||||
'''
|
||||
r=subprocess.run(['kubectl','-n','mfa','exec','deployment/privacyidea','-c','privacyidea','--','python3','-c',code],capture_output=True,timeout=45)
|
||||
try:result=json.loads(r.stdout)
|
||||
except Exception:result={'success':False}
|
||||
assert set(result)<={'success','policy','existing_factors_changed'}
|
||||
print(json.dumps(result))
|
||||
raise SystemExit(0 if result.get('success') else 1)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit private export; first-bootstrap audit creation requires the named flag.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/key-custody.py" export "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Restore private recovery files into the reviewed helper pod's actual data PVC.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/key-custody.py" restore "$@"
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
"""In-pod key transport. Only invoke through key-custody.py (captured output)."""
|
||||
import base64
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
|
||||
FILES = ('enckey', 'private.pem', 'public.pem')
|
||||
LIMIT = 65536
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate(values):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
if set(values) != set(FILES) or len(values['enckey']) != 96:
|
||||
raise Refused()
|
||||
if any(not value or len(value) > LIMIT for value in values.values()):
|
||||
raise Refused()
|
||||
private = serialization.load_pem_private_key(values['private.pem'], password=None)
|
||||
public = serialization.load_pem_public_key(values['public.pem'])
|
||||
if (not isinstance(private, rsa.RSAPrivateKey) or private.key_size < 2048
|
||||
or not isinstance(public, rsa.RSAPublicKey)
|
||||
or private.public_key().public_numbers() != public.public_numbers()):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def read(directory, name):
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
forbidden = 0o022 if name == 'public.pem' else 0o027
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_mode & forbidden:
|
||||
raise Refused()
|
||||
with os.fdopen(os.dup(fd), 'rb') as stream:
|
||||
data = stream.read(LIMIT + 1)
|
||||
after = os.stat(name, dir_fd=directory, follow_symlinks=False)
|
||||
if len(data) > LIMIT or any(getattr(info, k) != getattr(after, k)
|
||||
for k in ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')):
|
||||
raise Refused()
|
||||
return data
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def install(directory, values, *, uid=0, gid=999):
|
||||
"""Verify every existing file before creating anything; never replace."""
|
||||
missing = []
|
||||
for name, value in values.items():
|
||||
try:
|
||||
if read(directory, name) != value:
|
||||
raise Refused()
|
||||
except FileNotFoundError:
|
||||
missing.append(name)
|
||||
for name in missing:
|
||||
temp = '.key-custody-' + secrets.token_hex(12)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=directory)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(values[name]); stream.flush()
|
||||
os.fchown(stream.fileno(), uid, gid)
|
||||
os.fchmod(stream.fileno(), 0o644 if name == 'public.pem' else 0o640)
|
||||
os.fsync(stream.fileno())
|
||||
os.link(temp, name, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
|
||||
os.unlink(temp, dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if any(read(directory, name) != value for name, value in values.items()):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def operate(request, root=Path('/etc/privacyidea'), *, uid=0, gid=999):
|
||||
if any(p.is_symlink() for p in (root, *root.parents)):
|
||||
raise Refused()
|
||||
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
# The admitted service uses fsGroup 999, which may make its PVC group-writable.
|
||||
if os.fstat(directory).st_mode & 0o002:
|
||||
raise Refused()
|
||||
fcntl.flock(directory, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if request == {'action': 'initialize-audit'}:
|
||||
# This explicit first-bootstrap action never creates/replaces enckey.
|
||||
enckey = read(directory, 'enckey')
|
||||
if len(enckey) != 96:
|
||||
raise Refused()
|
||||
for name in ('private.pem', 'public.pem'):
|
||||
try:
|
||||
os.stat(name, dir_fd=directory, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
raise Refused()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
values = {'enckey': enckey,
|
||||
'private.pem': private.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()),
|
||||
'public.pem': private.public_key().public_bytes(serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)}
|
||||
validate(values)
|
||||
install(directory, values, uid=uid, gid=gid)
|
||||
return {'result': 'initialized'}
|
||||
if request == {'action': 'export'}:
|
||||
values = {name: read(directory, name) for name in FILES}
|
||||
validate(values)
|
||||
return {name: base64.b64encode(value).decode() for name, value in values.items()}
|
||||
if set(request) == {'action', 'keys'} and request['action'] == 'restore':
|
||||
values = {name: base64.b64decode(value, validate=True) for name, value in request['keys'].items()}
|
||||
validate(values)
|
||||
install(directory, values, uid=uid, gid=gid)
|
||||
return {'result': 'restored'}
|
||||
raise Refused()
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(4 * LIMIT + 1)
|
||||
if len(raw) > 4 * LIMIT:
|
||||
raise Refused()
|
||||
print(json.dumps(operate(json.loads(raw))))
|
||||
except Exception:
|
||||
# Transport may carry key bytes; diagnostics must never serialize them.
|
||||
print('Key custody operation refused or failed.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Captured key export/restore for the admitted privacyIDEA PVC profile."""
|
||||
import argparse
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
common = load('service_custody', HERE.parents[1] / 'bootstrap/apply-service-secrets.py')
|
||||
remote = load('key_custody_remote', HERE / 'key-custody-remote.py')
|
||||
Refused = common.Refused
|
||||
LOCAL = {'enckey': 'pi.enc', 'private.pem': 'private.pem', 'public.pem': 'public.pem'}
|
||||
TARGETS = {'privacyidea-enckey': ('enckey',), 'privacyidea-auditkeys': ('private.pem', 'public.pem')}
|
||||
RECOVERY_POD = 'privacyidea-key-recovery'
|
||||
SLEEP = ['python3', '-c', 'import time; time.sleep(3600)']
|
||||
IMAGE = re.compile(r'ghcr\.io/gpappsoft/privacyidea-docker@sha256:[a-f0-9]{64}\Z')
|
||||
UID = re.compile(r'[a-zA-Z0-9-]{1,128}\Z')
|
||||
|
||||
|
||||
def get(kind, name=None, missing=False):
|
||||
args = ['kubectl', 'get', kind]
|
||||
if name:
|
||||
args.append(name)
|
||||
args += ['-n', 'mfa', '-o', 'json']
|
||||
if missing:
|
||||
args.append('--ignore-not-found')
|
||||
raw = common.run(args)
|
||||
return json.loads(raw) if raw.strip() else None
|
||||
|
||||
|
||||
def meta(obj, kind, name=None):
|
||||
if not isinstance(obj, dict) or obj.get('kind') != kind:
|
||||
raise Refused()
|
||||
m = obj.get('metadata', {})
|
||||
if (m.get('namespace') != 'mfa' or (name and m.get('name') != name)
|
||||
or not UID.fullmatch(str(m.get('uid', ''))) or m.get('deletionTimestamp')):
|
||||
raise Refused()
|
||||
return m
|
||||
|
||||
|
||||
def mounts_data(spec, container):
|
||||
volumes = {v['name']: v for v in spec.get('volumes', [])}
|
||||
return any(v.get('mountPath') == '/etc/privacyidea' and not v.get('subPath')
|
||||
and not v.get('readOnly') and volumes.get(v['name'], {}).get('persistentVolumeClaim', {}).get('claimName') == 'privacyidea-data'
|
||||
for v in container.get('volumeMounts', []))
|
||||
|
||||
|
||||
def pod_uses_data(pod):
|
||||
return any(v.get('persistentVolumeClaim', {}).get('claimName') == 'privacyidea-data'
|
||||
for v in pod.get('spec', {}).get('volumes', []))
|
||||
|
||||
|
||||
def context(action, expected_uid=None):
|
||||
deployment = get('deployment', 'privacyidea')
|
||||
dm = meta(deployment, 'Deployment', 'privacyidea')
|
||||
template = deployment['spec']['template']['spec']
|
||||
container = next(c for c in template['containers'] if c['name'] == 'privacyidea')
|
||||
if template.get('securityContext', {}).get('fsGroup') != 999 or not mounts_data(template, container):
|
||||
raise Refused()
|
||||
pvc = get('pvc', 'privacyidea-data')
|
||||
pm = meta(pvc, 'PersistentVolumeClaim', 'privacyidea-data')
|
||||
if pvc.get('status', {}).get('phase') != 'Bound' or (expected_uid and pm['uid'] != expected_uid):
|
||||
raise Refused()
|
||||
listing = get('pods')
|
||||
if listing.get('kind') not in ('PodList', 'List'):
|
||||
raise Refused()
|
||||
users = [pod for pod in listing['items'] if pod_uses_data(pod)]
|
||||
if len(users) != 1:
|
||||
raise Refused()
|
||||
pod = users[0]; podmeta = meta(pod, 'Pod')
|
||||
spec = pod['spec']
|
||||
if pod.get('status', {}).get('phase') != 'Running':
|
||||
raise Refused()
|
||||
if action == 'restore':
|
||||
if (deployment['spec'].get('replicas', 1) != 0
|
||||
or any(deployment.get('status', {}).get(k, 0) for k in ('replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas'))
|
||||
or deployment.get('status', {}).get('observedGeneration', 0) < dm.get('generation', 1)):
|
||||
raise Refused()
|
||||
if (podmeta['name'] != RECOVERY_POD or podmeta.get('annotations', {}).get('net-kingdom/recovery-pvc-uid') != expected_uid
|
||||
or spec.get('automountServiceAccountToken') is not False
|
||||
or any(spec.get(k) for k in ('hostNetwork', 'hostPID', 'hostIPC', 'initContainers', 'ephemeralContainers'))
|
||||
or len(spec.get('containers', [])) != 1 or len(spec.get('volumes', [])) != 1):
|
||||
raise Refused()
|
||||
c = spec['containers'][0]
|
||||
if (c.get('name') != 'recovery' or not IMAGE.fullmatch(c.get('image', ''))
|
||||
or c.get('command') != SLEEP or c.get('args') or c.get('env') or c.get('envFrom')
|
||||
or c.get('securityContext', {}).get('privileged')
|
||||
or not mounts_data(spec, c)):
|
||||
raise Refused()
|
||||
else:
|
||||
c = next(c for c in spec['containers'] if c['name'] == 'privacyidea')
|
||||
if not mounts_data(spec, c):
|
||||
raise Refused()
|
||||
owners = podmeta.get('ownerReferences', [])
|
||||
if len(owners) != 1 or owners[0].get('kind') != 'ReplicaSet' or not owners[0].get('controller'):
|
||||
raise Refused()
|
||||
rs = get('replicaset', owners[0]['name']); rm = meta(rs, 'ReplicaSet', owners[0]['name'])
|
||||
if rm['uid'] != owners[0]['uid'] or not any(o.get('uid') == dm['uid'] and o.get('kind') == 'Deployment' and o.get('controller') for o in rm.get('ownerReferences', [])):
|
||||
raise Refused()
|
||||
if not any(s.get('name') == c['name'] and s.get('ready') for s in pod['status'].get('containerStatuses', [])):
|
||||
raise Refused()
|
||||
return {'pod': podmeta['name'], 'pod_uid': podmeta['uid'], 'pvc_uid': pm['uid'],
|
||||
'deployment_uid': dm['uid'], 'container': c['name']}
|
||||
|
||||
|
||||
def invoke(ctx, request):
|
||||
source = (HERE / 'key-custody-remote.py').read_text()
|
||||
raw = common.run(['kubectl', 'exec', '-i', '-n', 'mfa', ctx['pod'], '-c', ctx['container'],
|
||||
'--', 'python3', '-c', source], json.dumps(request).encode())
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def read_backups():
|
||||
result = {}
|
||||
for name in TARGETS:
|
||||
obj = get('secret', name, missing=True)
|
||||
result[name] = common.decode_secret(json.dumps(obj), 'mfa', name) if obj is not None else None
|
||||
return result
|
||||
|
||||
|
||||
def verify_backups(backups, values):
|
||||
for name, keys in TARGETS.items():
|
||||
old = backups[name]
|
||||
if old is not None and any(old['data'].get(k) != base64.b64encode(values[k]).decode() for k in keys):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def local_values(directory):
|
||||
result = {name: common.read_private(directory, local) for name, local in LOCAL.items()}
|
||||
remote.validate(result)
|
||||
return result
|
||||
|
||||
|
||||
def save_local(directory, values):
|
||||
# All differences are checked before creating any file. Existing copies survive.
|
||||
missing = []
|
||||
for name, local in LOCAL.items():
|
||||
try:
|
||||
if common.read_private(directory, local) != values[name]:
|
||||
raise Refused()
|
||||
except FileNotFoundError:
|
||||
missing.append((name, local))
|
||||
for name, local in missing:
|
||||
temporary = '.key-export-' + secrets.token_hex(12)
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(values[name]); stream.flush(); os.fsync(stream.fileno())
|
||||
os.link(temporary, local, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
|
||||
os.unlink(temporary, dir_fd=directory); os.fsync(directory)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if local_values(directory) != values:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def perform(action, root, expected_uid=None, initialize=False):
|
||||
if action == 'restore' and (not expected_uid or not UID.fullmatch(expected_uid)):
|
||||
raise Refused()
|
||||
with common.custody(root, ('privacyidea',)) as directories:
|
||||
directory = directories['privacyidea']
|
||||
values = local_values(directory) if action == 'restore' else None
|
||||
ctx = context(action, expected_uid)
|
||||
if action == 'restore':
|
||||
result = invoke(ctx, {'action': 'restore', 'keys': {k: base64.b64encode(v).decode() for k, v in values.items()}})
|
||||
if result != {'result': 'restored'} or context(action, expected_uid) != ctx:
|
||||
raise Refused()
|
||||
else:
|
||||
backups = read_backups()
|
||||
if initialize:
|
||||
if any(value is not None for value in backups.values()):
|
||||
raise Refused()
|
||||
# Independent local recovery copies also prohibit first-bootstrap creation.
|
||||
for name in LOCAL.values():
|
||||
try:
|
||||
os.stat(name, dir_fd=directory, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
raise Refused()
|
||||
if invoke(ctx, {'action': 'initialize-audit'}) != {'result': 'initialized'}:
|
||||
raise Refused()
|
||||
encoded = invoke(ctx, {'action': 'export'})
|
||||
values = {k: base64.b64decode(v, validate=True) for k, v in encoded.items()}
|
||||
remote.validate(values)
|
||||
if context(action) != ctx:
|
||||
raise Refused()
|
||||
verify_backups(backups, values)
|
||||
save_local(directory, values)
|
||||
for name, keys in TARGETS.items():
|
||||
common.publish('mfa', name, {k: values[k] for k in keys}, backups[name], set())
|
||||
print('Key custody operation verified. Retain independent recovery copies; native service acceptance remains required.')
|
||||
|
||||
|
||||
def render(image, pvc_uid):
|
||||
if not image or not IMAGE.fullmatch(image) or not pvc_uid or not UID.fullmatch(pvc_uid):
|
||||
raise Refused()
|
||||
return {'apiVersion': 'v1', 'kind': 'Pod',
|
||||
'metadata': {'name': RECOVERY_POD, 'namespace': 'mfa',
|
||||
'annotations': {'net-kingdom/recovery-pvc-uid': pvc_uid}},
|
||||
'spec': {'restartPolicy': 'Never', 'automountServiceAccountToken': False,
|
||||
'securityContext': {'runAsUser': 0, 'runAsGroup': 999, 'fsGroup': 999},
|
||||
'containers': [{'name': 'recovery', 'image': image, 'command': SLEEP,
|
||||
'securityContext': {'allowPrivilegeEscalation': False, 'readOnlyRootFilesystem': True,
|
||||
'capabilities': {'drop': ['ALL'], 'add': ['CHOWN', 'DAC_OVERRIDE', 'FOWNER']}},
|
||||
'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, 'limits': {'cpu': '100m', 'memory': '128Mi'}},
|
||||
'volumeMounts': [{'name': 'data', 'mountPath': '/etc/privacyidea'}]}],
|
||||
'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'privacyidea-data'}}]}}
|
||||
|
||||
|
||||
def main():
|
||||
class QuietParser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
raise Refused()
|
||||
parser = QuietParser(description=__doc__)
|
||||
parser.add_argument('action', choices=['export', 'restore', 'render-pod'])
|
||||
parser.add_argument('root', nargs='?')
|
||||
parser.add_argument('--pvc-uid')
|
||||
parser.add_argument('--image')
|
||||
parser.add_argument('--initialize-audit-keys', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.action == 'render-pod':
|
||||
if args.root or args.initialize_audit_keys:
|
||||
raise Refused()
|
||||
print(json.dumps(render(args.image, args.pvc_uid), indent=2))
|
||||
else:
|
||||
if not args.root or args.image or (args.action == 'restore' and args.initialize_audit_keys) or (args.action == 'export' and args.pvc_uid):
|
||||
raise Refused()
|
||||
perform(args.action, args.root, args.pvc_uid, args.initialize_audit_keys)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
print('ERROR: key custody refused or failed; diagnostics suppressed. A failed write may have partial effects. '
|
||||
'Retain copies and reconcile target state before retrying.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""Provider-local idempotent setup of an inert realm-visibility probe; emits no key."""
|
||||
import contextlib,io,json,logging
|
||||
SERIAL="KCFACTORSCOPE01"
|
||||
DESCRIPTION="KeyCape realm visibility probe; keep disabled and unassigned"
|
||||
result={"success":False}
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
try:
|
||||
logging.disable(logging.CRITICAL)
|
||||
from privacyidea.app import create_app
|
||||
from privacyidea.lib.token import get_tokens,init_token
|
||||
from privacyidea.lib.realm import get_realms
|
||||
app=create_app(config_name="production",silent=True)
|
||||
with app.app_context():
|
||||
if "coulomb" not in get_realms():raise ValueError()
|
||||
tokens=get_tokens(serial=SERIAL)
|
||||
if not tokens:
|
||||
token=init_token({"serial":SERIAL,"type":"hotp","genkey":1,"realm":"coulomb","description":DESCRIPTION})
|
||||
token.token.active=False;token.token.save()
|
||||
tokens=get_tokens(serial=SERIAL)
|
||||
if len(tokens)!=1:raise ValueError()
|
||||
data=tokens[0].token.get_vars()
|
||||
if data["description"]!=DESCRIPTION or data["active"] or data["user_id"] or data["realms"]!=["coulomb"]:raise ValueError()
|
||||
result={"success":True,"serial":SERIAL,"realm":"coulomb","disabled":True,"unassigned":True}
|
||||
except Exception as e:result["failure_type"]=type(e).__name__
|
||||
print(json.dumps(result));raise SystemExit(0 if result["success"] else 1)
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only, scoped factor diagnostic. No credential discovery or issuance."""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REALM = 'coulomb'
|
||||
PROBE = 'KCFACTORSCOPE01'
|
||||
MAX_BODY = 1024 * 1024
|
||||
MAX_FACTORS = 1000
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class QuietParser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
raise Refused('arguments')
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused('response')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def endpoint(value):
|
||||
if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value):
|
||||
raise Refused('endpoint')
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
|
||||
raise Refused('endpoint')
|
||||
if value.rstrip('/') in ('https://pink.coulomb.social', 'https://pink.coulomb.social:443'):
|
||||
return value.rstrip('/')
|
||||
# Literal loopback only: an explicitly selected local fixture/approved forward.
|
||||
if (parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https')
|
||||
or parsed.port is None or not 1 <= parsed.port <= 65535):
|
||||
raise Refused('endpoint')
|
||||
expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}'
|
||||
if value.rstrip('/') != expected:
|
||||
raise Refused('endpoint')
|
||||
return expected
|
||||
|
||||
|
||||
def credential(path):
|
||||
path = Path(os.path.abspath(path))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('credential')
|
||||
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in path.parents):
|
||||
raise Refused('credential')
|
||||
parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(parent)
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused('credential')
|
||||
fd = os.open(path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent)
|
||||
try:
|
||||
before = os.fstat(fd)
|
||||
if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid()
|
||||
or before.st_mode & 0o077 or before.st_nlink != 1 or before.st_size > 16385):
|
||||
raise Refused('credential')
|
||||
raw = os.read(fd, 16386)
|
||||
after = os.fstat(fd)
|
||||
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
|
||||
raise Refused('credential')
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(parent)
|
||||
token = raw.decode('ascii').removesuffix('\n')
|
||||
if len(token) > 16384 or not re.fullmatch(r'[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', token):
|
||||
raise Refused('credential')
|
||||
claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '==='), object_pairs_hook=unique_object)
|
||||
# A structural preflight only. The provider must verify signature and expiry.
|
||||
if (not isinstance(claims, dict) or claims.get('role') != 'admin'
|
||||
or type(claims.get('exp')) not in (int, float) or claims['exp'] <= time.time()):
|
||||
raise Refused('credential')
|
||||
return token
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base, token):
|
||||
self.base = endpoint(base)
|
||||
self.token = token
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# Use system trust, not ambient SSL_CERT_FILE/SSL_CERT_DIR overrides.
|
||||
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(),
|
||||
urllib.request.HTTPSHandler(context=context))
|
||||
|
||||
def get(self, query):
|
||||
req = urllib.request.Request(self.base + '/token/?' + urllib.parse.urlencode(query),
|
||||
headers={'Authorization': self.token, 'Accept': 'application/json'}, method='GET')
|
||||
try:
|
||||
with self.opener.open(req, timeout=10) as response:
|
||||
if response.status != 200:
|
||||
raise Refused('provider')
|
||||
raw = response.read(MAX_BODY + 1)
|
||||
if len(raw) > MAX_BODY:
|
||||
raise Refused('response')
|
||||
document = json.loads(raw, object_pairs_hook=unique_object)
|
||||
result = document.get('result') if isinstance(document, dict) else None
|
||||
if not isinstance(result, dict) or result.get('status') is not True:
|
||||
raise Refused('provider')
|
||||
return result.get('value')
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
raise Refused('provider') from None
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
raise Refused('provider') from None
|
||||
|
||||
|
||||
def complete_page(value):
|
||||
if not isinstance(value, dict):
|
||||
raise Refused('response')
|
||||
count, rows = value.get('count'), value.get('tokens')
|
||||
if (type(count) is not int or not 0 <= count <= MAX_FACTORS or not isinstance(rows, list)
|
||||
or count != len(rows) or type(value.get('current')) is not int or value['current'] != 1
|
||||
or value.get('next') is not None or value.get('prev') is not None
|
||||
or any(not isinstance(row, dict) for row in rows)):
|
||||
raise Refused('response')
|
||||
serials = [row.get('serial') for row in rows]
|
||||
if any(not isinstance(s, str) or not s or len(s) > 256 for s in serials) or len(set(serials)) != len(rows):
|
||||
raise Refused('response')
|
||||
return rows
|
||||
|
||||
|
||||
def prove_scope(client):
|
||||
rows = complete_page(client.get({'serial': PROBE, 'tokenrealm': REALM, 'page': 1, 'pagesize': 2}))
|
||||
if (len(rows) != 1 or rows[0]['serial'] != PROBE or rows[0].get('active') is not False
|
||||
or rows[0].get('user_id') != '' or rows[0].get('realms') != [REALM]):
|
||||
raise Refused('visibility')
|
||||
|
||||
|
||||
def diagnose(client, username):
|
||||
# Avoid provider wildcard and user@realm expansion; support literal estate IDs.
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', username):
|
||||
raise Refused('username')
|
||||
prove_scope(client)
|
||||
rows = complete_page(client.get({'user': username, 'realm': REALM, 'tokenrealm': REALM,
|
||||
'page': 1, 'pagesize': MAX_FACTORS, 'sortby': 'serial', 'sortdir': 'asc'}))
|
||||
counts = {'total': len(rows), 'active_confirmed': 0, 'active_pending': 0, 'inactive': 0}
|
||||
for row in rows:
|
||||
if (row.get('username') != username or row.get('user_realm') != REALM
|
||||
or not isinstance(row.get('realms'), list) or REALM not in row['realms']
|
||||
or type(row.get('active')) is not bool):
|
||||
raise Refused('response')
|
||||
if not row['active']:
|
||||
counts['inactive'] += 1
|
||||
elif row.get('rollout_state') in ('', 'enrolled'):
|
||||
counts['active_confirmed'] += 1
|
||||
elif row.get('rollout_state') in ('verify', 'clientwait', 'pending'):
|
||||
counts['active_pending'] += 1
|
||||
else:
|
||||
raise Refused('response')
|
||||
# Do not turn a successful empty provider response after withdrawal into zero.
|
||||
prove_scope(client)
|
||||
return {'result': 'observed', 'realm': REALM, 'user': username, 'factors': counts,
|
||||
'reader_visibility': 'verified', 'user_existence': 'not_checked', 'mfa_login': 'not_tested'}
|
||||
|
||||
|
||||
def main():
|
||||
phase = 'input'
|
||||
try:
|
||||
parser = QuietParser(description=__doc__)
|
||||
parser.add_argument('username', nargs='?', default='platform-root')
|
||||
parser.add_argument('--token-file', required=True, help='Owner-admitted private JWT file outside Git; no automatic retrieval')
|
||||
parser.add_argument('--pi-url', default=os.environ.get('PI_URL', 'https://pink.coulomb.social'))
|
||||
args = parser.parse_args()
|
||||
base = endpoint(args.pi_url)
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.username):
|
||||
raise Refused('username')
|
||||
token = credential(args.token_file)
|
||||
phase = 'diagnostic'
|
||||
result = diagnose(Client(base, token), args.username)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except Exception:
|
||||
# Never render exception text, URLs, paths, raw provider errors or tokens.
|
||||
print(json.dumps({'result': 'unverified', 'phase': phase}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""Captured JSON transport for protected callers; never run against live data to stdout."""
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from mfa_state import NoRedirect, endpoint, unique_object
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
|
||||
|
||||
def service_endpoint(value, profile='privacyidea'):
|
||||
"""Named destinations; profiles never inherit each other's credential scope."""
|
||||
if profile == 'privacyidea':
|
||||
return endpoint(value)
|
||||
hosts = {'lldap-auth': 'lldap.coulomb.social', 'keycape-discovery': 'kc.coulomb.social'}
|
||||
if profile not in hosts:
|
||||
raise ValueError()
|
||||
if value in ('https://' + hosts[profile], 'https://' + hosts[profile] + ':443'):
|
||||
return value
|
||||
base = endpoint(value)
|
||||
if urllib.parse.urlsplit(base).hostname not in ('127.0.0.1', '::1'):
|
||||
raise ValueError()
|
||||
return base
|
||||
|
||||
|
||||
def request(url, payload=None, token=None, *, method=None, profile='privacyidea'):
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
base = service_endpoint(urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', '')), profile)
|
||||
if parsed.fragment or not parsed.path.startswith('/') or any(ord(c) < 32 for c in url):
|
||||
return 0, None
|
||||
verb = method or ('POST' if payload is not None else 'GET')
|
||||
if profile != 'privacyidea':
|
||||
if token is not None or parsed.query: return 0, None
|
||||
if profile == 'lldap-auth':
|
||||
if (verb != 'POST' or parsed.path != '/auth/simple/login' or not isinstance(payload, dict)
|
||||
or set(payload) != {'username', 'password'} or payload['username'] != 'admin'
|
||||
or not isinstance(payload['password'], str) or not payload['password']): return 0, None
|
||||
elif verb != 'GET' or parsed.path != '/.well-known/openid-configuration' or payload is not None:
|
||||
return 0, None
|
||||
# Origin validated before creating a request containing protected values.
|
||||
headers = {'Accept': 'application/json'}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
if len(data) > MAX_BODY: return 0, None
|
||||
headers['Content-Type'] = 'application/json'
|
||||
if token:
|
||||
if not isinstance(token, str) or len(token) > 16384 or any(ord(c) < 33 or ord(c) > 126 for c in token):
|
||||
return 0, None
|
||||
headers['Authorization'] = token
|
||||
req = urllib.request.Request(base + parsed.path + ('?' + parsed.query if parsed.query else ''),
|
||||
data=data, headers=headers, method=method or ('POST' if payload is not None else 'GET'))
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(),
|
||||
urllib.request.HTTPSHandler(context=context))
|
||||
with opener.open(req, timeout=20) as response:
|
||||
status, raw = response.status, response.read(MAX_BODY + 1)
|
||||
if len(raw) > MAX_BODY: return status, None
|
||||
return status, json.loads(raw, object_pairs_hook=unique_object) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
return exc.code, None
|
||||
except Exception:
|
||||
# Callers emit fixed phases, never exception strings or response bodies.
|
||||
return 0, None
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ('GET', 'POST'):
|
||||
raise ValueError()
|
||||
token = sys.stdin.readline(16386).removesuffix('\n')
|
||||
body = sys.stdin.read(MAX_BODY + 1)
|
||||
if len(body) > MAX_BODY: raise ValueError()
|
||||
status, result = request(sys.argv[2], json.loads(body, object_pairs_hook=unique_object) if body else None,
|
||||
token or None, method=sys.argv[1])
|
||||
if not 200 <= status < 300 or not isinstance(result, dict): raise ValueError()
|
||||
# Private shell adapter: caller must capture this; it can contain a JWT/config.
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
except Exception:
|
||||
print('CURL_FAILED')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Repair missing realm/resolver configuration while preserving established policy."""
|
||||
import argparse
|
||||
import getpass
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from mfa_state import QuietParser, endpoint
|
||||
from pi_api import request
|
||||
|
||||
RESOLVER = 'lldap-coulomb'
|
||||
REALM = 'coulomb'
|
||||
CORE = {'LDAPURI': 'ldap://lldap.sso.svc.cluster.local:3890',
|
||||
'LDAPBASE': 'dc=netkingdom,dc=local', 'BINDDN': 'uid=admin,ou=people,dc=netkingdom,dc=local'}
|
||||
TUNING = {'TIMEOUT': '5', 'CACHE_TIMEOUT': '120', 'SIZELIMIT': '500'}
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def password(value):
|
||||
if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value):
|
||||
raise Refused()
|
||||
return value
|
||||
|
||||
|
||||
def private_inputs(root):
|
||||
path = Path(__file__).resolve().parents[2] / 'bootstrap/apply-service-secrets.py'
|
||||
spec = importlib.util.spec_from_file_location('realm_custody', path)
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
with module.custody(root, ['privacyidea', 'lldap']) as dirs:
|
||||
admin = password(module.read_env(dirs['privacyidea']).get('PI_ADMIN_PASSWORD'))
|
||||
bind = password(module.read_env(dirs['lldap']).get('LLDAP_LDAP_USER_PASS'))
|
||||
return admin, bind
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, url, admin_password):
|
||||
self.url = endpoint(url)
|
||||
self.token = None
|
||||
auth = self.call('/auth', {'username': 'pi-admin', 'password': password(admin_password)})
|
||||
if not isinstance(auth, dict) or auth.get('role') != 'admin' or not isinstance(auth.get('token'), str) or not auth['token']:
|
||||
raise Refused()
|
||||
self.token = auth['token']
|
||||
|
||||
def call(self, path, payload=None):
|
||||
status, body = request(self.url + path, payload, self.token)
|
||||
result = body.get('result') if isinstance(body, dict) else None
|
||||
if status != 200 or not isinstance(result, dict) or result.get('status') is not True:
|
||||
raise Refused()
|
||||
return result.get('value')
|
||||
|
||||
def snapshot(self):
|
||||
resolvers = self.call('/resolver/' + RESOLVER)
|
||||
realms = self.call('/realm/')
|
||||
if not isinstance(resolvers, dict) or set(resolvers) - {RESOLVER} or not isinstance(realms, dict):
|
||||
raise Refused()
|
||||
if any(not isinstance(v, dict) for v in realms.values()): raise Refused()
|
||||
return {'resolver': resolvers.get(RESOLVER), 'realms': realms}
|
||||
|
||||
|
||||
def plan(snapshot):
|
||||
resolver, realms = snapshot['resolver'], snapshot['realms']
|
||||
updates = {}
|
||||
if resolver is None:
|
||||
updates = dict(CORE, **TUNING, type='ldapresolver', LOGINNAMEATTRIBUTE='uid',
|
||||
LDAPSEARCHFILTER='(objectClass=inetOrgPerson)', LDAPFILTER='(&(objectClass=inetOrgPerson)(uid=%s))',
|
||||
USERINFO=json.dumps({'username':'uid','phone':'telephoneNumber','mobile':'mobile','email':'mail',
|
||||
'surname':'sn','givenname':'givenName'}),
|
||||
UIDTYPE='uid', NOREFERRALS=True, NOSCHEMAS=True)
|
||||
else:
|
||||
if not isinstance(resolver, dict) or resolver.get('type') != 'ldapresolver' or not isinstance(resolver.get('data'), dict):
|
||||
raise Refused()
|
||||
data = resolver['data']
|
||||
if any(data.get(key) != value for key, value in CORE.items()): raise Refused()
|
||||
for key, default in TUNING.items():
|
||||
value = data.get(key)
|
||||
if value in (None, ''): updates[key] = default
|
||||
elif type(value) not in (str, int) or not str(value).isascii() or not str(value).isdigit(): raise Refused()
|
||||
if updates: updates['type'] = 'ldapresolver'
|
||||
realm = realms.get(REALM)
|
||||
if realm is not None:
|
||||
bindings = realm.get('resolver')
|
||||
if (not isinstance(bindings, list) or not any(isinstance(v, dict) and v.get('name') == RESOLVER for v in bindings)):
|
||||
raise Refused()
|
||||
return {'resolver_update': updates, 'create_resolver': resolver is None,
|
||||
'create_realm': realm is None, 'set_initial_default': not realms}
|
||||
|
||||
|
||||
def repair(client, bind_input, *, apply=False):
|
||||
initial = client.snapshot()
|
||||
changes = plan(initial)
|
||||
actions = []
|
||||
if changes['resolver_update']: actions.append('create-resolver' if changes['create_resolver'] else 'fill-missing-tuning')
|
||||
if changes['create_realm']: actions.append('create-realm')
|
||||
if changes['set_initial_default']: actions.append('set-first-realm-default')
|
||||
if not apply:
|
||||
return {'result': 'planned', 'actions': actions, 'configuration_writes': 0, 'policies_changed': False, 'mfa_verified': False}
|
||||
bind = password(bind_input()) if changes['create_resolver'] else None
|
||||
# Recheck the full observed target before each operation. API has no CAS;
|
||||
# an exclusive owner window remains required, and ambiguous writes are not retried.
|
||||
expected = initial
|
||||
for action in actions:
|
||||
if client.snapshot() != expected: raise Refused()
|
||||
if action in ('create-resolver', 'fill-missing-tuning'):
|
||||
payload = dict(changes['resolver_update'])
|
||||
if action == 'create-resolver': payload['BINDPW'] = bind
|
||||
value = client.call('/resolver/' + RESOLVER, payload)
|
||||
if type(value) is not int or value < 1: raise Refused()
|
||||
after = client.snapshot()
|
||||
current = after['resolver']
|
||||
if not isinstance(current, dict) or current.get('type') != 'ldapresolver': raise Refused()
|
||||
data = current.get('data', {})
|
||||
for key, desired in payload.items():
|
||||
if key in ('type', 'BINDPW'): continue
|
||||
actual = data.get(key)
|
||||
if isinstance(desired, bool):
|
||||
if str(actual).lower() != str(desired).lower(): raise Refused()
|
||||
elif str(actual) != str(desired): raise Refused()
|
||||
if action == 'fill-missing-tuning':
|
||||
old = expected['resolver']['data']
|
||||
if any(data.get(k) != v for k, v in old.items() if k not in payload): raise Refused()
|
||||
if after['realms'] != expected['realms']: raise Refused()
|
||||
elif action == 'create-realm':
|
||||
value = client.call('/realm/' + REALM, {'resolvers': [RESOLVER], 'priority.'+RESOLVER: 1})
|
||||
if not isinstance(value, dict) or value.get('failed') != [] or value.get('added') != [RESOLVER]: raise Refused()
|
||||
after = client.snapshot()
|
||||
realm = after['realms'].get(REALM)
|
||||
if not isinstance(realm, dict) or [r.get('name') for r in realm.get('resolver', [])] != [RESOLVER]: raise Refused()
|
||||
if {k:v for k,v in after['realms'].items() if k != REALM} != expected['realms']: raise Refused()
|
||||
if after['resolver'] != expected['resolver']: raise Refused()
|
||||
else:
|
||||
value = client.call('/defaultrealm/' + REALM, {})
|
||||
if type(value) is not int or value < 1: raise Refused()
|
||||
after = client.snapshot()
|
||||
anticipated = json.loads(json.dumps(expected))
|
||||
anticipated['realms'][REALM]['default'] = True
|
||||
if after != anticipated: raise Refused()
|
||||
expected = after
|
||||
final = client.snapshot()
|
||||
if final != expected or plan(final)['resolver_update'] or plan(final)['create_realm']: raise Refused()
|
||||
return {'result': 'configured', 'actions': actions, 'configuration_writes': len(actions),
|
||||
'policies_changed': False, 'mfa_verified': False, 'onboarding_policy': 'owner-managed'}
|
||||
|
||||
|
||||
def main():
|
||||
phase = 'input'
|
||||
try:
|
||||
parser = QuietParser(description=__doc__)
|
||||
parser.add_argument('root', nargs='?')
|
||||
parser.add_argument('url', nargs='?', default=os.environ.get('PI_URL', 'https://pink.coulomb.social'))
|
||||
parser.add_argument('--pi-url')
|
||||
parser.add_argument('--attended', action='store_true')
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
parser.add_argument('--verify-user')
|
||||
args = parser.parse_args()
|
||||
base = endpoint(args.pi_url or args.url)
|
||||
if args.verify_user and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.verify_user): raise Refused()
|
||||
if args.attended:
|
||||
if args.root is not None or not sys.stdin.isatty(): raise Refused()
|
||||
admin = password(getpass.getpass('privacyIDEA pi-admin password: '))
|
||||
bind_input = lambda: getpass.getpass('LLDAP bind password for missing resolver: ')
|
||||
else:
|
||||
if args.root is None or args.verify_user: raise Refused()
|
||||
admin, bind = private_inputs(args.root)
|
||||
bind_input = lambda: bind
|
||||
phase = 'configuration'
|
||||
client = Client(base, admin)
|
||||
report = repair(client, bind_input, apply=args.apply)
|
||||
if args.verify_user and args.apply:
|
||||
phase = 'functional-verification'
|
||||
from verify_mfa import verify
|
||||
proof = verify(base, args.verify_user, REALM, RESOLVER, admin,
|
||||
lambda:getpass.getpass('Fresh MFA code (include token PIN if required): '))
|
||||
if proof['result'] != 'PASS': raise Refused()
|
||||
report['mfa_verified'] = True
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 0
|
||||
except (Exception, KeyboardInterrupt):
|
||||
print(json.dumps({'result':'unverified','phase':phase,'partial_changes_possible':phase!='input'}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Attended inputs stay in Python memory; disable inherited tracing first.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/resolver_cutover.py" reconcile "$@"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# Attended configuration repair and functional proof; passwords remain in memory.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
if [[ $# -ne 0 ]]; then
|
||||
printf '%s\n' 'ERROR: this attended entrypoint takes no positional credential inputs.' >&2
|
||||
exit 2
|
||||
fi
|
||||
exec python3 "$(dirname -- "$0")/realm_bootstrap.py" --attended --apply --verify-user "${MFA_USER:-platform-root}"
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended resolver password cutover; no secret files or ambient target overrides."""
|
||||
import copy
|
||||
import getpass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
from mfa_state import QuietParser, endpoint
|
||||
from pi_api import request, service_endpoint
|
||||
import realm_bootstrap as realm
|
||||
from realm_bootstrap import Client, Refused, password
|
||||
from verify_mfa import verify
|
||||
|
||||
WORKLOADS = (('sso', 'lldap'), ('mfa', 'privacyidea'), ('sso', 'keycape'),
|
||||
('sso', 'authelia'), ('sso', 'identity-provisioner'))
|
||||
|
||||
|
||||
def public_snapshot(snapshot):
|
||||
result = copy.deepcopy(snapshot)
|
||||
result['resolver']['data'].pop('BINDPW', None)
|
||||
return result
|
||||
|
||||
|
||||
def existing(snapshot):
|
||||
changes = realm.plan(snapshot)
|
||||
if any(changes.values()):
|
||||
raise Refused()
|
||||
# Never fingerprint a password, even if a provider stops censoring it.
|
||||
data = snapshot['resolver']['data']
|
||||
if data.get('BINDPW') != '__CENSORED__':
|
||||
raise Refused()
|
||||
return snapshot
|
||||
|
||||
|
||||
def digest(snapshot, target):
|
||||
return hashlib.sha256(json.dumps({'state': public_snapshot(snapshot), 'target': target},
|
||||
sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def native_bind(client, snapshot, candidate='__CENSORED__'):
|
||||
payload = dict(snapshot['resolver']['data'], resolver=realm.RESOLVER,
|
||||
type='ldapresolver', BINDPW=password(candidate))
|
||||
if client.call('/resolver/test', payload) is not True:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def login(base, candidate, *, denied=False):
|
||||
status, body = request(base + '/auth/simple/login',
|
||||
{'username': 'admin', 'password': password(candidate)}, profile='lldap-auth')
|
||||
if denied:
|
||||
if status not in (401, 403): raise Refused()
|
||||
elif status != 200 or not isinstance(body, dict) or not isinstance(body.get('token'), str) or not body['token']:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def cluster_ready(target):
|
||||
prefix = ['kubectl', '--kubeconfig', target['kubeconfig'], '--context', target['context'], '--request-timeout=15s']
|
||||
|
||||
def read(args):
|
||||
result = subprocess.run(prefix + args, capture_output=True, text=True, timeout=20)
|
||||
if result.returncode or len(result.stdout) > 4096: raise Refused()
|
||||
return result.stdout.strip()
|
||||
|
||||
if read(['config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}']) != target['server']:
|
||||
raise Refused()
|
||||
if read(['get', 'namespace', 'kube-system', '-o', 'jsonpath={.metadata.uid}']) != target['cluster_uid']:
|
||||
raise Refused()
|
||||
fields = ('metadata.generation', 'status.observedGeneration', 'spec.replicas', 'status.replicas',
|
||||
'status.updatedReplicas', 'status.readyReplicas', 'status.availableReplicas', 'metadata.deletionTimestamp')
|
||||
expression = '{"|"}'.join('{.' + field + '}' for field in fields)
|
||||
for namespace, name in WORKLOADS:
|
||||
values = read(['get', 'deployment', name, '-n', namespace, '-o', 'jsonpath=' + expression]).split('|')
|
||||
if (len(values) != 8 or not values[0].isdigit() or int(values[0]) < 1 or values[0] != values[1]
|
||||
or values[2:7] != ['1'] * 5 or values[7]): raise Refused()
|
||||
|
||||
|
||||
def health(target):
|
||||
status, _ = request(target['pi_url'] + '/token/')
|
||||
if status not in (200, 401, 403): raise Refused()
|
||||
base = target['keycape_url']
|
||||
issuer = 'https://kc.coulomb.social'
|
||||
status, body = request(base + '/.well-known/openid-configuration', profile='keycape-discovery')
|
||||
if status != 200 or not isinstance(body, dict) or body.get('issuer') != issuer: raise Refused()
|
||||
for key in ('authorization_endpoint', 'token_endpoint', 'jwks_uri'):
|
||||
value = body.get(key)
|
||||
if not isinstance(value, str) or not value.startswith(issuer + '/'):
|
||||
raise Refused()
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.fragment or parsed.username or parsed.password or any(ord(c) <= 32 for c in value): raise Refused()
|
||||
|
||||
|
||||
def run(client, target, mode, *, replacement=None, predecessor=None, expected_digest=None,
|
||||
mfa_proof=None, gate=None, report=None):
|
||||
report = report if report is not None else {}
|
||||
report.update(result='unverified', mode=mode, helper=target['helper'], phase='preflight', write_attempted=False, configuration_writes=0,
|
||||
mfa_verified=False, supplied_predecessor_denied=False, incident_acceptance='unverified')
|
||||
full = target['helper'] == 'reconcile'
|
||||
if full: gate()
|
||||
report['phase'] = 'snapshot'
|
||||
initial = existing(client.snapshot())
|
||||
fingerprint = digest(initial, target)
|
||||
report['snapshot_sha256'] = fingerprint
|
||||
if mode == 'preview':
|
||||
report.update(result='planned', phase='complete')
|
||||
return report
|
||||
if mode == 'apply' and expected_digest != fingerprint: raise Refused()
|
||||
report['phase'] = 'replacement-authentication'
|
||||
if replacement == '__CENSORED__': raise Refused()
|
||||
login(target['lldap_url'], replacement)
|
||||
native_bind(client, initial, replacement)
|
||||
if full and predecessor is not None:
|
||||
report['phase'] = 'predecessor-denial'
|
||||
if predecessor == replacement: raise Refused()
|
||||
login(target['lldap_url'], predecessor, denied=True)
|
||||
report['supplied_predecessor_denied'] = True
|
||||
# No CAS in privacyIDEA: owner must still reserve an exclusive change window.
|
||||
report['phase'] = 'prewrite-recheck'
|
||||
if client.snapshot() != initial: raise Refused()
|
||||
if mode == 'apply':
|
||||
report.update(phase='resolver-update', write_attempted=True)
|
||||
value = client.call('/resolver/' + realm.RESOLVER, {'type': 'ldapresolver', 'BINDPW': replacement})
|
||||
if type(value) is not int or value < 1: raise Refused()
|
||||
report['configuration_writes'] = 1
|
||||
report['phase'] = 'stored-bind-verification'
|
||||
after = existing(client.snapshot())
|
||||
if public_snapshot(after) != public_snapshot(initial): raise Refused()
|
||||
native_bind(client, after)
|
||||
if full:
|
||||
report['phase'] = 'exact-user-lookup'
|
||||
query = urllib.parse.urlencode({'realm': realm.REALM, 'username': target['user']})
|
||||
users = client.call('/user/?' + query)
|
||||
if (not isinstance(users, list) or len(users) != 1 or not isinstance(users[0], dict)
|
||||
or users[0].get('username') != target['user'] or users[0].get('resolver') != realm.RESOLVER):
|
||||
raise Refused()
|
||||
report['phase'] = 'token-backed-mfa'
|
||||
if mfa_proof().get('result') != 'PASS': raise Refused()
|
||||
report['mfa_verified'] = True
|
||||
report['phase'] = 'postflight'
|
||||
gate()
|
||||
if client.snapshot() != after: raise Refused()
|
||||
# An arbitrary wrong password can also be denied. Owner provenance is a
|
||||
# separate acceptance requirement; this receipt only describes supplied input.
|
||||
report.update(phase='complete', result=('verified' if predecessor is not None else 'incomplete') if full else ('configured' if mode == 'apply' else 'checked'))
|
||||
return report
|
||||
|
||||
|
||||
def arguments():
|
||||
parser = QuietParser(description=__doc__)
|
||||
parser.add_argument('helper', choices=('update', 'reconcile'))
|
||||
modes = parser.add_mutually_exclusive_group(required=True)
|
||||
for mode in ('preview', 'check', 'apply'): modes.add_argument('--' + mode, dest='mode', action='store_const', const=mode)
|
||||
parser.add_argument('--expected-digest')
|
||||
parser.add_argument('--pi-url', default='https://pink.coulomb.social')
|
||||
parser.add_argument('--lldap-url', default='https://lldap.coulomb.social')
|
||||
parser.add_argument('--keycape-url', default='https://kc.coulomb.social')
|
||||
parser.add_argument('--user', default='platform-root')
|
||||
parser.add_argument('--predecessor-unavailable', action='store_true')
|
||||
for name in ('kubeconfig', 'context', 'server', 'cluster-uid'): parser.add_argument('--' + name)
|
||||
args = parser.parse_args()
|
||||
args.pi_url = endpoint(args.pi_url)
|
||||
args.lldap_url = service_endpoint(args.lldap_url, 'lldap-auth')
|
||||
args.keycape_url = service_endpoint(args.keycape_url, 'keycape-discovery')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.user): raise Refused()
|
||||
if args.mode == 'apply':
|
||||
if not re.fullmatch(r'[0-9a-f]{64}', args.expected_digest or ''): raise Refused()
|
||||
elif args.expected_digest is not None: raise Refused()
|
||||
if args.helper == 'reconcile':
|
||||
if not args.kubeconfig or not Path(args.kubeconfig).is_absolute(): raise Refused()
|
||||
for value in (args.context, args.cluster_uid):
|
||||
if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,199}', value): raise Refused()
|
||||
parsed = urllib.parse.urlsplit(args.server or '')
|
||||
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password
|
||||
or parsed.query or parsed.fragment or parsed.path or any(ord(c) <= 32 for c in args.server)):
|
||||
raise Refused()
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty(): raise Refused()
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
report = {'result': 'unverified', 'phase': 'input', 'write_attempted': False}
|
||||
try:
|
||||
args = arguments()
|
||||
# Do not silently fall back to echoed stdin when terminal access fails.
|
||||
import warnings
|
||||
warnings.simplefilter('error', getpass.GetPassWarning)
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
target = {key: getattr(args, key) for key in ('helper', 'pi_url', 'lldap_url', 'keycape_url', 'user',
|
||||
'kubeconfig', 'context', 'server', 'cluster_uid')}
|
||||
gate = lambda: (cluster_ready(target), health(target))
|
||||
if args.helper == 'reconcile': gate()
|
||||
report['phase'] = 'privacyidea-authentication'
|
||||
admin = password(getpass.getpass('privacyIDEA pi-admin password: '))
|
||||
client = Client(args.pi_url, admin)
|
||||
replacement = predecessor = None
|
||||
if args.mode != 'preview':
|
||||
replacement = password(getpass.getpass('Approved replacement LLDAP password: '))
|
||||
if args.helper == 'reconcile' and not args.predecessor_unavailable:
|
||||
predecessor = password(getpass.getpass('Actual predecessor LLDAP password (denial proof only): '))
|
||||
proof = lambda: verify(args.pi_url, args.user, realm.REALM, realm.RESOLVER, admin,
|
||||
lambda: password(getpass.getpass('Fresh MFA code (include token PIN if required): ')))
|
||||
run(client, target, args.mode, replacement=replacement, predecessor=predecessor,
|
||||
expected_digest=args.expected_digest, mfa_proof=proof, gate=gate, report=report)
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 2 if report['result'] == 'incomplete' else 0
|
||||
except (Exception, KeyboardInterrupt):
|
||||
report['result'] = 'unverified'
|
||||
print(json.dumps(report, sort_keys=True), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("guard_deploy", Path(__file__).with_name("deploy-onboarding-guard.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class GuardDeploymentTest(unittest.TestCase):
|
||||
def test_preserves_custody_and_recovery_and_replays(self):
|
||||
config = {"data": {"pi.cfg": "EXISTING=True\n", "other": "unchanged"}}
|
||||
deployment = {"spec": {"template": {"spec": {
|
||||
"containers": [{"name": name, "image": "pinned", "envFrom": [{"secretRef": {"name": "existing"}}], "volumeMounts": [{"name": "data", "mountPath": "/etc/privacyidea"}]} for name in ["privacyidea", "factor-recovery", "unrelated"]],
|
||||
"volumes": [{"name": "data", "persistentVolumeClaim": {"claimName": "existing"}}],
|
||||
}}}}
|
||||
original = copy.deepcopy(deployment)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory); (source / "scripts").mkdir()
|
||||
(source / "scripts/keycape_onboarding_guard.py").write_text("def check(request, action): return True\n")
|
||||
cm, updated, planned = module.plan(source, config, deployment)
|
||||
self.assertEqual(deployment, original)
|
||||
self.assertEqual(updated, config["data"]["pi.cfg"] + module.SUFFIX)
|
||||
self.assertTrue(cm["immutable"])
|
||||
for before, after in zip(original["spec"]["template"]["spec"]["containers"], planned["containers"]):
|
||||
if before["name"] == "unrelated":
|
||||
self.assertEqual(before, after)
|
||||
else:
|
||||
self.assertEqual(before["envFrom"], after["envFrom"])
|
||||
self.assertEqual(before["image"], after["image"])
|
||||
self.assertEqual(after["env"], [{"name": "PYTHONPATH", "value": "/opt/keycape-onboarding"}])
|
||||
self.assertIn(before["volumeMounts"][0], after["volumeMounts"])
|
||||
replay = module.plan(source, {"data": {"pi.cfg": updated}}, {"spec": {"template": {"spec": planned}}})
|
||||
self.assertEqual(replay, (cm, updated, planned))
|
||||
with self.assertRaisesRegex(RuntimeError, "existing_hook_requires_review"):
|
||||
module.plan(source, {"data": {"pi.cfg": "PI_INIT_CHECK_HOOK='another.check'"}}, deployment)
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Attended inputs stay in Python memory; disable inherited tracing first.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/resolver_cutover.py" update "$@"
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"""Attended functional privacyIDEA proof for verify-t06.sh."""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
from pi_api import request
|
||||
|
||||
|
||||
def value(status, body):
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise ValueError("request failed")
|
||||
result = body.get("result")
|
||||
if not isinstance(result, dict) or result.get("status") is not True:
|
||||
raise ValueError("API operation failed")
|
||||
return result.get("value")
|
||||
|
||||
|
||||
def verify(url, user, realm, resolver, password, otp_prompt):
|
||||
phase = "authentication"
|
||||
try:
|
||||
auth = value(
|
||||
*request(url + "/auth", {"username": "pi-admin", "password": password})
|
||||
)
|
||||
token = auth.get("token") if isinstance(auth, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("admin token missing")
|
||||
phase = "realm-binding"
|
||||
realms = value(*request(url + "/realm/", token=token))
|
||||
bindings = realms[realm]["resolver"]
|
||||
if not any(
|
||||
isinstance(item, dict) and item.get("name") == resolver for item in bindings
|
||||
):
|
||||
raise ValueError("realm not bound to expected resolver")
|
||||
phase = "resolver-tuning"
|
||||
resolvers = value(
|
||||
*request(
|
||||
url + "/resolver/" + urllib.parse.quote(resolver, safe=""), token=token
|
||||
)
|
||||
)
|
||||
config = resolvers[resolver]
|
||||
if config.get("type") != "ldapresolver":
|
||||
raise ValueError("wrong resolver type")
|
||||
data = config["data"]
|
||||
for name in ("TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"):
|
||||
number = data.get(name)
|
||||
if (
|
||||
isinstance(number, bool)
|
||||
or not isinstance(number, (str, int))
|
||||
or not str(number).isascii()
|
||||
or not str(number).isdigit()
|
||||
):
|
||||
raise ValueError("missing or invalid numeric parameter")
|
||||
phase = "known-user-lookup"
|
||||
query = urllib.parse.urlencode({"realm": realm, "username": user})
|
||||
users = value(*request(url + "/user/?" + query, token=token))
|
||||
if not isinstance(users, list) or not any(
|
||||
isinstance(item, dict)
|
||||
and item.get("username") == user
|
||||
and item.get("resolver") == resolver
|
||||
for item in users
|
||||
):
|
||||
raise ValueError("known user did not resolve through expected resolver")
|
||||
phase = "mfa-validation"
|
||||
otp = otp_prompt()
|
||||
if not otp:
|
||||
raise ValueError("MFA input missing")
|
||||
status, body = request(
|
||||
url + "/validate/check", {"user": user, "realm": realm, "pass": otp}, token
|
||||
)
|
||||
# passthru can return value=true for token-less users. Require a token
|
||||
# serial and type in the successful validation, not password-only success.
|
||||
if value(status, body) is not True:
|
||||
raise ValueError("MFA denied")
|
||||
detail = body.get("detail", {})
|
||||
if not detail.get("serial") or detail.get("type") not in {"totp", "hotp"}:
|
||||
raise ValueError("no token-backed MFA proof")
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError, EOFError):
|
||||
return {"result": "FAIL", "phase": phase}
|
||||
return {
|
||||
"result": "PASS",
|
||||
"phase": "complete",
|
||||
"proofs": [
|
||||
"realm-binding",
|
||||
"resolver-tuning",
|
||||
"known-user-lookup",
|
||||
"token-backed-mfa",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pi-url", default="https://pink.coulomb.social")
|
||||
parser.add_argument("--user", required=True)
|
||||
parser.add_argument("--realm", default="coulomb")
|
||||
parser.add_argument("--resolver", default="lldap-coulomb")
|
||||
args = parser.parse_args(argv)
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
"T06 requires an attended terminal for protected password and fresh MFA input.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if urllib.parse.urlsplit(args.pi_url).scheme != "https":
|
||||
parser.error("--pi-url must use HTTPS")
|
||||
try:
|
||||
password = getpass.getpass("privacyIDEA pi-admin password: ")
|
||||
if not password:
|
||||
raise ValueError("empty password")
|
||||
report = verify(
|
||||
args.pi_url.rstrip("/"),
|
||||
args.user,
|
||||
args.realm,
|
||||
args.resolver,
|
||||
password,
|
||||
lambda: getpass.getpass("Fresh MFA code (include token PIN if required): "),
|
||||
)
|
||||
except (EOFError, KeyboardInterrupt, ValueError):
|
||||
report = {"result": "FAIL", "phase": "protected-input"}
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 0 if report["result"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
"""Admit the reviewed tree through bounded, fully scanned evidence-branch pushes.
|
||||
|
||||
Main and the canonical worktree stay unchanged. The remote gate remains enabled.
|
||||
After admission, the original reviewed commit needs only one new commit object.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path('/home/worsch/the-custodian')
|
||||
BASE = Path(__file__).resolve().parent
|
||||
WORK = Path('/tmp/custodian-rollout-admission-5596185e')
|
||||
BRANCH = 'evidence/receiver-guard-5596185e'
|
||||
REF = 'refs/heads/' + BRANCH
|
||||
SOURCE = '5596185e71db4bff092b75af4d647e7501d47ff9'
|
||||
PRIOR = 'f8c7dfa2a9c7919879b62c77eb39582b3e23654e'
|
||||
SCANNER = '/home/worsch/repo-manager/src/repo_manager/secret_scan.py'
|
||||
BINARY = '/home/worsch/.local/lib/repo-manager/gitleaks/8.30.1/gitleaks'
|
||||
HOOK = ROOT / '.git/hooks/pre-commit'
|
||||
LIMIT = 40
|
||||
|
||||
def run(path, *args, timeout=30):
|
||||
return subprocess.run(list(args), cwd=path, capture_output=True, timeout=timeout)
|
||||
|
||||
def git(path, *args):
|
||||
result = run(path, 'git', *args)
|
||||
assert result.returncode == 0, 'git-operation-refused: ' + args[0]
|
||||
return result.stdout
|
||||
|
||||
def sha(value):
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
def remote(ref):
|
||||
lines = git(ROOT, 'ls-remote', 'origin', ref).decode().splitlines()
|
||||
assert len(lines) <= 1
|
||||
return lines[0].split()[0] if lines else None
|
||||
|
||||
def save(value):
|
||||
(BASE / 'rollout-bounded-admission.json').write_text(json.dumps(value, indent=2) + '\n')
|
||||
|
||||
index = git(ROOT, 'ls-files', '--stage', '-z')
|
||||
hook_hash = sha(HOOK.read_bytes())
|
||||
scanner_hash = sha(Path(SCANNER).read_bytes())
|
||||
assert scanner_hash == 'e709c658f779534fcab093207b0a78e49d54fc584c515fdf7ac535ce75f6c400'
|
||||
backup = BASE / 'custodian-publication-backup'
|
||||
originals = json.loads((backup / 'originals.json').read_text())
|
||||
others = json.loads((backup / 'other-dirty-hashes.json').read_text())
|
||||
|
||||
def preserve():
|
||||
assert git(ROOT, 'rev-parse', 'HEAD').decode().strip() == SOURCE
|
||||
assert git(ROOT, 'ls-files', '--stage', '-z') == index
|
||||
assert sha(HOOK.read_bytes()) == hook_hash
|
||||
assert sha(Path(SCANNER).read_bytes()) == scanner_hash
|
||||
for name, entry in originals.items():
|
||||
assert (ROOT / name).read_bytes() == (backup / (name + '.worktree')).read_bytes()
|
||||
assert git(ROOT, 'ls-files', '--stage', '--', name).decode() == entry
|
||||
assert all(sha((ROOT / name).read_bytes()) == pin for name, pin in others.items())
|
||||
|
||||
preserve()
|
||||
assert remote('refs/heads/main') == PRIOR
|
||||
assert remote(REF) is None and not WORK.exists()
|
||||
assert run(ROOT, 'git', 'show-ref', '--verify', '--quiet', REF).returncode == 1
|
||||
source_tree = git(ROOT, 'rev-parse', SOURCE + '^{tree}').decode().strip()
|
||||
all_paths = git(ROOT, 'diff-tree', '--no-commit-id', '--name-only', '-r', PRIOR, SOURCE).decode().splitlines()
|
||||
last = ['.kaizen/agents/custodian-codex/supervision.json', 'docs/netkingdom-target-implementation.md',
|
||||
'workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md',
|
||||
str((BASE / 'rollout-publication-inputs.json').relative_to(ROOT))]
|
||||
remaining = [p for p in sorted(all_paths) if p not in last]
|
||||
ledger = {'schema': 'custodian.bounded-object-admission.v1', 'source_commit': SOURCE,
|
||||
'source_tree': source_tree, 'prior_main': PRIOR, 'evidence_ref': REF,
|
||||
'object_limit_per_push': LIMIT, 'original_hook_sha256': hook_hash,
|
||||
'canonical_index_sha256': sha(index), 'scanner_source_sha256': scanner_hash,
|
||||
'local_worktree_hook_allowlist_misses_worktree': True,
|
||||
'explicit_unchanged_staged_scanner_enforced_before_normal_hook_commit': True,
|
||||
'remote_scanner_or_configuration_changed': False, 'status': 'preparing', 'batches': []}
|
||||
save(ledger)
|
||||
git(ROOT, 'worktree', 'add', '-b', BRANCH, str(WORK), PRIOR)
|
||||
previous = PRIOR
|
||||
remote_previous = None
|
||||
known = set(git(ROOT, 'rev-list', '--objects', '--no-object-names', PRIOR).decode().splitlines())
|
||||
while remaining or last:
|
||||
chosen = remaining[:24] if remaining else last[:]
|
||||
if remaining and len(remaining) < 8:
|
||||
chosen += last
|
||||
git(WORK, 'restore', '--source=' + SOURCE, '--staged', '--worktree', '--', *chosen)
|
||||
while True:
|
||||
tree = git(WORK, 'write-tree').decode().strip()
|
||||
tree_objects = {tree}
|
||||
for line in git(WORK, 'ls-tree', '-r', '-t', tree).decode().splitlines():
|
||||
tree_objects.add(line.split('\t', 1)[0].split()[2])
|
||||
introduced_upper_bound = 1 + len(tree_objects - known)
|
||||
if introduced_upper_bound <= LIMIT:
|
||||
break
|
||||
assert len(chosen) > 1
|
||||
remove = chosen[len(chosen)//2:]
|
||||
chosen = chosen[:len(chosen)//2]
|
||||
git(WORK, 'restore', '--source=HEAD', '--staged', '--worktree', '--', *remove)
|
||||
batch = {'number': len(ledger['batches']) + 1, 'paths': chosen, 'parent': previous,
|
||||
'tree': tree, 'introduced_objects_upper_bound': introduced_upper_bound,
|
||||
'status': 'scanning'}
|
||||
ledger['batches'].append(batch)
|
||||
save(ledger)
|
||||
staged = git(WORK, 'ls-files', '--stage', '-z')
|
||||
assert sha(Path(SCANNER).read_bytes()) == scanner_hash
|
||||
scan = run(WORK, 'python3', SCANNER, '--path', str(WORK), '--binary', BINARY, timeout=135)
|
||||
assert sha(Path(SCANNER).read_bytes()) == scanner_hash
|
||||
batch['local_scan'] = json.loads(scan.stdout)
|
||||
save(ledger)
|
||||
assert scan.returncode == 0 and batch['local_scan']['status'] == 'clean'
|
||||
assert git(WORK, 'ls-files', '--stage', '-z') == staged
|
||||
commit = run(WORK, 'git', 'commit', '--only', '-m',
|
||||
'Admit reviewed receiver evidence batch ' + str(batch['number']), '--', *chosen, timeout=140)
|
||||
batch['commit_exit_code'] = commit.returncode
|
||||
assert commit.returncode == 0, 'normal-hook-commit-refused'
|
||||
head = git(WORK, 'rev-parse', 'HEAD').decode().strip()
|
||||
assert git(WORK, 'rev-parse', head + '^{tree}').decode().strip() == tree
|
||||
assert git(WORK, 'rev-parse', head + '^').decode().strip() == previous
|
||||
introduced = set(git(WORK, 'rev-list', '--objects', '--no-object-names', head, '^' + previous).decode().splitlines())
|
||||
assert len(introduced) <= LIMIT
|
||||
batch.update(commit=head, introduced_objects=len(introduced), status='pushing')
|
||||
save(ledger)
|
||||
preserve()
|
||||
assert remote('refs/heads/main') == PRIOR and remote(REF) == remote_previous
|
||||
started = time.monotonic()
|
||||
try:
|
||||
push = run(WORK, 'git', 'push', 'origin', 'HEAD:' + REF, timeout=115)
|
||||
except subprocess.TimeoutExpired:
|
||||
batch.update(status='push-outcome-unverified', remote_observed=remote(REF))
|
||||
save(ledger)
|
||||
raise
|
||||
reports = []
|
||||
for line in (push.stdout + b'\n' + push.stderr).decode(errors='replace').splitlines():
|
||||
if '{' not in line:
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line[line.index('{'):line.rindex('}') + 1])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if value.get('scope') == 'introduced-reachable-git-objects-and-updated-ref-names':
|
||||
reports.append(value)
|
||||
observed = remote(REF)
|
||||
batch.update(push_exit_code=push.returncode, remote_reports=reports,
|
||||
elapsed_seconds=round(time.monotonic()-started, 3), remote_observed=observed)
|
||||
clean = [r for r in reports if r.get('status') == 'clean']
|
||||
batch['status'] = 'admitted' if push.returncode == 0 and observed == head and len(clean) == 1 else 'refused-or-unverified'
|
||||
save(ledger)
|
||||
assert batch['status'] == 'admitted'
|
||||
assert clean[0]['scanned_refs'] == 1 and 0 < clean[0]['scanned_objects'] <= LIMIT
|
||||
print(json.dumps({'batch': batch['number'], 'status': 'admitted', 'scanned_objects': clean[0]['scanned_objects']}), flush=True)
|
||||
known.update(introduced)
|
||||
previous = remote_previous = head
|
||||
remaining = [p for p in remaining if p not in chosen]
|
||||
last = [p for p in last if p not in chosen]
|
||||
preserve()
|
||||
|
||||
assert git(WORK, 'rev-parse', 'HEAD^{tree}').decode().strip() == source_tree
|
||||
assert remote(REF) == previous and remote('refs/heads/main') == PRIOR
|
||||
final_objects = git(ROOT, 'rev-list', '--objects', '--no-object-names', SOURCE, '^' + PRIOR, '^' + previous).decode().splitlines()
|
||||
assert final_objects == [SOURCE]
|
||||
preserve()
|
||||
ledger.update(status='admitted-original-main-not-yet-pushed', evidence_tip=previous,
|
||||
final_original_commit_introduces_exactly=final_objects,
|
||||
main_unchanged=True, canonical_index_and_worktree_preserved=True,
|
||||
evidence_branch_retained_until_final_acceptance=True)
|
||||
save(ledger)
|
||||
print(json.dumps({'status': ledger['status'], 'batches': len(ledger['batches']), 'final_new_objects': len(final_objects)}))
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
{
|
||||
"repo_slug": "the-custodian",
|
||||
"repo_path": "/home/worsch/the-custodian",
|
||||
"source_revision": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"source_revision_reason": null,
|
||||
"issues": [
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['CUST-WP', 'THE-WP']",
|
||||
"file_path": "workplans/",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager contradictory: workplans/ADHOC-2026-08-25-retired.md: workplan id 'CUST-WP-ADHOC-2026-08-25-RETIRED' is not accepted by the canon kind registry",
|
||||
"file_path": "workplans/ADHOC-2026-08-25-retired.md",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-05",
|
||||
"message": "Title drift in 'cust-wp-0010': file='Domain and Repository Goals' db='Workplan Lifecycle Documentation' (file wins)",
|
||||
"file_path": "workplans/CUST-WP-0010b-domain-and-repo-goals.md",
|
||||
"db_id": "def8455a-9de4-4891-a15a-5b09ca98ab1e",
|
||||
"file_value": "Domain and Repository Goals",
|
||||
"db_value": "Workplan Lifecycle Documentation",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-20",
|
||||
"message": "Workplan dependency target 'STATE-WP-0092' is not linked to State Hub",
|
||||
"file_path": "workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md",
|
||||
"db_id": "",
|
||||
"file_value": "STATE-WP-0092",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-40",
|
||||
"message": "blocker satisfied, task still wait: 'CUST-WP-0073-T05' in workplan 'CUST-WP-0073' \u2014 decision 1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa is resolved",
|
||||
"file_path": "workplans/CUST-WP-0073-agent-credential-separation.md#CUST-WP-0073-T05",
|
||||
"db_id": "",
|
||||
"file_value": "1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-39",
|
||||
"message": "Human wait task 'CUST-WP-0079-T06' in workplan 'CUST-WP-0079' has needs_human: true without decision_id \u2014 unrouted human gate",
|
||||
"file_path": "workplans/CUST-WP-0079-deterministic-security-flow.md#CUST-WP-0079-T06",
|
||||
"db_id": "",
|
||||
"file_value": "needs_human: true",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-22",
|
||||
"message": "Task description drift 'CUST-WP-0080-T03': file content differs from DB (file wins)",
|
||||
"file_path": "workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md#CUST-WP-0080-T03",
|
||||
"db_id": "87637402-02f1-57c1-8fbc-4141598af8e3",
|
||||
"file_value": "Start with the reproduced DN-subject validation defect in existing KEY-WP-0040.\nReconcile G04/G07/G08/G10/G12/G13 agains",
|
||||
"db_value": "Start with the reproduced DN-subject validation defect in existing KEY-WP-0040.\nReconcile G04/G07/G08/G10/G12/G13 agains",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Closed DB workstream 'cust-wp-0010b' (id=416ff571\u2026, status=finished) has no backing workplan file",
|
||||
"file_path": "",
|
||||
"db_id": "416ff571-18cf-5cb2-9c8b-836f68a301b3",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'interactive-the-custodian@retired-20260828' (id=370c2481\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "370c2481-6806-41eb-a917-f8874f03184f",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'adhoc-2026-05-02@retired-20260827' (id=3f54ce9c\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "3f54ce9c-1f95-42de-894f-0f81a52ba2e8",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'the-custodian-adhoc-2026-07-02@retired-20260827' (id=5c53733b\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "5c53733b-c077-4a93-be66-9c3af8f9b6fd",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-26",
|
||||
"message": "Workplan id 'THE-WP-0001' uses prefix 'THE-WP' but repo canonical prefix is 'CUST-WP' \u2014 new plans must conform",
|
||||
"file_path": "workplans/THE-WP-0001-federation-interface.md",
|
||||
"db_id": "",
|
||||
"file_value": "THE-WP-0001",
|
||||
"db_value": "CUST-WP",
|
||||
"fixable": false
|
||||
}
|
||||
],
|
||||
"fixes_applied": [
|
||||
"bindings: synced 89/89",
|
||||
"C-17 cleared: pushed 2 backlogged commit(s)",
|
||||
"C-05 fixed: workstream def8455a\u2026 title \u2192 'Domain and Repository Goals'",
|
||||
"C-22 fixed: task 87637402\u2026 description updated",
|
||||
"push: pushed"
|
||||
],
|
||||
"summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 8,
|
||||
"info": 4
|
||||
},
|
||||
"result": "warn"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WARN: working-memory commit failed: Generated-only commits rejected: staged
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
{
|
||||
"status": "verified",
|
||||
"observed_at": "2026-10-17T22:43:40.018363+00:00",
|
||||
"checks": [
|
||||
{
|
||||
"owner": "the-custodian",
|
||||
"commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"projection": "primary/railiance01",
|
||||
"bindings": [
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T01",
|
||||
"uuid": "c770f7e1-25eb-53ec-9a37-1f7794077ff8",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T02",
|
||||
"uuid": "5973026f-8ca9-5882-99fe-65a54bc250ac",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T03",
|
||||
"uuid": "87637402-02f1-57c1-8fbc-4141598af8e3",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T04",
|
||||
"uuid": "82378b0f-7004-5c2f-9683-b356a1227576",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T05",
|
||||
"uuid": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "CUST-WP-0080-T06",
|
||||
"uuid": "6aca534c-5c7a-556f-b6b6-406433670c29",
|
||||
"status": "progress"
|
||||
}
|
||||
],
|
||||
"protected_worktree_and_index_preserved": true
|
||||
},
|
||||
{
|
||||
"owner": "net-kingdom",
|
||||
"commit": "b3f31d9f26b98a3b92690e0be34a205e49fa3956",
|
||||
"projection": "primary/railiance01",
|
||||
"bindings": [
|
||||
{
|
||||
"record_id": "NK-WP-0048-T01",
|
||||
"uuid": "930ac3e9-20ec-5473-98cd-3fe8fddb730f",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T02",
|
||||
"uuid": "19c25d96-a55c-58f0-9168-3fdbc4955fc2",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T03",
|
||||
"uuid": "e487975b-73b1-516c-971d-da4cbbb93063",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T04",
|
||||
"uuid": "2fffe112-7d55-5680-9952-78d74f44f87f",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T05",
|
||||
"uuid": "3d987d3d-0797-538b-86e4-51185a327ae2",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T06",
|
||||
"uuid": "d9b26bcf-a7c6-59a2-92a0-f913d76a203c",
|
||||
"status": "wait"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T07",
|
||||
"uuid": "c3c9c3ae-4f23-5ba7-9e6f-e63bcf29b9f7",
|
||||
"status": "wait"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T08",
|
||||
"uuid": "8e519082-dc8b-5e46-b660-03d03e55dcf6",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T09",
|
||||
"uuid": "0ee12dfc-8260-5038-bd39-cc7fb4113be0",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T10",
|
||||
"uuid": "4b06ca97-c1e4-5680-bf22-ab75ab1646a4",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T11",
|
||||
"uuid": "ef2a0119-f59e-5e45-9519-115da9df7b85",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T12",
|
||||
"uuid": "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T13",
|
||||
"uuid": "b264c10b-565c-5d29-b788-03cf5fbd2883",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T14",
|
||||
"uuid": "fce95440-a25b-5f27-8ef3-a3d182099113",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T15",
|
||||
"uuid": "1b825705-4848-5edc-832f-b1622cd9bdb7",
|
||||
"status": "wait"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T16",
|
||||
"uuid": "200bf500-5084-5ae7-be26-3033fe1f296e",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T17",
|
||||
"uuid": "8c3e4baa-a218-5996-af9e-fe1153a46dac",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T18",
|
||||
"uuid": "9c45da71-fb4c-5f20-9065-90f448f37c64",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T19",
|
||||
"uuid": "f50fdcd1-00be-5c3c-be0c-345aa2126032",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T20",
|
||||
"uuid": "863fcbdb-e246-51ba-87ca-a2f44d01d6f8",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T21",
|
||||
"uuid": "afe78e4d-408a-516a-ab92-e212e8a2d11d",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T22",
|
||||
"uuid": "ea50d93b-0d04-55d2-98c4-9b6c3fe925b1",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T23",
|
||||
"uuid": "6e7cd483-c7af-53a2-9d0a-3d76b6fb49ee",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T24",
|
||||
"uuid": "1a3931ee-3b5f-52fc-9295-4073804f4bd2",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T25",
|
||||
"uuid": "0de2a7d9-874d-578b-a076-f653fe42e30b",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T26",
|
||||
"uuid": "94b3cbf0-c908-5ac6-b0db-67385c3e021c",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T27",
|
||||
"uuid": "e56365f8-29b5-5c89-a329-8115f9f3b2a7",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T28",
|
||||
"uuid": "6dab281c-ea9a-5a17-a83f-e8e9a4d4484f",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T29",
|
||||
"uuid": "129db41d-4560-5e20-9f3b-c3c91e0a5601",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T30",
|
||||
"uuid": "6f6073ac-52dd-5b70-8874-6d76d4c072e4",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T31",
|
||||
"uuid": "ed1ccd87-89a0-5e69-885c-0eaf5ab0bb5a",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T32",
|
||||
"uuid": "c6d0a27e-f6a7-5df0-95f6-3e7473d38bb5",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T33",
|
||||
"uuid": "5ace28ff-a4cb-5647-a8f6-3cd54bb9d0a5",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T34",
|
||||
"uuid": "37f43c40-d014-544e-aa0b-5219dd4a6b93",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T35",
|
||||
"uuid": "cf1d6ab3-846e-53ea-8575-d6347b6f4769",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T36",
|
||||
"uuid": "7ba71abd-3c85-53ce-9837-7b0653ec709c",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T37",
|
||||
"uuid": "e493fb4b-52d2-54d4-a732-b42242855b3e",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T38",
|
||||
"uuid": "2c32b963-a87c-50a1-91e1-b07ea1fe4960",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T39",
|
||||
"uuid": "513c43ae-05f2-5a65-9c80-a911b7d680b8",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T40",
|
||||
"uuid": "7cc93671-fe94-5108-a486-1b0029e69487",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T41",
|
||||
"uuid": "6edf049f-2c52-5040-8d38-0bb1bc78fa31",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T42",
|
||||
"uuid": "3d1b9128-bade-5bd9-8660-22a14eea2d37",
|
||||
"status": "progress"
|
||||
},
|
||||
{
|
||||
"record_id": "NK-WP-0048-T43",
|
||||
"uuid": "ac77b046-1226-5e5a-a212-512cb4fdc484",
|
||||
"status": "progress"
|
||||
}
|
||||
],
|
||||
"protected_worktree_and_index_preserved": true
|
||||
}
|
||||
],
|
||||
"original_candidate_files_unchanged": 37,
|
||||
"native_gates_closed": 0,
|
||||
"review_decision": "changes-required"
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"id": "666bf368-31fd-4d68-8adf-2e65d4c449eb",
|
||||
"topic_id": null,
|
||||
"task_id": "87637402-02f1-57c1-8fbc-4141598af8e3",
|
||||
"decision_id": null,
|
||||
"event_type": "note",
|
||||
"summary": "Resumed interrupted application source work. Completed exact 17-file release-build evidence checkpoint 8951d324 and committed independent review/test evidence as 33a7815e. Both reconstructed Access Engine commands fail: standalone adapter contains empty signfixture Go source (33 verifier test/subtest results pass but full package setup fails); actual producer rejects Forgejo policy for five undeclared attribute reads and missing fixture/Rego tests (17 existing control test/subtest results pass, new profile test fails). Consumer callback test is empty and ongoing HTTP/API/Git/SSH enforcement remains absent. Changes-required recorded under existing CUST-WP-0080-T03, NK48-T43 and RAPPS16-T09; private input/transaction review remains T41/T42. All 37 original candidate files unchanged. Stale Git lock retained by rename; original failed commit attempt retained; normal hooks pass corrected commits. Ran required consistency and primary projection for Custodian and previously committed NetKingdom refinement b3f31d9f, restoring protected worktree/index state exactly. Verified 49 source/UUID/status bindings (6 coordinator + 43 NK), including newly projected T40 done and T41/T42/T43 progress. Consistency warnings retained; no native gate, credential/profile authority or deployment changed. Evidence: docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/review.json and final-verification.json.",
|
||||
"detail": null,
|
||||
"author": "codex",
|
||||
"session_id": null,
|
||||
"created_at": "2026-10-17T22:43:51.770018Z",
|
||||
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
{
|
||||
"ok": true,
|
||||
"status": "applied",
|
||||
"repo_slug": "the-custodian",
|
||||
"commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"requests": 2,
|
||||
"elapsed_ms": 9361.6,
|
||||
"git": {
|
||||
"upstream": "origin/main",
|
||||
"behind": 0,
|
||||
"ahead": 0
|
||||
},
|
||||
"identifiers": {
|
||||
"schema": "repo-manager.identifier-assignment.v1",
|
||||
"ok": true,
|
||||
"executed": true,
|
||||
"repo": "the-custodian",
|
||||
"namespace": "helixforge",
|
||||
"assignments": [],
|
||||
"historical_invalid_identifiers": [
|
||||
{
|
||||
"kind": "workplan",
|
||||
"id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"source": "workplans/ADHOC-2026-08-25-retired.md"
|
||||
}
|
||||
],
|
||||
"historical_identity_collisions": [],
|
||||
"files_changed": []
|
||||
},
|
||||
"receipt": {
|
||||
"schema": "state-hub.repository-projection-reconcile.v1",
|
||||
"instance_role": "primary",
|
||||
"instance_label": "railiance01",
|
||||
"expected_commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"derived_commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"outcome": {
|
||||
"schema": "state-hub.projection-reset.v1",
|
||||
"repo_slug": "the-custodian",
|
||||
"commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"status": "applied",
|
||||
"counts": {
|
||||
"created": 0,
|
||||
"updated": 88,
|
||||
"retired": 0,
|
||||
"refused": 0,
|
||||
"released": 0,
|
||||
"created_tasks": 0,
|
||||
"updated_tasks": 0,
|
||||
"cancelled_tasks": 0,
|
||||
"created_edges": 0,
|
||||
"removed_edges": 0,
|
||||
"unresolved_edges": 0,
|
||||
"external_edges": 13
|
||||
},
|
||||
"created": [],
|
||||
"updated": [
|
||||
"CUST-WP-0000",
|
||||
"CUST-WP-0000b",
|
||||
"CUST-WP-0000c",
|
||||
"CUST-WP-0001",
|
||||
"CUST-WP-0002",
|
||||
"CUST-WP-0004",
|
||||
"CUST-WP-0005",
|
||||
"CUST-WP-0006",
|
||||
"CUST-WP-0007",
|
||||
"CUST-WP-0008",
|
||||
"CUST-WP-0009",
|
||||
"CUST-WP-0010",
|
||||
"CUST-WP-0010b",
|
||||
"CUST-WP-0013",
|
||||
"CUST-WP-0014",
|
||||
"CUST-WP-0015",
|
||||
"CUST-WP-0016",
|
||||
"CUST-WP-0017",
|
||||
"CUST-WP-0017-LEGACY",
|
||||
"CUST-WP-0018",
|
||||
"CUST-WP-0019",
|
||||
"CUST-WP-0020",
|
||||
"CUST-WP-0021",
|
||||
"CUST-WP-0022",
|
||||
"CUST-WP-0023",
|
||||
"CUST-WP-0024",
|
||||
"CUST-WP-0025",
|
||||
"CUST-WP-0026",
|
||||
"CUST-WP-0027",
|
||||
"CUST-WP-0028",
|
||||
"CUST-WP-0029",
|
||||
"CUST-WP-0030",
|
||||
"CUST-WP-0031",
|
||||
"CUST-WP-0032",
|
||||
"CUST-WP-0033",
|
||||
"CUST-WP-0034",
|
||||
"CUST-WP-0035",
|
||||
"CUST-WP-0036",
|
||||
"CUST-WP-0037",
|
||||
"CUST-WP-0039",
|
||||
"CUST-WP-0040",
|
||||
"CUST-WP-0041",
|
||||
"CUST-WP-0043",
|
||||
"CUST-WP-0044",
|
||||
"CUST-WP-0045",
|
||||
"CUST-WP-0046",
|
||||
"CUST-WP-0047",
|
||||
"CUST-WP-0048",
|
||||
"CUST-WP-0049",
|
||||
"CUST-WP-0050",
|
||||
"CUST-WP-0051",
|
||||
"CUST-WP-0052",
|
||||
"CUST-WP-0053",
|
||||
"CUST-WP-0053-LEGACY",
|
||||
"CUST-WP-0054",
|
||||
"CUST-WP-0055",
|
||||
"CUST-WP-0056",
|
||||
"CUST-WP-0057",
|
||||
"CUST-WP-0058",
|
||||
"CUST-WP-0059",
|
||||
"CUST-WP-0060",
|
||||
"CUST-WP-0061",
|
||||
"CUST-WP-0062",
|
||||
"CUST-WP-0063",
|
||||
"CUST-WP-0064",
|
||||
"CUST-WP-0065",
|
||||
"CUST-WP-0066",
|
||||
"CUST-WP-0067",
|
||||
"CUST-WP-0068",
|
||||
"CUST-WP-0069",
|
||||
"CUST-WP-0070",
|
||||
"CUST-WP-0071",
|
||||
"CUST-WP-0072",
|
||||
"CUST-WP-0073",
|
||||
"CUST-WP-0074",
|
||||
"CUST-WP-0075",
|
||||
"CUST-WP-0076",
|
||||
"CUST-WP-0077",
|
||||
"CUST-WP-0078",
|
||||
"CUST-WP-0079",
|
||||
"CUST-WP-0080",
|
||||
"CUST-WP-0081",
|
||||
"CUST-WP-ADHOC-2026-03-29",
|
||||
"CUST-WP-ADHOC-2026-05-02",
|
||||
"CUST-WP-ADHOC-2026-07-02",
|
||||
"CUST-WP-ADHOC-2026-08-25",
|
||||
"CUST-WP-ADHOC-2026-09-06",
|
||||
"THE-WP-0001"
|
||||
],
|
||||
"retired": [],
|
||||
"released": [],
|
||||
"created_tasks": [],
|
||||
"updated_tasks": [],
|
||||
"cancelled_tasks": [],
|
||||
"created_edges": [],
|
||||
"removed_edges": [],
|
||||
"unresolved_edges": [],
|
||||
"external_edges": [
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T05",
|
||||
"target": "CUST-WP-0071-T04",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "20ddd4d7-d85d-5b95-bb0a-7b0ca8af76ca",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T03",
|
||||
"target": "CUST-WP-0071-T01",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T03",
|
||||
"target": "CUST-WP-0071-T02",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T02",
|
||||
"target": "CUST-WP-0071-T01",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T02",
|
||||
"target": "RAPPS-WP-0014-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T04",
|
||||
"target": "CUST-WP-0071-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0073",
|
||||
"from": "CUST-WP-0073-T02",
|
||||
"target": "GLAS-WP-0012",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
|
||||
"from_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35",
|
||||
"to_workplan_id": "170bf1ae-337f-5553-8d1e-03b07100e08f",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0073",
|
||||
"from": "CUST-WP-0073-T04",
|
||||
"target": "CUST-WP-0073-T02",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
|
||||
"from_task_id": "90725511-4e31-549f-b567-47feff1a9ca4",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0079",
|
||||
"from": "CUST-WP-0079-T03",
|
||||
"target": "ACTIVITY-WP-0047",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
|
||||
"from_task_id": "1be0569c-221d-5925-a0ff-fd2af88e52b1",
|
||||
"to_workplan_id": "ee97869b-dca2-54c9-aa18-0b479c244fb3",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0079",
|
||||
"from": "CUST-WP-0079-T04",
|
||||
"target": "PNEX-WP-0006",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
|
||||
"from_task_id": "5dd52156-09a8-5781-b288-99ae822f59a6",
|
||||
"to_workplan_id": "774c5037-a3d0-5e65-893c-d18970acd66a",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T04",
|
||||
"target": "CUST-WP-0080-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "82378b0f-7004-5c2f-9683-b356a1227576",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "87637402-02f1-57c1-8fbc-4141598af8e3"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T05",
|
||||
"target": "RPF-WP-0056",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
|
||||
"to_workplan_id": "31933d08-c877-5fda-aff9-6220c3705c92",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T05",
|
||||
"target": "RAPP-PLAYWRIGHT-WP-0004",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
|
||||
"to_workplan_id": "f9280c40-201e-59b5-a6f0-1aace8e0e2e2",
|
||||
"to_task_id": null
|
||||
}
|
||||
],
|
||||
"refused": [],
|
||||
"notes": [
|
||||
"Preserved bound historical row and task UUIDs: CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"returncode": 0,
|
||||
"commit": "33a7815e7bd8480dc7cf7b0167058f659cf3ea6f",
|
||||
"paths": [
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/adapter-result.json",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/adapter-test.jsonl",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/checkpoint-commit-attempt2.json",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/checkpoint-commit.json",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/finish_review.py",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/git-lock-recovery.json",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/producer-result.json",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/producer-test.jsonl",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/reproduce.py",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/resume_checkpoint.py",
|
||||
"docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/review.json",
|
||||
"docs/netkingdom-target-implementation.md",
|
||||
"workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md"
|
||||
],
|
||||
"stdout": "[main 33a7815] Record reproduced application policy and source package review failures\n 13 files changed, 1140 insertions(+)\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/adapter-result.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/adapter-test.jsonl\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/checkpoint-commit-attempt2.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/checkpoint-commit.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/finish_review.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/git-lock-recovery.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/producer-result.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/producer-test.jsonl\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/reproduce.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/resume_checkpoint.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/application-resume-review-r1/review.json\n",
|
||||
"stderr": "{\"blocked_blob_ids\": [], \"scanned_blobs\": 13, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"clean\"}\n"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"original_outcome": "Local preparation stopped at incorrect18file count assertion; no staging/commit/owner changes.",
|
||||
"refinement": "Correct17explicitfiles; same selection verified below260000bytes.",
|
||||
"bytes": 141260,
|
||||
"native_actions": false
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — the-custodian
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-10-15 23:45 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### NetKingdom target security architecture and SEC-DSM implementation
|
||||
Progress: 2/6 done | workplan_id: `dfadb19b-445e-59c6-bcd6-48e87b912c52`
|
||||
|
||||
**Open tasks:**
|
||||
- ► Close source defects and reconcile uncovered lifecycle gaps `87637402`
|
||||
- ► Coordinate self-service Playwright identity delivery `ed8b909a`
|
||||
- ► Establish the target architecture and drive the restructuring `6aca534c`
|
||||
- · Verify native closure against every requirement `82378b0f`
|
||||
|
||||
### Separate agent credentials and establish supervised privileged execution
|
||||
Progress: 2/5 done | workplan_id: `a98a9f34-83b4-5c8c-8107-e05f7806d50d`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Build and hand out the agent identity `4b88b0b7`
|
||||
*(wait: Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-enablement k3s config, railiance-infra principal mapping and ops-warden certificate issuance.)*
|
||||
- ! Fleet guidance and harness guards `90725511`
|
||||
*(wait: Await CUST-WP-0073-T02: the verified agent identity and execution path must exist before it can be documented; Codex/Grok read-denial guard needs glas-harness or harness-owner delivery.)*
|
||||
- ! Rotate what was exposed `1b41b532`
|
||||
*(wait: Deferred by founder decision 2026-09-24; reopened only by the founder on one of the three triggers below, in one attended rotation window.)*
|
||||
|
||||
### Size Railiance workloads from actual demand and establish a weekly allocation review
|
||||
Progress: 1/5 done | workplan_id: `2249bddb-7524-5add-bd5c-c4163a6ca0f3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Measure the Vergabe prototype under representative pilot use `82192370`
|
||||
*(wait: Await RAPPS-WP-0014-T03 (blocked, needs_human): populated two-user/document workflow run by Bernd and the company contact, giving representative load for p95 <= 2s / zero failures.)*
|
||||
- ! Review ecosystem allocations and reserve operating room `6dc67558`
|
||||
*(wait: Await CUST-WP-0071-T02 representative sample; Forgejo/runner demand and zero-request workload review need owner input (railiance-cluster, railiance-forge, resource-control).)*
|
||||
- ! Apply accepted sizing changes and verify their effects `944f98e2`
|
||||
*(wait: Await reviewed allocation proposals and applicable workload-owner authority.)*
|
||||
- ! Establish the regular weekly workload-demand and allocation assessment `20ddd4d7`
|
||||
*(wait: Await the repeatable observation and accepted sizing/review procedure.)*
|
||||
|
||||
### Deterministic security flow: standing authorizations, flow report, and agent re-engagement
|
||||
Progress: 3/7 done | workplan_id: `ab288f92-d364-5c24-9362-aee451f266c3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Schedule the flow report and decision sync `1be0569c`
|
||||
*(wait: Activity Core accepted the schedule in ACTIVITY-WP-0047. Await claiming rein workstation/hub acceptance, owner activation and the first unassisted fire with report/checkpoint evidence.)*
|
||||
- ! Encode the standing change rule as a policy-nexus guardrail `5dd52156`
|
||||
*(wait: policy-nexus owns guardrail publication; handoff written as PNEX-WP-0006.)*
|
||||
- ! Stop agent-authored index and sync commits `8a3dc219`
|
||||
*(wait: Local guard and repeatable shared-hook installation are implemented. Fleet generator adoption and the required full week of clean history remain unverified; repo-manager owner acceptance is needed.)*
|
||||
- ! Attended window #1 `e3da807f`
|
||||
*(wait: Partly done 2026-10-11: list cut from 14 to 3; U05-U07 accepted, U08 waits on KEY-WP-0040-T01. Remaining founder acts: USER-WP-0026-T03 readback, RPF-WP-0044-T08 coulombcore check, the U08 re-run, and Sol's added RPF-WP-0044-T04 issuer login.)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
# Work Records — the-custodian
|
||||
|
||||
> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record
|
||||
> stage 3). Do not edit by hand — edit the source file/block listed for
|
||||
> each record and re-run fix-consistency to refresh this index. Archived
|
||||
> workplans are omitted; closed decisions/intakes/engagements stay listed
|
||||
> so recently-resolved work is still visible. [auto]
|
||||
|
||||
| Kind | ID | Status | Lane | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| workplan | CUST-WP-ADHOC-2026-05-02 | done | — | workplans/ADHOC-2026-05-02.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-07-02 | finished | — | workplans/ADHOC-2026-07-02.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-08-25-RETIRED | finished | — | workplans/ADHOC-2026-08-25-retired.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-08-25 | finished | — | workplans/ADHOC-2026-08-25.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-09-06 | finished | — | workplans/ADHOC-2026-09-06.md |
|
||||
| workplan | CUST-WP-0000 | finished | — | workplans/CUST-WP-0000-state-hub-v0.1-build-deploy.md |
|
||||
| workplan | CUST-WP-0000b | finished | — | workplans/CUST-WP-0000b-state-hub-v0.2-decisions-dependencies.md |
|
||||
| workplan | CUST-WP-0001 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| workplan | CUST-WP-0002 | finished | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| workplan | CUST-WP-0004 | finished | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| workplan | CUST-WP-0005 | finished | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| workplan | CUST-WP-0006 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| workplan | CUST-WP-0007 | finished | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| workplan | CUST-WP-0008 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| workplan | CUST-WP-0009 | finished | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| workplan | CUST-WP-0010 | finished | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| workplan | CUST-WP-0010b | finished | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| workplan | CUST-WP-0013 | finished | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| workplan | CUST-WP-0015 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| workplan | CUST-WP-0016 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| workplan | CUST-WP-0017 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| workplan | CUST-WP-0018 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| workplan | CUST-WP-0019 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| workplan | CUST-WP-0020 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| workplan | CUST-WP-0021 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| workplan | CUST-WP-0022 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| workplan | CUST-WP-0023 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| workplan | CUST-WP-0024 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| workplan | CUST-WP-0026 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| workplan | CUST-WP-0027 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| workplan | CUST-WP-0029 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| workplan | CUST-WP-0030 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| workplan | CUST-WP-0031 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| workplan | CUST-WP-0032 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| workplan | CUST-WP-0033 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| workplan | CUST-WP-0039 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| workplan | CUST-WP-0040 | finished | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| workplan | CUST-WP-0041 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| workplan | CUST-WP-0043 | finished | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| workplan | CUST-WP-0044 | finished | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| workplan | CUST-WP-0045 | finished | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| workplan | CUST-WP-0069 | finished | — | workplans/CUST-WP-0045-cutover-runbook.md |
|
||||
| workplan | CUST-WP-0046 | finished | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| workplan | CUST-WP-0047 | finished | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| workplan | CUST-WP-0048 | finished | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| workplan | CUST-WP-0049 | finished | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| workplan | CUST-WP-0050 | finished | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| workplan | CUST-WP-0051 | finished | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| workplan | CUST-WP-0052 | finished | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| workplan | CUST-WP-0053 | finished | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| workplan | CUST-WP-0055 | finished | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| workplan | CUST-WP-0056 | finished | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| workplan | CUST-WP-0059 | finished | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| workplan | CUST-WP-0060 | finished | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| workplan | CUST-WP-0061 | finished | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| workplan | CUST-WP-0062 | finished | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| workplan | CUST-WP-0063 | finished | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| workplan | CUST-WP-0064 | finished | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| workplan | CUST-WP-0065 | finished | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| workplan | CUST-WP-0066 | finished | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| workplan | CUST-WP-0067 | finished | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| workplan | CUST-WP-0068 | finished | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| workplan | CUST-WP-0070 | finished | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| workplan | CUST-WP-0071 | blocked | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| workplan | CUST-WP-0072 | finished | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| workplan | CUST-WP-0073 | blocked | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| workplan | CUST-WP-0074 | finished | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| workplan | CUST-WP-0075 | finished | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| workplan | CUST-WP-0076 | finished | — | workplans/CUST-WP-0076-provisioning-window-cadence.md |
|
||||
| workplan | CUST-WP-0077 | finished | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| workplan | CUST-WP-0078 | finished | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| workplan | CUST-WP-0079 | blocked | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| workplan | CUST-WP-0080 | active | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| workplan | CUST-WP-0081 | finished | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| workplan | THE-WP-0001 | finished | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| task | CUST-WP-ADHOC-2026-05-02-T01 | done | — | workplans/ADHOC-2026-05-02.md |
|
||||
| task | CUST-WP-ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md |
|
||||
| task | ADHOC-2026-08-25-T01 | done | — | workplans/ADHOC-2026-08-25-retired.md |
|
||||
| task | CUST-WP-ADHOC-2026-08-25-T01 | done | — | workplans/ADHOC-2026-08-25.md |
|
||||
| task | CUST-WP-ADHOC-2026-09-06-T01 | done | — | workplans/ADHOC-2026-09-06.md |
|
||||
| task | CUST-WP-0001-T01 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| task | CUST-WP-0001-T02 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| task | CUST-WP-0002-T01 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T02 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T03 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T04 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T05 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T06 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T07 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T08 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T09 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T10 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T11 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T12 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T13 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T14 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T15 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0004-T01 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T02 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T03 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T04 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T05 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T06 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T07 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T08 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T09 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T10 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0005-T01 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T02 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T03 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T04 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T05 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T06 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T07 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T08 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T09 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T10 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T11 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T08 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T09 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T10 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T11 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T12 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T13 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T14 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T15 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T16 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T17 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | CUST-WP-0008-T01 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T02 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T03 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T04 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0009-T1 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0009-T2 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0009-T3 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0010-T1 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010-T2 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010-T3 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010b-T01 | done | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| task | CUST-WP-0010b-T02 | done | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| task | CUST-WP-0013-T01 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T02 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T03 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T04 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T05 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T06 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T07 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T08 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T09 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T10 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T11 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T12 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T13 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T14 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0015-T01 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T02 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T03 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T04 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T05 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0016-T01 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T02 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T03 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T04 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T05 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0017-T01 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T02 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T03 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T04 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T05 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T06 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0018-T01 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T02 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T03 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T04 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T05 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T06 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T07 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0019-T01 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0019-T02 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0019-T03 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0020-T01 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T02 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T03 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T04 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T05 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0021-T01 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T02 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T03 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T04 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T05 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0022-T01 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T02 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T03 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T04 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T05 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T06 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T07 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T08 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T09 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T10 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0023-T01 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T02 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T03 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T04 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T05 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T06 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T07 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T08 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T09 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T10 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0024-T01 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T02 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T03 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T04 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T05 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0026-T01 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T02 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T03 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T04 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T05 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0027-T01 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T02 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T03 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T04 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T05 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0029-T01 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T02 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T03 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T04 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T05 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T06 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T07 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T08 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T08 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T09 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | CUST-WP-0032-T01 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T02 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T03 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T04 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T05 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T06 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T07 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T08 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T09 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T10 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T11 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T12 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T13 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T14 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T15 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0033-T01 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T02 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T03 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T04 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T05 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T06 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0039-T1 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T2 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T3 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T4 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T5 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T6 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | CUST-WP-0041-T1 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T2 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T3 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T4 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0043-T01 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T02 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T03 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T04 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T05 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T06 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T07 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T08 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T09 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0044-T01 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T02 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T03 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T04 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T05 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T06 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0045-T01 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T02 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T03 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T04 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T05 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T06 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T07 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T08 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0046-T01 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T02 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T03 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T04 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T05 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T06 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T07 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0047-T01 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T02 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T03 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T04 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T05 | cancel | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T06 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T07 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0048-T01 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T02 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T03 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T04 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T05 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T06 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T07 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T08 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0049-T01 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T02 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T03 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T04 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T05 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T06 | cancel | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0050-T01 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T02 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T03 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T04 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T05 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T06 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T07 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T08 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T09 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T10 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T11 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0051-T01 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T02 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T03 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T04 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T05 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T06 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T07 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T08 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0052-T01 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T02 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T03 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T04 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T05 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T06 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T07 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0053-T01 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T02 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T03 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T04 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T05 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0055-T01 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T02 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T03 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T04 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T05 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T06 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T07 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T08 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0056-T01 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0056-T02 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0056-T03 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0059-T01 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T02 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T03 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T04 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T05 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T06 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0060-T01 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T02 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T03 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T04 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T05 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T06 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T07 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0061-T01 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T02 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T03 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T04 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T05 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T06 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0062-T01 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T02 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T03 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T04 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T05 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T06 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0063-T01 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T02 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T03 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T04 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T05 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0064-T01 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T02 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T03 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T04 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0065-T01 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T02 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T03 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T04 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0066-T01 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0066-T02 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0066-T03 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0067-T01 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T02 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T03 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T04 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T05 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T06 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T07 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T08 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T09 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T10 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0068-T01 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T02 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T03 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T04 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T05 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T06 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T07 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T08 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T09 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0070-T01 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0070-T02 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0070-T03 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0071-T01 | done | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T02 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T03 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T04 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T05 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0072-T01 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T02 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T03 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T04 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0073-T01 | done | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T02 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T03 | done | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T04 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T05 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0074-T01 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T02 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T03 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T04 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T05 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T06 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0075-T01 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T02 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T03 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T04 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T05 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T06 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0076-T01 | done | — | workplans/CUST-WP-0076-provisioning-window-cadence.md |
|
||||
| task | CUST-WP-0077-T01 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T02 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T03 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T04 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T05 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0078-T01 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T02 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T03 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T04 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0079-T01 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T02 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T03 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T04 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T05 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T06 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T07 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0080-T01 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T02 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T03 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T04 | todo | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T05 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T06 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0081-T01 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | CUST-WP-0081-T02 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | CUST-WP-0081-T03 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | THE-WP-0001-T01 | done | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| task | THE-WP-0001-T02 | done | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| intake | CUST-IN-0001 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0002 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0003 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0004 | routed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0005 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0006 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0007 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0008 | closed | blue | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0009 | closed | blue | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0010 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0011 | routed | red | intakes.md |
|
||||
| intake | CUST-IN-0012 | closed | green | intakes.md |
|
||||
| intake | CUST-IN-0013 | closed | blue | intakes.md |
|
||||
| intake | CUST-IN-0014 | closed | blue | intakes.md |
|
||||
| intake | CUST-IN-0015 | open | blue | intakes.md |
|
||||
| intake | CUST-IN-0016 | open | green | intakes.md |
|
||||
| intake | CUST-IN-0017 | open | green | intakes.md |
|
||||
| intake | CUST-IN-0018 | open | green | intakes.md |
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
{
|
||||
"path": ".custodian-brief.md",
|
||||
"index": "100644 6aad3a5788fad3abd60c0f1270f160062dc0168b 0\t.custodian-brief.md"
|
||||
},
|
||||
{
|
||||
"path": "WORK-RECORDS.md",
|
||||
"index": "100644 d535e0f2dcc3d454e041a8f1b547b5dea4493380 0\tWORK-RECORDS.md"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"owner": "the-custodian",
|
||||
"base_revision": "16236ef479135df4361686aa69e9eeb987f33129",
|
||||
"message": "Record reviewed application build source and remaining authenticated operation work",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/netkingdom-target-implementation.md",
|
||||
"sha256": "aa128260f54e12339f3651f4ed8c06b546d822657d01813fbe7aeb7033e5a254"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/README.md",
|
||||
"sha256": "3d7f003cc537e5bc8246d3b791fda327a3f52b6cfb17fa98a08ebec049bf0a9a"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/source-inventory.json",
|
||||
"sha256": "1f355db62adda2b4613023d2435962474df2bddd31e04ff5edf5a5e4c747d415"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/validation.json",
|
||||
"sha256": "011359d4a5ef22a0f4bb0c19def51ab5556f1e74d928110b65682f207b4157f8"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/binary-reproduction.json",
|
||||
"sha256": "31a5bf44d45965659efb0f9bbdc413b809e1078df5ee610f4925acc18d0de20f"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-reproduction.json",
|
||||
"sha256": "0cdafd8d1e78b83ea6378ebe79a620aeed1a83d49b4b5efc0ea1393ca09c9bfa"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/source-refinement.json",
|
||||
"sha256": "1e96b866284908299b6363c0ddd1363181bd8513ae63eb178967f636de008972"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/review.json",
|
||||
"sha256": "df2c23df953e1d4c10bd3abd7aea350cc33d8bbfdb2a99d92a436feba0946f73"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/proc-boundary-result.json",
|
||||
"sha256": "d8c7d4ec492dcfbd4bb1584dff74da9c05ec13a730fb58b09138f33d4db00b94"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/proc-boundary-test.py",
|
||||
"sha256": "45eb2d6eab745ad4e7bfd5a1fc0b33e0757cab20f314b45aaeca15afe76b8703"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r2/review.json",
|
||||
"sha256": "6f016d53a7ec9ece2f9da9686e76127c52d17066f2b3d371628832ac9537ac5c"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r2/results.json",
|
||||
"sha256": "232827ebb83148b32c67d81b943011f955c0a279604b6c7f764737d22e08c1e5"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-source-integration-r1/source-application.json",
|
||||
"sha256": "14dab3d30f6f89234252bb9231a2fc6fbb3d2418e9ad21a8f565481f0cda86c5"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-source-integration-r1/verified-task-bindings.json",
|
||||
"sha256": "a93a223310a9141c81af7d420061d0f90c7b383c592b33b3149e0b1b236407bf"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-source-integration-r1/identifier-assignment.json",
|
||||
"sha256": "11ee237a166d021b40b213b3071de9f4b880b6fa3ed03226d377aad37cc94b93"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-source-integration-r1/workplan.md",
|
||||
"sha256": "8981cd54aaf858ac35189fb1229c180930428e67eccc68b698d398ca1a18837f"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/apps-release-source-integration-r1/apply_source.py",
|
||||
"sha256": "b1206011c6199d948696830c3a4911077336b1929e11f0e8fad217d9cc4699d8"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"""Apply coordinator-reviewed exact patches; never deploy or enable registrar."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('lane', choices=['receiver', 'sops-record', 'postgres', 'receiver-readiness'])
|
||||
args = parser.parse_args()
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
if args.lane == 'receiver':
|
||||
packet = BASE.parent / 'netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r2'
|
||||
frozen = json.loads((packet / 'review-freeze.json').read_text())
|
||||
assert sha(packet / 'review-freeze.json') == 'eac2671eb3ac08e2a1eacc333482fc2638ccb895041781b04e8733b4b7d5a0f8'
|
||||
for item in frozen['files']:
|
||||
assert sha(packet / item['path']) == item['sha256'].removeprefix('sha256:')
|
||||
expected = json.loads((packet / 'source-patch-manifest.json').read_text())['repos']['state-hub']
|
||||
root = Path('/home/worsch/state-hub')
|
||||
patch = packet / 'state-hub.patch'
|
||||
revision = expected['base_revision']
|
||||
pin = expected['patch_sha256']
|
||||
pre = {x['path']: x['base_sha256'] for x in expected['files']}
|
||||
post = {x['path']: x['candidate_sha256'] for x in expected['files']}
|
||||
elif args.lane == 'sops-record':
|
||||
packet = BASE / 'wp49-sops-successor-r1'
|
||||
expected = json.loads((packet / 'manifest.json').read_text())
|
||||
root = Path('/home/worsch/net-kingdom')
|
||||
patch = packet / 'owner-refinement.patch'
|
||||
revision = expected['owner_head']
|
||||
pin = '5364b1966334869c71a58ff2d90194db45c7ea6f3e60d57327a231f558437641'
|
||||
pre = {expected['path']: expected['preimage_sha256']}
|
||||
post = {expected['path']: expected['result_sha256']}
|
||||
elif args.lane == 'postgres':
|
||||
packet = BASE.parent / 'netkingdom-target-2026-10-16/group-postgres-r1'
|
||||
expected = json.loads((packet / 'source-manifest.json').read_text())
|
||||
root = Path('/home/worsch/net-kingdom')
|
||||
patch = packet / 'source.patch'
|
||||
revision = expected['base_revision']
|
||||
pin = '5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d'
|
||||
pre = {x['path']: x['preimage_sha256'] for x in expected['files']}
|
||||
post = {x['path']: x['postimage_sha256'] for x in expected['files']}
|
||||
else:
|
||||
packet = BASE.parent / 'netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness'
|
||||
assert sha(packet / 'review-freeze.json') == 'ab5c12f71a75101184ae9e722d3c4f3145aad37d3c868879a26fa384de099483'
|
||||
frozen = json.loads((packet / 'review-freeze.json').read_text())
|
||||
for item in frozen['files']:
|
||||
assert sha(packet / item['path']) == item['sha256'].removeprefix('sha256:')
|
||||
expected = json.loads((packet / 'source-patch-manifest.json').read_text())
|
||||
root = Path('/home/worsch/state-hub')
|
||||
patch = packet / 'state-hub-readiness.patch'
|
||||
revision = expected['base_revision']
|
||||
pin = '4c52fd5a102df43ceb8e007d93d34efe861a3b321527cf5f3e195e35113c36a0'
|
||||
pre = {x['path']: x['base_sha256'] for x in expected['files']}
|
||||
post = {x['path']: x['candidate_sha256'] for x in expected['files']}
|
||||
|
||||
def git(*argv):
|
||||
return subprocess.check_output(['git', '-C', str(root), *argv], stderr=subprocess.PIPE)
|
||||
|
||||
assert git('rev-parse', 'HEAD').decode().strip() == revision
|
||||
assert sha(patch) == pin
|
||||
paths = [line[6:] for line in patch.read_text().splitlines() if line.startswith('+++ b/')]
|
||||
assert len(paths) == len(set(paths)) and set(paths) == set(post)
|
||||
index = git('ls-files', '--stage', '-z')
|
||||
for name in paths:
|
||||
assert not Path(name).is_absolute() and '..' not in Path(name).parts
|
||||
assert not git('status', '--porcelain', '--', name).strip()
|
||||
assert (sha(root / name) if (root / name).exists() else None) == pre[name]
|
||||
git('apply', '--check', str(patch))
|
||||
git('apply', str(patch))
|
||||
if args.lane == 'postgres':
|
||||
wp = packet / 'final-integration-r1/workplan.patch'
|
||||
assert sha(wp) == 'af9f720fba1b931679f866a29e8b3bc7d08f24a7f653db48daf485e35cb16eea'
|
||||
name = 'workplans/NK-WP-0048-security-case-types.md'
|
||||
assert not git('status', '--porcelain', '--', name).strip()
|
||||
assert sha(root / name) == '08215f6dc91a89706bf456a897d77de4032d47a21cd0252d202249619def135d'
|
||||
git('apply', '--check', str(wp))
|
||||
git('apply', str(wp))
|
||||
post[name] = sha(packet / 'final-integration-r1/workplan.postimage.md')
|
||||
assert git('ls-files', '--stage', '-z') == index
|
||||
for name, digest in post.items():
|
||||
assert sha(root / name) == digest
|
||||
receipt = {'owner': root.name, 'base_revision': revision, 'patch_sha256': pin,
|
||||
'files': post, 'index_unchanged': True, 'production_mutation': False,
|
||||
'registrar_enabled': False, 'status': 'applied_source_only'}
|
||||
(BASE / (args.lane + '-followthrough-integration.json')).write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print(json.dumps({'owner': root.name, 'status': receipt['status'], 'files': len(post)}))
|
||||
35
docs/changes/netkingdom-target-2026-10-17/apply_sources.py
Normal file
35
docs/changes/netkingdom-target-2026-10-17/apply_sources.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Apply reviewed owner sources only; unrelated work and index stay intact."""
|
||||
from pathlib import Path
|
||||
import argparse,hashlib,json,subprocess
|
||||
base=Path(__file__).resolve().parent
|
||||
handoffs=base.parent/'netkingdom-target-2026-10-16/recovery-owner-handoffs-r1'
|
||||
p=argparse.ArgumentParser();p.add_argument('lane',choices=['cluster','secrets','sops']);args=p.parse_args()
|
||||
if args.lane=='sops':
|
||||
manifest=json.loads((base/'sops-fd-r2/manifest.json').read_text());owner='net-kingdom'
|
||||
revision=manifest['owner_base'];patch=base/'sops-fd-r2/source.patch';pin=manifest['patch_sha256']
|
||||
expected={e['path']:e['after_sha256'] for e in manifest['files']}
|
||||
pre={e['path']:e['before_sha256'] for e in manifest['files']}
|
||||
else:
|
||||
review=json.loads((handoffs/'owner-handoff-review.json').read_text());owner='railiance-cluster' if args.lane=='cluster' else 'secrets-engine'
|
||||
revision=review['owners'][owner]['baseline_head'];expected=review['owners'][owner]['paths'];pre={k:None for k in expected}
|
||||
patch=handoffs/(owner+'.patch');pin=review['patches'][owner]
|
||||
root=Path('/home/worsch')/owner
|
||||
def sha(path):return 'sha256:'+hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
def git(*args):return subprocess.check_output(['git','-C',str(root),*args],stderr=subprocess.PIPE)
|
||||
assert git('rev-parse','HEAD').decode().strip()==revision
|
||||
assert sha(patch)==pin
|
||||
paths=[s[6:] for s in patch.read_text().splitlines() if s.startswith('+++ b/')]
|
||||
assert set(paths)==set(expected)
|
||||
for rel in paths:
|
||||
assert not Path(rel).is_absolute() and '..' not in Path(rel).parts
|
||||
assert not git('status','--porcelain','--',rel).strip()
|
||||
assert (sha(root/rel) if (root/rel).exists() else None)==pre[rel]
|
||||
before_index=git('ls-files','--stage','--','.custodian-brief.md')
|
||||
before_brief=(root/'.custodian-brief.md').read_bytes()
|
||||
git('apply','--check',str(patch));git('apply',str(patch))
|
||||
for rel,pin in expected.items():assert sha(root/rel)==pin
|
||||
assert git('ls-files','--stage','--','.custodian-brief.md')==before_index
|
||||
assert (root/'.custodian-brief.md').read_bytes()==before_brief
|
||||
out={'owner':owner,'base_revision':revision,'applied':True,'files':expected,'preexisting_brief_preserved':True,'native_acceptance':False}
|
||||
(base/(args.lane+'-integration.json')).write_text(json.dumps(out,indent=2)+'\n')
|
||||
print(json.dumps({'owner':owner,'applied':True,'files':len(paths)}))
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# Target Revenue Source License
|
||||
|
||||
**Version 1.0, Candidate 1 (V1C1)**
|
||||
|
||||
---
|
||||
|
||||
> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL**
|
||||
>
|
||||
> This repository is governed by the Target Revenue Source License
|
||||
> (TRSL), Version 1, Candidate 1. This is the framework's first working
|
||||
> candidate, adopted as the org's preliminary operating license across
|
||||
> all repos (maintainer decision, 2026-07-29) during the build/alpha
|
||||
> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link
|
||||
> below) for the alpha/beta risk-acceptance decision this reflects and
|
||||
> what it does and does not mean. Full specialist legal review is
|
||||
> explicitly deferred until the framework moves out of beta.
|
||||
>
|
||||
> **Canonical source, full candidate-status banner, and Appendix A
|
||||
> (non-normative candidate notes tracking every open item):** the
|
||||
> `coulomb` org's `target-revenue` repository,
|
||||
> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative
|
||||
> legal text (Preamble through Section 11) only; the canonical document
|
||||
> is authoritative if this copy and that document ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Preamble
|
||||
|
||||
This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License.
|
||||
|
||||
Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept.
|
||||
|
||||
**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase.
|
||||
|
||||
**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`.
|
||||
|
||||
**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement.
|
||||
|
||||
**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service.
|
||||
|
||||
**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger.
|
||||
|
||||
**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event.
|
||||
|
||||
**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest.
|
||||
|
||||
**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release.
|
||||
|
||||
**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest.
|
||||
|
||||
**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character.
|
||||
|
||||
**"Outstanding Target"** means, at any time, `max(0, Initial Target − cumulative Development Credit − cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger.
|
||||
|
||||
**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest.
|
||||
|
||||
**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification.
|
||||
|
||||
**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger.
|
||||
|
||||
**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension.
|
||||
|
||||
**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest.
|
||||
|
||||
**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification.
|
||||
|
||||
**"You"** or **"Licensee"** means the individual or entity exercising rights under this License.
|
||||
|
||||
## 2. Grant of Rights for Noncommercial Use
|
||||
|
||||
Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to:
|
||||
|
||||
(a) use, reproduce, and study the Software for any Noncommercial Use;
|
||||
|
||||
(b) modify the Software and create derivative works of it for any Noncommercial Use; and
|
||||
|
||||
(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software.
|
||||
|
||||
This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3.
|
||||
|
||||
## 3. Commercial Use
|
||||
|
||||
You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so.
|
||||
|
||||
This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement.
|
||||
|
||||
## 4. Patent License
|
||||
|
||||
Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software.
|
||||
|
||||
If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed.
|
||||
|
||||
## 5. Automatic Conversion to the Future License
|
||||
|
||||
**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party.
|
||||
|
||||
**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software.
|
||||
|
||||
**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release.
|
||||
|
||||
**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger.
|
||||
|
||||
## 6. Successive Phases
|
||||
|
||||
The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release.
|
||||
|
||||
## 7. Term and Termination
|
||||
|
||||
**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License.
|
||||
|
||||
**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity.
|
||||
|
||||
**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2.
|
||||
|
||||
**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification.
|
||||
|
||||
A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle.
|
||||
|
||||
Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party.
|
||||
|
||||
## 8. Disclaimer of Warranty
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT.
|
||||
|
||||
## 9. Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW.
|
||||
|
||||
## 10. Trademarks
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution.
|
||||
|
||||
## 11. General Provisions
|
||||
|
||||
**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2.
|
||||
|
||||
**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable.
|
||||
|
||||
**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision.
|
||||
|
||||
**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License.
|
||||
|
||||
**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License.
|
||||
|
||||
---
|
||||
|
||||
**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 2–7 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/netkingdom/access-engine
|
||||
|
||||
go 1.24
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Package sign attaches a detached Ed25519 signature to a decision envelope
|
||||
// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with
|
||||
// the signature field omitted.
|
||||
package sign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
const AlgorithmEd25519 = "ed25519"
|
||||
|
||||
// CanonicalBytes is the exact payload that is signed and verified: the
|
||||
// envelope as emitted, with signature absent.
|
||||
func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) {
|
||||
envelope.Signature = nil
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal canonical envelope: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error {
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("envelope is required")
|
||||
}
|
||||
if len(private) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("ed25519 private key has length %d", len(private))
|
||||
}
|
||||
if kid == "" {
|
||||
return fmt.Errorf("kid is required")
|
||||
}
|
||||
payload, err := CanonicalBytes(*envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig := ed25519.Sign(private, payload)
|
||||
envelope.Signature = &api.EnvelopeSignature{
|
||||
Mode: api.EnvelopeSignatureSigned,
|
||||
Alg: AlgorithmEd25519,
|
||||
Kid: kid,
|
||||
Value: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error {
|
||||
sig := envelope.Signature
|
||||
if sig == nil || sig.Mode != api.EnvelopeSignatureSigned {
|
||||
return fmt.Errorf("envelope is not signed")
|
||||
}
|
||||
if sig.Alg != AlgorithmEd25519 {
|
||||
return fmt.Errorf("unsupported signature alg %q", sig.Alg)
|
||||
}
|
||||
pub, ok := keys[sig.Kid]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown signature kid %q", sig.Kid)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
payload, err := CanonicalBytes(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, raw) {
|
||||
return fmt.Errorf("signature does not match envelope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,699 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtectedSystemManifest describes a system that delegates authorization to
|
||||
// flex-auth.
|
||||
type ProtectedSystemManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"`
|
||||
ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"`
|
||||
Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ClientBinding names the key-cape client a protected system's users sign in
|
||||
// through. It is descriptive only: the readback registry projection lists it,
|
||||
// and it never decides an allow (FLEX-DEC-2026-019).
|
||||
type ClientBinding struct {
|
||||
Issuer string `json:"issuer" yaml:"issuer"`
|
||||
ClientID string `json:"client_id" yaml:"client_id"`
|
||||
}
|
||||
|
||||
// ResourceType describes a resource namespace entry owned by a protected system.
|
||||
type ResourceType struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ParentTypes []string `json:"parent_types,omitempty" yaml:"parent_types,omitempty"`
|
||||
ScopeLevel ScopeLevel `json:"scope_level,omitempty" yaml:"scope_level,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ActionDefinition maps a protected-system action to CARING capabilities.
|
||||
type ActionDefinition struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectManifest declares subjects, groups, teams, and tenants for local
|
||||
// registry loading.
|
||||
type SubjectManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Subjects []Subject `json:"subjects,omitempty" yaml:"subjects,omitempty"`
|
||||
Groups []Group `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Teams []Team `json:"teams,omitempty" yaml:"teams,omitempty"`
|
||||
Tenants []Tenant `json:"tenants,omitempty" yaml:"tenants,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Subject is a human, service, automation, agent, or other acting identity.
|
||||
type Subject struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type" yaml:"type"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
OrganizationRelation OrganizationRelation `json:"organization_relation,omitempty" yaml:"organization_relation,omitempty"`
|
||||
Roles []CanonicalRole `json:"roles,omitempty" yaml:"roles,omitempty"`
|
||||
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Claims map[string]any `json:"claims,omitempty" yaml:"claims,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Group is an assignment convenience, not a canonical role.
|
||||
type Group struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Team is a group-like ownership unit used by protected systems.
|
||||
type Team struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Tenant is a structural isolation boundary.
|
||||
type Tenant struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipFact records a relation between subjects, groups, teams, tenants,
|
||||
// and resources.
|
||||
type RelationshipFact struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Subject string `json:"subject" yaml:"subject"`
|
||||
Relation string `json:"relation" yaml:"relation"`
|
||||
Object string `json:"object" yaml:"object"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyPackageMetadata is the frontmatter contract for Rego-in-Markdown
|
||||
// policy packages.
|
||||
type PolicyPackageMetadata struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
||||
Version string `json:"version" yaml:"version"`
|
||||
Status string `json:"status,omitempty" yaml:"status,omitempty"`
|
||||
Package string `json:"package" yaml:"package"`
|
||||
Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
|
||||
Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"`
|
||||
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
|
||||
Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
// AllowTTL is a Go duration (for example "15m") that bounds every allow
|
||||
// this package produces. Omit to use DefaultAllowTTL. "none" or "0s"
|
||||
// means no stated end; the engine denies those allows (§9.7.1).
|
||||
AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"`
|
||||
}
|
||||
|
||||
// CaringPolicyMetadata declares the CARING envelope a policy governs.
|
||||
type CaringPolicyMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"`
|
||||
CanonicalRoles []CanonicalRole `json:"canonical_roles,omitempty" yaml:"canonical_roles,omitempty"`
|
||||
OrganizationRelations []OrganizationRelation `json:"organization_relations,omitempty" yaml:"organization_relations,omitempty"`
|
||||
Scopes []CaringScope `json:"scopes,omitempty" yaml:"scopes,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyFixture binds a check request to an expected decision.
|
||||
type PolicyFixture struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Request CheckRequest `json:"request" yaml:"request"`
|
||||
Expect DecisionExpectation `json:"expect" yaml:"expect"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionExpectation is the compact fixture expectation for policy tests.
|
||||
type DecisionExpectation struct {
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
}
|
||||
|
||||
// CheckRequest is the stable protected-system-facing decision request.
|
||||
type CheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
|
||||
// Purpose is empty on every enforcement request. The readback pin sets it
|
||||
// to PurposeReadback, and it is hashed into every request digest, so an
|
||||
// advisory readback decision can never pass a PEP's §6.4.2 replay test for
|
||||
// an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check
|
||||
// refuse a request that carries it.
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// PurposeReadback marks an advisory application-access readback decision.
|
||||
const PurposeReadback = "readback"
|
||||
|
||||
// BatchCheckRequest evaluates one subject/action against multiple resources.
|
||||
type BatchCheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resources []ResourceRef `json:"resources" yaml:"resources"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectRef is a normalized subject reference in request and decision shapes.
|
||||
type SubjectRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceRef is a normalized resource reference in request and decision shapes.
|
||||
type ResourceRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionEffect is the stable decision outcome vocabulary.
|
||||
type DecisionEffect string
|
||||
|
||||
const (
|
||||
DecisionEffectAllow DecisionEffect = "allow"
|
||||
DecisionEffectDeny DecisionEffect = "deny"
|
||||
DecisionEffectRedact DecisionEffect = "redact"
|
||||
DecisionEffectAuditOnly DecisionEffect = "audit_only"
|
||||
DecisionEffectNotApplicable DecisionEffect = "not_applicable"
|
||||
)
|
||||
|
||||
// DecisionEffects returns the complete effect vocabulary. Every effect is an
|
||||
// emission class in cadence.yaml; a test fails if a constant above is missing
|
||||
// here, so a new effect cannot ship unclassified (FLEX-WP-0031-T03).
|
||||
func DecisionEffects() []DecisionEffect {
|
||||
return []DecisionEffect{
|
||||
DecisionEffectAllow,
|
||||
DecisionEffectDeny,
|
||||
DecisionEffectRedact,
|
||||
DecisionEffectAuditOnly,
|
||||
DecisionEffectNotApplicable,
|
||||
}
|
||||
}
|
||||
|
||||
// Restricts reports whether an effect withholds authority. This classification
|
||||
// does not exempt an effect from required durable emission (GH-DEC-2026-024).
|
||||
func (e DecisionEffect) Restricts() bool {
|
||||
switch e {
|
||||
case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecisionRecordContractV1 is the published decision-record contract identifier.
|
||||
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
|
||||
|
||||
// DecisionEnvelope is the stable response produced by standalone and delegated
|
||||
// evaluators. It is flex-auth's published decision-record contract (§17).
|
||||
type DecisionEnvelope struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"`
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
MatchedPolicyVersion string `json:"matched_policy_version,omitempty" yaml:"matched_policy_version,omitempty"`
|
||||
MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
|
||||
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
|
||||
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
|
||||
Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"`
|
||||
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeSignatureMode is how the responder authenticates the envelope.
|
||||
type EnvelopeSignatureMode string
|
||||
|
||||
const (
|
||||
EnvelopeSignatureSigned EnvelopeSignatureMode = "signed"
|
||||
EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned"
|
||||
)
|
||||
|
||||
// EnvelopeSignature is a detached signature over the canonical envelope with
|
||||
// this field omitted (FLEX-WP-0024). It is not decision material and is not
|
||||
// part of request_digest.
|
||||
type EnvelopeSignature struct {
|
||||
Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"`
|
||||
Alg string `json:"alg,omitempty" yaml:"alg,omitempty"`
|
||||
Kid string `json:"kid,omitempty" yaml:"kid,omitempty"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
}
|
||||
|
||||
func UnsignedEnvelopeSignature() *EnvelopeSignature {
|
||||
return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned}
|
||||
}
|
||||
|
||||
// DecisionLifetimeKind identifies how an allow ends.
|
||||
type DecisionLifetimeKind string
|
||||
|
||||
const (
|
||||
DecisionLifetimeTTL DecisionLifetimeKind = "ttl"
|
||||
)
|
||||
|
||||
// DefaultAllowTTL is the engine default when a policy package omits allow_ttl.
|
||||
const DefaultAllowTTL = 15 * time.Minute
|
||||
|
||||
// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end.
|
||||
const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated"
|
||||
|
||||
// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept,
|
||||
// so the first honest shape is a policy-package-declared TTL.
|
||||
type DecisionLifetime struct {
|
||||
Kind DecisionLifetimeKind `json:"kind" yaml:"kind"`
|
||||
TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// DecisionBinding is the exact normalized authorization request evaluated by
|
||||
// a decision. It lets a consumer verify structured action, target, actor, and
|
||||
// context fields without parsing reason or diagnostic prose.
|
||||
type DecisionBinding struct {
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
// Purpose is "readback" on an advisory readback decision and absent on an
|
||||
// enforcement decision (FLEX-DEC-2026-019).
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
RequestDigest string `json:"request_digest" yaml:"request_digest"`
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest computed with the approval claim
|
||||
// removed from context. It exists to break a circularity: an approval's
|
||||
// pdp_digest is recorded at issue time, but the request that later carries
|
||||
// the claim inside its hashed context has a different RequestDigest by
|
||||
// construction, so a claim can never name the request that carries it.
|
||||
//
|
||||
// This digest is stable across attaching the claim, so it is the value
|
||||
// claim.binding.pdp_digest must equal (GH-DEC-2026-008). It is NOT a replay
|
||||
// identity and must not be used as one: two requests differing only in
|
||||
// which approval was presented share it, and the decision does not.
|
||||
ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"`
|
||||
|
||||
// SubmittedRequestDigest is RequestDigest over the request exactly as the
|
||||
// consumer sent it, before the evaluator overlays registry facts onto the
|
||||
// subject and resource.
|
||||
//
|
||||
// It exists because RequestDigest is not consumer-computable and was
|
||||
// published as though it were. The evaluator hashes the ENRICHED request —
|
||||
// subject.tenant, subject.attributes, and resource attributes the registry
|
||||
// contributed — and the registry is flex-auth's, so a consumer recomputing
|
||||
// the digest over what it sent gets a different value on every real allow.
|
||||
// secrets-engine found this on its first live request (FLEX-DEC-2026-012).
|
||||
//
|
||||
// This is the digest a PEP compares for §6.4 obligation 2's replay test.
|
||||
// Nothing is lost by hashing the pre-enrichment form: enrichment is a
|
||||
// function of the request and the registry snapshot, and
|
||||
// provenance.registry_snapshot_digest already pins the snapshot, so
|
||||
// SubmittedRequestDigest together with that digest identifies the evaluated
|
||||
// request completely.
|
||||
SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalContextKey is the context key carrying an approval-engine
|
||||
// approval-claim. It is excluded from ApprovalBindingDigest and from nothing
|
||||
// else.
|
||||
const ApprovalContextKey = "approval"
|
||||
|
||||
// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request
|
||||
// id, policy version, and caring_context are excluded: id is correlation, the
|
||||
// version is provenance, and caring_context is an input-claim digest.
|
||||
type requestDigestMaterial struct {
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
Resource ResourceRef `json:"resource"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// NewDecisionBinding returns a stable structured binding for the exact request
|
||||
// an evaluator consumed.
|
||||
//
|
||||
// Prefer NewDecisionBindingFor, which also records the digest of the request as
|
||||
// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the
|
||||
// evaluator did not record one" rather than "the two are equal".
|
||||
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
||||
contextCopy := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
contextCopy[key] = value
|
||||
}
|
||||
binding := &DecisionBinding{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: contextCopy,
|
||||
Purpose: request.Purpose,
|
||||
RequestDigest: RequestDigest(request),
|
||||
}
|
||||
if _, carries := request.Context[ApprovalContextKey]; carries {
|
||||
binding.ApprovalBindingDigest = ApprovalBindingDigest(request)
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
// NewDecisionBindingFor records the binding of the evaluated request together
|
||||
// with the digest of the request as submitted.
|
||||
//
|
||||
// The two arguments are the same request before and after the evaluator
|
||||
// overlaid registry facts. Where a request named nothing the registry knows they
|
||||
// are equal, and the field is emitted anyway: a consumer that only sees it on
|
||||
// enriched decisions would build a check that passes by absence.
|
||||
func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding {
|
||||
binding := NewDecisionBinding(evaluated)
|
||||
binding.SubmittedRequestDigest = RequestDigest(submitted)
|
||||
return binding
|
||||
}
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest over the same material with the
|
||||
// approval claim removed from context.
|
||||
//
|
||||
// When a request carries no approval claim the two are identical, which is the
|
||||
// point: an approval issued against a claim-free Check records that Check's
|
||||
// request_digest, and the later claim-bearing request reproduces the same value
|
||||
// here. Comparing it to claim.binding.pdp_digest establishes correspondence by
|
||||
// identity rather than by translating between two action vocabularies
|
||||
// (GH-DEC-2026-008).
|
||||
func ApprovalBindingDigest(request CheckRequest) string {
|
||||
if _, carries := request.Context[ApprovalContextKey]; !carries {
|
||||
return RequestDigest(request)
|
||||
}
|
||||
stripped := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
if key == ApprovalContextKey {
|
||||
continue
|
||||
}
|
||||
stripped[key] = value
|
||||
}
|
||||
if len(stripped) == 0 {
|
||||
stripped = nil
|
||||
}
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: stripped,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
|
||||
// JSON of tenant, subject, action, resource, context, and purpose. Purpose is
|
||||
// omitted when empty, so enforcement digests are unchanged by its existence.
|
||||
func RequestDigest(request CheckRequest) string {
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: request.Context,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON.
|
||||
// encoding/json sorts map keys, so two equal Go values agree.
|
||||
func CanonicalDigest(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256(nil)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// InputClaimDigests hashes the request-time claim classes the evaluator joined.
|
||||
func InputClaimDigests(request CheckRequest) map[string]string {
|
||||
digests := make(map[string]string)
|
||||
if len(request.Context) > 0 {
|
||||
digests["context"] = CanonicalDigest(request.Context)
|
||||
}
|
||||
if request.CaringContext != nil {
|
||||
digests["caring_context"] = CanonicalDigest(request.CaringContext)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// DecisionCompletion carries evaluator-side inputs used to finish an envelope.
|
||||
type DecisionCompletion struct {
|
||||
AllowTTL string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CompleteDecision stamps contract version, input-claim digests, decision time,
|
||||
// and an explicit allow lifetime. An allow with no stated end becomes a deny.
|
||||
func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if envelope.ContractVersion == "" {
|
||||
envelope.ContractVersion = DecisionRecordContractV1
|
||||
}
|
||||
if envelope.Provenance.InputClaimDigests == nil {
|
||||
envelope.Provenance.InputClaimDigests = InputClaimDigests(request)
|
||||
}
|
||||
ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now)
|
||||
}
|
||||
|
||||
// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow
|
||||
// would have no stated end. Invalid strings return an error so package
|
||||
// validation can reject them.
|
||||
func ParseAllowTTL(declared string) (time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(declared)
|
||||
if trimmed == "" {
|
||||
return DefaultAllowTTL, nil
|
||||
}
|
||||
if strings.EqualFold(trimmed, "none") {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A
|
||||
// missing or zero TTL denies the allow rather than mint a standing grant.
|
||||
func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
if envelope.Provenance.DecisionTime == "" {
|
||||
envelope.Provenance.DecisionTime = now.Format(time.RFC3339)
|
||||
}
|
||||
if envelope.Effect != DecisionEffectAllow {
|
||||
return
|
||||
}
|
||||
ttl, err := ParseAllowTTL(declaredTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
if envelope.Diagnostics == nil {
|
||||
envelope.Diagnostics = map[string]any{}
|
||||
}
|
||||
if envelope.Reason != "" {
|
||||
envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason
|
||||
}
|
||||
envelope.Effect = DecisionEffectDeny
|
||||
envelope.Reason = ReasonAllowLifetimeUnstated
|
||||
envelope.MatchedRule = ReasonAllowLifetimeUnstated
|
||||
envelope.Lifetime = nil
|
||||
return
|
||||
}
|
||||
display := strings.TrimSpace(declaredTTL)
|
||||
if display == "" {
|
||||
display = "15m"
|
||||
}
|
||||
envelope.Lifetime = &DecisionLifetime{
|
||||
Kind: DecisionLifetimeTTL,
|
||||
TTL: display,
|
||||
NotBefore: now.Format(time.RFC3339),
|
||||
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ActionAuthorizationStatus is the lifecycle state of a durable authorization.
|
||||
type ActionAuthorizationStatus string
|
||||
|
||||
const (
|
||||
ActionAuthorizationPending ActionAuthorizationStatus = "pending"
|
||||
ActionAuthorizationApproved ActionAuthorizationStatus = "approved"
|
||||
ActionAuthorizationDenied ActionAuthorizationStatus = "denied"
|
||||
ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded"
|
||||
ActionAuthorizationExpired ActionAuthorizationStatus = "expired"
|
||||
ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked"
|
||||
)
|
||||
|
||||
// ActionAuthorization joins a durable approval lifecycle to one exact
|
||||
// flex-auth request and decision. Storage and approval collection remain the
|
||||
// responsibility of the organizational decision authority.
|
||||
type ActionAuthorization struct {
|
||||
SchemaVersion string `json:"schema_version" yaml:"schema_version"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Status ActionAuthorizationStatus `json:"status" yaml:"status"`
|
||||
SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"`
|
||||
Request CheckRequest `json:"request" yaml:"request"`
|
||||
Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"`
|
||||
Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"`
|
||||
Decision DecisionEnvelope `json:"decision" yaml:"decision"`
|
||||
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationValidity bounds execution of an approved action.
|
||||
type ActionAuthorizationValidity struct {
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationApprovals declares the approval threshold and evidence.
|
||||
type ActionAuthorizationApprovals struct {
|
||||
RequiredCount int `json:"required_count" yaml:"required_count"`
|
||||
Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"`
|
||||
}
|
||||
|
||||
// ActionAuthorizationApprovalEntry is one authenticated approver's evidence.
|
||||
type ActionAuthorizationApprovalEntry struct {
|
||||
SubjectID string `json:"subject_id" yaml:"subject_id"`
|
||||
ApprovedAt string `json:"approved_at" yaml:"approved_at"`
|
||||
Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
// Obligation describes a follow-up behavior required by a decision.
|
||||
type Obligation struct {
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
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.
|
||||
// It is not decision material: the same request from a different authenticated
|
||||
// caller must decide identically. Mode is required. Under disabled the object
|
||||
// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009).
|
||||
type CallerProvenance struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Principal string `json:"principal,omitempty" yaml:"principal,omitempty"`
|
||||
Audience string `json:"audience,omitempty" yaml:"audience,omitempty"`
|
||||
NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"`
|
||||
}
|
||||
|
||||
// CaringDecisionMetadata carries CARING descriptor and conformance details in
|
||||
// a decision envelope.
|
||||
type CaringDecisionMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"`
|
||||
RestrictionsEvaluated []Restriction `json:"restrictions_evaluated,omitempty" yaml:"restrictions_evaluated,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent is the local log shape for decisions and exposure events.
|
||||
type AuditEvent struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
DecisionID string `json:"decision_id,omitempty" yaml:"decision_id,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Resource ResourceRef `json:"resource,omitempty" yaml:"resource,omitempty"`
|
||||
Action string `json:"action,omitempty" yaml:"action,omitempty"`
|
||||
Effect DecisionEffect `json:"effect,omitempty" yaml:"effect,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
package api
|
||||
|
||||
// CaringProfileCaring040RC2 is the executable profile identifier for the
|
||||
// CARING 0.4.0-RC2 standard pinned by flex-auth.
|
||||
const CaringProfileCaring040RC2 = "caring-0.4.0-rc2"
|
||||
|
||||
// SubjectType is the CARING subject dimension.
|
||||
type SubjectType string
|
||||
|
||||
const (
|
||||
SubjectTypeHuman SubjectType = "Human"
|
||||
SubjectTypeGroup SubjectType = "Group"
|
||||
SubjectTypeOrganization SubjectType = "Organization"
|
||||
SubjectTypeService SubjectType = "Service"
|
||||
SubjectTypeAutomation SubjectType = "Automation"
|
||||
SubjectTypeAgent SubjectType = "Agent"
|
||||
SubjectTypeSystem SubjectType = "System"
|
||||
SubjectTypeDevice SubjectType = "Device"
|
||||
SubjectTypeProcess SubjectType = "Process"
|
||||
SubjectTypeAnonymous SubjectType = "Anonymous"
|
||||
SubjectTypeUnknown SubjectType = "Unknown"
|
||||
)
|
||||
|
||||
// OrganizationRelation is the CARING organization-relation dimension.
|
||||
type OrganizationRelation string
|
||||
|
||||
const (
|
||||
OrganizationRelationVendor OrganizationRelation = "Vendor"
|
||||
OrganizationRelationServiceProvider OrganizationRelation = "ServiceProvider"
|
||||
OrganizationRelationDistributor OrganizationRelation = "Distributor"
|
||||
OrganizationRelationConsultant OrganizationRelation = "Consultant"
|
||||
OrganizationRelationCustomer OrganizationRelation = "Customer"
|
||||
OrganizationRelationCommunity OrganizationRelation = "Community"
|
||||
OrganizationRelationAuthority OrganizationRelation = "Authority"
|
||||
OrganizationRelationUnknown OrganizationRelation = "Unknown"
|
||||
)
|
||||
|
||||
// CanonicalRole is the CARING lifecycle responsibility posture.
|
||||
type CanonicalRole string
|
||||
|
||||
const (
|
||||
CanonicalRoleCreator CanonicalRole = "Creator"
|
||||
CanonicalRoleBuilder CanonicalRole = "Builder"
|
||||
CanonicalRoleVerifier CanonicalRole = "Verifier"
|
||||
CanonicalRoleMaintainer CanonicalRole = "Maintainer"
|
||||
CanonicalRoleIntegrator CanonicalRole = "Integrator"
|
||||
CanonicalRoleOperator CanonicalRole = "Operator"
|
||||
CanonicalRoleManager CanonicalRole = "Manager"
|
||||
CanonicalRoleCoach CanonicalRole = "Coach"
|
||||
CanonicalRoleDoer CanonicalRole = "Doer"
|
||||
)
|
||||
|
||||
// ScopeLevel is the CARING scope ladder.
|
||||
type ScopeLevel string
|
||||
|
||||
const (
|
||||
ScopeLevelEcosystem ScopeLevel = "Ecosystem"
|
||||
ScopeLevelProduct ScopeLevel = "Product"
|
||||
ScopeLevelPlatform ScopeLevel = "Platform"
|
||||
ScopeLevelCluster ScopeLevel = "Cluster"
|
||||
ScopeLevelEnvironment ScopeLevel = "Environment"
|
||||
ScopeLevelTenant ScopeLevel = "Tenant"
|
||||
ScopeLevelNamespace ScopeLevel = "Namespace"
|
||||
ScopeLevelDomain ScopeLevel = "Domain"
|
||||
ScopeLevelWorkspace ScopeLevel = "Workspace"
|
||||
ScopeLevelProject ScopeLevel = "Project"
|
||||
ScopeLevelProcess ScopeLevel = "Process"
|
||||
ScopeLevelDataset ScopeLevel = "Dataset"
|
||||
ScopeLevelResource ScopeLevel = "Resource"
|
||||
ScopeLevelSubresource ScopeLevel = "Subresource"
|
||||
ScopeLevelRecord ScopeLevel = "Record"
|
||||
ScopeLevelField ScopeLevel = "Field"
|
||||
ScopeLevelAction ScopeLevel = "Action"
|
||||
)
|
||||
|
||||
// Plane is the CARING access-surface dimension.
|
||||
type Plane string
|
||||
|
||||
const (
|
||||
PlaneIntent Plane = "Intent"
|
||||
PlaneBuild Plane = "Build"
|
||||
PlaneRuntime Plane = "Runtime"
|
||||
PlaneExecution Plane = "Execution"
|
||||
PlaneConfiguration Plane = "Configuration"
|
||||
PlaneData Plane = "Data"
|
||||
PlaneIdentity Plane = "Identity"
|
||||
PlanePolicy Plane = "Policy"
|
||||
PlaneSecret Plane = "Secret"
|
||||
PlaneAudit Plane = "Audit"
|
||||
PlaneCommercial Plane = "Commercial"
|
||||
PlaneCommunity Plane = "Community"
|
||||
)
|
||||
|
||||
// Capability is a CARING capability verb.
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityView Capability = "View"
|
||||
CapabilityViewCollection Capability = "ViewCollection"
|
||||
CapabilityObserve Capability = "Observe"
|
||||
CapabilityCreate Capability = "Create"
|
||||
CapabilityEditOwn Capability = "EditOwn"
|
||||
CapabilityEditAssigned Capability = "EditAssigned"
|
||||
CapabilityEditAny Capability = "EditAny"
|
||||
CapabilityDeleteOwn Capability = "DeleteOwn"
|
||||
CapabilityDeleteAny Capability = "DeleteAny"
|
||||
CapabilityBulkDelete Capability = "BulkDelete"
|
||||
CapabilitySubmit Capability = "Submit"
|
||||
CapabilityComment Capability = "Comment"
|
||||
CapabilityReview Capability = "Review"
|
||||
CapabilityApprove Capability = "Approve"
|
||||
CapabilityReject Capability = "Reject"
|
||||
CapabilityPublish Capability = "Publish"
|
||||
CapabilityArchive Capability = "Archive"
|
||||
CapabilityRestore Capability = "Restore"
|
||||
CapabilityExecute Capability = "Execute"
|
||||
CapabilityConfigure Capability = "Configure"
|
||||
CapabilityOperate Capability = "Operate"
|
||||
CapabilityDeploy Capability = "Deploy"
|
||||
CapabilityIntegrate Capability = "Integrate"
|
||||
CapabilityGrant Capability = "Grant"
|
||||
CapabilityRevoke Capability = "Revoke"
|
||||
CapabilityDelegate Capability = "Delegate"
|
||||
CapabilityImpersonate Capability = "Impersonate"
|
||||
CapabilityExport Capability = "Export"
|
||||
CapabilityImport Capability = "Import"
|
||||
CapabilityReplicate Capability = "Replicate"
|
||||
CapabilityEncrypt Capability = "Encrypt"
|
||||
CapabilityDecrypt Capability = "Decrypt"
|
||||
CapabilityMask Capability = "Mask"
|
||||
CapabilityInspect Capability = "Inspect"
|
||||
CapabilityAudit Capability = "Audit"
|
||||
CapabilityOverride Capability = "Override"
|
||||
CapabilityEscalate Capability = "Escalate"
|
||||
CapabilityBind Capability = "Bind"
|
||||
CapabilityUse Capability = "Use"
|
||||
)
|
||||
|
||||
// ExposureMode describes how much information becomes visible or extractable.
|
||||
type ExposureMode string
|
||||
|
||||
const (
|
||||
ExposureModeNone ExposureMode = "None"
|
||||
ExposureModeMetadata ExposureMode = "Metadata"
|
||||
ExposureModeMasked ExposureMode = "Masked"
|
||||
ExposureModeAggregated ExposureMode = "Aggregated"
|
||||
ExposureModeSynthetic ExposureMode = "Synthetic"
|
||||
ExposureModePseudonymous ExposureMode = "Pseudonymous"
|
||||
ExposureModeEncrypted ExposureMode = "Encrypted"
|
||||
ExposureModePlaintext ExposureMode = "Plaintext"
|
||||
ExposureModeSecretMaterial ExposureMode = "SecretMaterial"
|
||||
ExposureModeExportable ExposureMode = "Exportable"
|
||||
ExposureModeCrossTenantAggregate ExposureMode = "CrossTenantAggregate"
|
||||
)
|
||||
|
||||
// Condition is a CARING runtime or governance condition.
|
||||
type Condition string
|
||||
|
||||
const (
|
||||
ConditionMFARequired Condition = "MFARequired"
|
||||
ConditionDeviceTrusted Condition = "DeviceTrusted"
|
||||
ConditionNetworkTrusted Condition = "NetworkTrusted"
|
||||
ConditionTicketRequired Condition = "TicketRequired"
|
||||
ConditionTenantConsentRequired Condition = "TenantConsentRequired"
|
||||
ConditionCustomerApprovalRequired Condition = "CustomerApprovalRequired"
|
||||
ConditionDualApprovalRequired Condition = "DualApprovalRequired"
|
||||
ConditionTimeLimited Condition = "TimeLimited"
|
||||
ConditionBusinessHoursOnly Condition = "BusinessHoursOnly"
|
||||
ConditionEmergencyOnly Condition = "EmergencyOnly"
|
||||
ConditionTrainingRequired Condition = "TrainingRequired"
|
||||
ConditionContractRequired Condition = "ContractRequired"
|
||||
ConditionNDARequired Condition = "NDARequired"
|
||||
ConditionPurposeBound Condition = "PurposeBound"
|
||||
ConditionCaseBound Condition = "CaseBound"
|
||||
ConditionEnvironmentBound Condition = "EnvironmentBound"
|
||||
ConditionNamespaceBound Condition = "NamespaceBound"
|
||||
ConditionPipelineBound Condition = "PipelineBound"
|
||||
ConditionChangeWindowBound Condition = "ChangeWindowBound"
|
||||
ConditionLogged Condition = "Logged"
|
||||
ConditionRecorded Condition = "Recorded"
|
||||
ConditionNotificationRequired Condition = "NotificationRequired"
|
||||
ConditionPostReviewRequired Condition = "PostReviewRequired"
|
||||
ConditionHumanReviewRequired Condition = "HumanReviewRequired"
|
||||
ConditionPolicyReviewRequired Condition = "PolicyReviewRequired"
|
||||
ConditionWorkloadIdentityRequired Condition = "WorkloadIdentityRequired"
|
||||
)
|
||||
|
||||
// LifecycleState describes why access exists now.
|
||||
type LifecycleState string
|
||||
|
||||
const (
|
||||
LifecycleStateDesign LifecycleState = "Design"
|
||||
LifecycleStateBuild LifecycleState = "Build"
|
||||
LifecycleStateTest LifecycleState = "Test"
|
||||
LifecycleStateReview LifecycleState = "Review"
|
||||
LifecycleStateRelease LifecycleState = "Release"
|
||||
LifecycleStateOnboard LifecycleState = "Onboard"
|
||||
LifecycleStateIntegrate LifecycleState = "Integrate"
|
||||
LifecycleStateMigrate LifecycleState = "Migrate"
|
||||
LifecycleStateOperate LifecycleState = "Operate"
|
||||
LifecycleStateSupport LifecycleState = "Support"
|
||||
LifecycleStateImprove LifecycleState = "Improve"
|
||||
LifecycleStateDeprecate LifecycleState = "Deprecate"
|
||||
LifecycleStateArchive LifecycleState = "Archive"
|
||||
LifecycleStateIncident LifecycleState = "Incident"
|
||||
LifecycleStateLegal LifecycleState = "Legal"
|
||||
LifecycleStateTerminate LifecycleState = "Terminate"
|
||||
)
|
||||
|
||||
// Restriction is an overriding CARING deny or limiting policy effect.
|
||||
type Restriction string
|
||||
|
||||
const (
|
||||
RestrictionNoAccess Restriction = "NoAccess"
|
||||
RestrictionSuspended Restriction = "Suspended"
|
||||
RestrictionTerminated Restriction = "Terminated"
|
||||
RestrictionQuarantined Restriction = "Quarantined"
|
||||
RestrictionScopeExcluded Restriction = "ScopeExcluded"
|
||||
RestrictionDataClassRestricted Restriction = "DataClassRestricted"
|
||||
RestrictionLegalHold Restriction = "LegalHold"
|
||||
RestrictionExportBlocked Restriction = "ExportBlocked"
|
||||
RestrictionImpersonationBlocked Restriction = "ImpersonationBlocked"
|
||||
RestrictionCrossTenantBlocked Restriction = "CrossTenantBlocked"
|
||||
RestrictionSecretAccessBlocked Restriction = "SecretAccessBlocked"
|
||||
RestrictionPolicyFrozen Restriction = "PolicyFrozen"
|
||||
RestrictionEmergencyLocked Restriction = "EmergencyLocked"
|
||||
RestrictionRiskDenied Restriction = "RiskDenied"
|
||||
RestrictionExecutionBlocked Restriction = "ExecutionBlocked"
|
||||
RestrictionWorkloadCreationBlocked Restriction = "WorkloadCreationBlocked"
|
||||
RestrictionPrivilegeEscalationBlocked Restriction = "PrivilegeEscalationBlocked"
|
||||
)
|
||||
|
||||
// ExposureEventType is a CARING exceptional or irregular access class.
|
||||
type ExposureEventType string
|
||||
|
||||
const (
|
||||
ExposureEventSupport ExposureEventType = "X-Support"
|
||||
ExposureEventBreakGlass ExposureEventType = "X-BreakGlass"
|
||||
ExposureEventSecurityTest ExposureEventType = "X-SecurityTest"
|
||||
ExposureEventIncident ExposureEventType = "X-Incident"
|
||||
ExposureEventLegalDemand ExposureEventType = "X-LegalDemand"
|
||||
ExposureEventComplianceAudit ExposureEventType = "X-ComplianceAudit"
|
||||
ExposureEventMigration ExposureEventType = "X-Migration"
|
||||
ExposureEventRecovery ExposureEventType = "X-Recovery"
|
||||
ExposureEventAdversarial ExposureEventType = "X-Adversarial"
|
||||
ExposureEventMisconfiguration ExposureEventType = "X-Misconfiguration"
|
||||
ExposureEventInducedAccess ExposureEventType = "X-InducedAccess"
|
||||
ExposureEventPrivilegeEscalation ExposureEventType = "X-PrivilegeEscalation"
|
||||
)
|
||||
|
||||
// AccessPath describes how access is exercised.
|
||||
type AccessPath string
|
||||
|
||||
const (
|
||||
AccessPathDirect AccessPath = "direct"
|
||||
AccessPathDelegated AccessPath = "delegated"
|
||||
AccessPathMediated AccessPath = "mediated"
|
||||
AccessPathInduced AccessPath = "induced"
|
||||
)
|
||||
|
||||
// CaringScope identifies where a CARING descriptor applies.
|
||||
type CaringScope struct {
|
||||
Level ScopeLevel `json:"level" yaml:"level"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Parent string `json:"parent,omitempty" yaml:"parent,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Resource string `json:"resource,omitempty" yaml:"resource,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// CaringAccessDescriptor is the executable flex-auth representation of a
|
||||
// CARING access assignment.
|
||||
type CaringAccessDescriptor struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
SubjectType SubjectType `json:"subject_type" yaml:"subject_type"`
|
||||
OrganizationRelation OrganizationRelation `json:"organization_relation" yaml:"organization_relation"`
|
||||
CanonicalRole CanonicalRole `json:"canonical_role" yaml:"canonical_role"`
|
||||
Scope CaringScope `json:"scope" yaml:"scope"`
|
||||
Planes []Plane `json:"planes" yaml:"planes"`
|
||||
Capabilities []Capability `json:"capabilities" yaml:"capabilities"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
LifecycleState LifecycleState `json:"lifecycle_state,omitempty" yaml:"lifecycle_state,omitempty"`
|
||||
Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"`
|
||||
ExposureEvent ExposureEventType `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"`
|
||||
AccessPath AccessPath `json:"access_path,omitempty" yaml:"access_path,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// CaringDerivedCapability records effective authority created by another grant.
|
||||
type CaringDerivedCapability struct {
|
||||
Capability Capability `json:"capability" yaml:"capability"`
|
||||
Reason string `json:"reason" yaml:"reason"`
|
||||
Source string `json:"source,omitempty" yaml:"source,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
}
|
||||
|
||||
// CaringConformanceFinding is a diagnostic emitted by descriptive or
|
||||
// prescriptive CARING validation.
|
||||
type CaringConformanceFinding struct {
|
||||
Code string `json:"code" yaml:"code"`
|
||||
Severity string `json:"severity" yaml:"severity"`
|
||||
Message string `json:"message" yaml:"message"`
|
||||
Fields []string `json:"fields,omitempty" yaml:"fields,omitempty"`
|
||||
Descriptor string `json:"descriptor,omitempty" yaml:"descriptor,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// CaringExposureEvent records exceptional or irregular information exposure.
|
||||
type CaringExposureEvent struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type ExposureEventType `json:"type" yaml:"type"`
|
||||
Actor string `json:"actor" yaml:"actor"`
|
||||
Subject string `json:"subject" yaml:"subject"`
|
||||
Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"`
|
||||
Scope *CaringScope `json:"scope,omitempty" yaml:"scope,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
CapabilitiesUsed []Capability `json:"capabilities_used,omitempty" yaml:"capabilities_used,omitempty"`
|
||||
DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Reason string `json:"reason" yaml:"reason"`
|
||||
AuthoritySource string `json:"authority_source,omitempty" yaml:"authority_source,omitempty"`
|
||||
Approval string `json:"approval,omitempty" yaml:"approval,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty" yaml:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty" yaml:"end_time,omitempty"`
|
||||
ResourcesAccessed []string `json:"resources_accessed,omitempty" yaml:"resources_accessed,omitempty"`
|
||||
Evidence []string `json:"evidence,omitempty" yaml:"evidence,omitempty"`
|
||||
NotificationStatus string `json:"notification_status,omitempty" yaml:"notification_status,omitempty"`
|
||||
PostReview string `json:"post_review,omitempty" yaml:"post_review,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package api
|
||||
|
||||
// ResourceManifest is the shape a protected system publishes to register
|
||||
// its resources with flex-auth. The shape is pinned against the
|
||||
// Markitect-side emitter in markitect-tool (MKTT-WP-0014); see
|
||||
// schemas/resource_manifest.schema.json for the JSON Schema and
|
||||
// examples/markitect/resource_manifest.yaml for the canonical example.
|
||||
type ResourceManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
System string `json:"system" yaml:"system"`
|
||||
Resources []Resource `json:"resources" yaml:"resources"`
|
||||
Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
CaringProfile string `json:"caring_profile,omitempty" yaml:"caring_profile,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Resource is one entry in a ResourceManifest.
|
||||
type Resource struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Path string `json:"path,omitempty" yaml:"path,omitempty"`
|
||||
Parent string `json:"parent,omitempty" yaml:"parent,omitempty"`
|
||||
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
|
||||
TrustZone string `json:"trust_zone,omitempty" yaml:"trust_zone,omitempty"`
|
||||
Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
|
||||
Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// FlexAuthContractV0 is the metadata.flex_auth_contract value that
|
||||
// signals the v0 resource-registration contract.
|
||||
const FlexAuthContractV0 = "resource-registration-v0"
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
// Package decisionpep authenticates the producer's decision record for one
|
||||
// current request. It never evaluates policy, caches a verdict, or discovers trust.
|
||||
package decisionpep
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
var ErrRefused = errors.New("authenticated decision refused")
|
||||
|
||||
const nonceKey = "decisionpep_request_nonce"
|
||||
|
||||
const JSONProfile = "lossless-json-v1"
|
||||
const provenanceMode = "standalone/lossless-json-v1"
|
||||
const maxBytes = 2 << 20
|
||||
|
||||
// Admission is public, independently admitted owner configuration. TokenFile is
|
||||
// only a path to a private rotating caller credential, never a literal token.
|
||||
type Admission struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAFile string `json:"ca_file"`
|
||||
TokenFile string `json:"token_file"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
Evaluator string `json:"evaluator"`
|
||||
PolicyPackage string `json:"policy_package"`
|
||||
PolicyVersion string `json:"policy_version"`
|
||||
PolicyDigest string `json:"policy_digest"`
|
||||
RegistryDigest string `json:"registry_digest"`
|
||||
CallerPrincipal string `json:"caller_principal"`
|
||||
CallerAudience string `json:"caller_audience"`
|
||||
MaxTTL string `json:"max_ttl"`
|
||||
MaxClockSkew string `json:"max_clock_skew"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
admission Admission
|
||||
keys map[string]ed25519.PublicKey
|
||||
http *http.Client
|
||||
maxTTL, skew time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Load reads a current independently admitted file. No trust is bootstrapped
|
||||
// from a PDP response, token contents, discovery endpoint, or environment value.
|
||||
func Load(path string) (*Client, error) {
|
||||
raw, err := readFile(path, false)
|
||||
if err != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
var a Admission
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&a) != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if _, err = d.Token(); err != io.EOF {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
return New(a)
|
||||
}
|
||||
|
||||
func New(a Admission) (*Client, error) {
|
||||
u, err := url.Parse(a.Endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "/v1/check" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.TokenFile) || a.Evaluator == "" || a.PolicyPackage == "" || a.PolicyVersion == "" || a.CallerPrincipal == "" || a.CallerAudience == "" {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if !validDigest(a.PolicyDigest) || !validDigest(a.RegistryDigest) || len(a.Keys) == 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
ttl, err := time.ParseDuration(a.MaxTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
skew, err := time.ParseDuration(a.MaxClockSkew)
|
||||
if err != nil || skew < 0 || skew > ttl {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
ca, err := readFile(a.CAFile, false)
|
||||
if err != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(ca) {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
for kid, value := range a.Keys {
|
||||
b, e := base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
if e != nil || kid == "" || len(b) != ed25519.PublicKeySize {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
keys[kid] = append(ed25519.PublicKey(nil), b...)
|
||||
}
|
||||
transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrRefused }}
|
||||
return &Client{admission: a, keys: keys, http: client, maxTTL: ttl, skew: skew, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func validDigest(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") || len(value) != 71 {
|
||||
return false
|
||||
}
|
||||
_, e := hex.DecodeString(value[7:])
|
||||
return e == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func readFile(path string, private bool) ([]byte, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
defer f.Close()
|
||||
info, e := f.Stat()
|
||||
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if private && info.Mode().Perm()&0007 != 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
b, e := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
||||
if e != nil || len(b) > maxBytes {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Check sends exactly one request with a cryptographically fresh nonce. The
|
||||
// returned envelope is the producer's authenticated typed interpretation.
|
||||
// Callers must execute only the request's exact operation and honor their own
|
||||
// execution boundary; a login decision is never a later resource decision.
|
||||
func (c *Client) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
refuse := func() (api.DecisionEnvelope, error) { return api.DecisionEnvelope{}, ErrRefused }
|
||||
if c == nil || request.Purpose != "" || request.Tenant == "" || request.Subject.ID == "" || request.Subject.Type == "" || request.Resource.ID == "" || request.Resource.Type == "" || request.Resource.System == "" || request.Action == "" {
|
||||
return refuse()
|
||||
}
|
||||
if request.Subject.Tenant != request.Tenant || request.Resource.Tenant != request.Tenant {
|
||||
return refuse()
|
||||
}
|
||||
if request.PolicyVersion != "" && request.PolicyVersion != c.admission.PolicyVersion {
|
||||
return refuse()
|
||||
}
|
||||
// Use the producer's typed representation and preserve numeric lexemes. A
|
||||
// producer that rounds before hashing fails this join rather than weakening it.
|
||||
raw, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if decoder.Decode(&request) != nil {
|
||||
return refuse()
|
||||
}
|
||||
if request.Context == nil {
|
||||
request.Context = map[string]any{}
|
||||
}
|
||||
if _, exists := request.Context[nonceKey]; exists {
|
||||
return refuse()
|
||||
}
|
||||
nonce := make([]byte, 32)
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return refuse()
|
||||
}
|
||||
request.Context[nonceKey] = hex.EncodeToString(nonce)
|
||||
request.Context["decisionpep_json_profile"] = JSONProfile
|
||||
request.ID = hex.EncodeToString(nonce)
|
||||
request.PolicyVersion = c.admission.PolicyVersion
|
||||
raw, err = json.Marshal(request)
|
||||
if err != nil || len(raw) > maxBytes {
|
||||
return refuse()
|
||||
}
|
||||
token, err := readFile(c.admission.TokenFile, true)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
value := strings.TrimSpace(string(token))
|
||||
if value == "" || strings.ContainsAny(value, " \t\r\n") {
|
||||
return refuse()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Access-Engine-JSON-Profile", JSONProfile)
|
||||
req.Header.Set("Authorization", "Bearer "+value)
|
||||
started := c.now().UTC()
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return refuse()
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || len(body) > maxBytes {
|
||||
return refuse()
|
||||
}
|
||||
envelope, err := sign.DecodeEnvelope(body)
|
||||
if err != nil || sign.Verify(envelope, c.keys) != nil {
|
||||
return refuse()
|
||||
}
|
||||
if c.validate(envelope, request, started, c.now().UTC()) != nil {
|
||||
return refuse()
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func (c *Client) validate(e api.DecisionEnvelope, r api.CheckRequest, started, now time.Time) error {
|
||||
p := e.Provenance
|
||||
b := e.Binding
|
||||
l := e.Lifetime
|
||||
if e.ID == "" || e.ContractVersion != api.DecisionRecordContractV1 || e.Effect != api.DecisionEffectAllow || e.RequestID != r.ID || len(e.Obligations) != 0 || b == nil || l == nil {
|
||||
return ErrRefused
|
||||
}
|
||||
if p.Evaluator != c.admission.Evaluator || p.Mode != provenanceMode || p.PolicyPackage != c.admission.PolicyPackage || p.PolicyVersion != c.admission.PolicyVersion || p.PolicyPackageDigest != c.admission.PolicyDigest || p.RegistrySnapshotDigest != c.admission.RegistryDigest || e.MatchedPolicyVersion != p.PolicyVersion {
|
||||
return ErrRefused
|
||||
}
|
||||
if b.Purpose != "" || b.SubmittedRequestDigest != api.RequestDigest(r) || b.Tenant != r.Tenant || b.Action != r.Action || api.CanonicalDigest(b.Context) != api.CanonicalDigest(r.Context) {
|
||||
return ErrRefused
|
||||
}
|
||||
if !sameSubject(b.Subject, r.Subject) || !sameResource(b.Resource, r.Resource) || !sameSubject(e.Subject, b.Subject) || !sameResource(e.Resource, b.Resource) {
|
||||
return ErrRefused
|
||||
}
|
||||
evaluated := api.CheckRequest{Tenant: b.Tenant, Subject: b.Subject, Action: b.Action, Resource: b.Resource, Context: b.Context, Purpose: b.Purpose}
|
||||
if b.RequestDigest != api.RequestDigest(evaluated) {
|
||||
return ErrRefused
|
||||
}
|
||||
for k, v := range api.InputClaimDigests(r) {
|
||||
if p.InputClaimDigests[k] != v {
|
||||
return ErrRefused
|
||||
}
|
||||
}
|
||||
caller := p.Caller
|
||||
if caller == nil || caller.Mode != "enforce" || caller.Principal != c.admission.CallerPrincipal || caller.Audience != c.admission.CallerAudience {
|
||||
return ErrRefused
|
||||
}
|
||||
callerEnd, err := time.Parse(time.RFC3339Nano, caller.NotAfter)
|
||||
if err != nil || !now.Before(callerEnd) {
|
||||
return ErrRefused
|
||||
}
|
||||
decision, err := time.Parse(time.RFC3339Nano, p.DecisionTime)
|
||||
if err != nil || decision.Before(started.Add(-c.skew)) || decision.After(now.Add(c.skew)) {
|
||||
return ErrRefused
|
||||
}
|
||||
begin, err := time.Parse(time.RFC3339Nano, l.NotBefore)
|
||||
if err != nil || begin.After(now) {
|
||||
return ErrRefused
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339Nano, l.ExpiresAt)
|
||||
if err != nil || !now.Before(end) || !end.After(begin) || end.Sub(begin) > c.maxTTL || end.After(callerEnd) {
|
||||
return ErrRefused
|
||||
}
|
||||
ttl, err := time.ParseDuration(l.TTL)
|
||||
if err != nil || l.Kind != api.DecisionLifetimeTTL || ttl <= 0 || ttl > c.maxTTL {
|
||||
return ErrRefused
|
||||
}
|
||||
// Producer CompleteDecision uses its exact decision instant for both bounds.
|
||||
// RFC3339 output can truncate subsecond precision, so do not invent lost time.
|
||||
if !begin.Equal(decision) || end.Sub(begin) != ttl {
|
||||
return ErrRefused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameSubject(a, b api.SubjectRef) bool {
|
||||
return a.ID == b.ID && a.Type == b.Type && a.Tenant == b.Tenant
|
||||
}
|
||||
func sameResource(a, b api.ResourceRef) bool {
|
||||
return a.ID == b.ID && a.Type == b.Type && a.System == b.System && a.Tenant == b.Tenant
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package decisionpep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
type transportFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
func testClient(t *testing.T) (*Client, ed25519.PrivateKey, time.Time) {
|
||||
t.Helper()
|
||||
pub, key, e := ed25519.GenerateKey(rand.Reader)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
now := time.Date(2026, 10, 18, 1, 2, 3, 0, time.UTC)
|
||||
dir := t.TempDir()
|
||||
cert, e := x509.CreateCertificate(rand.Reader, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, pub, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ca := filepath.Join(dir, "ca.pem")
|
||||
token := filepath.Join(dir, "token")
|
||||
if e = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = os.WriteFile(token, []byte("synthetic-caller-token"), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
a := Admission{Endpoint: "https://pdp.invalid/v1/check", CAFile: ca, TokenFile: token, Keys: map[string]string{"test-key": base64.RawURLEncoding.EncodeToString(pub)}, Evaluator: "flex-auth/local", PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyDigest: "sha256:" + strings.Repeat("1", 64), RegistryDigest: "sha256:" + strings.Repeat("2", 64), CallerPrincipal: "system:serviceaccount:test:forgejo", CallerAudience: "access-engine", MaxTTL: "30s", MaxClockSkew: "1s"}
|
||||
c, e := New(a)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c.now = func() time.Time { return now }
|
||||
return c, key, now
|
||||
}
|
||||
func testRequest() api.CheckRequest {
|
||||
return api.CheckRequest{Tenant: "tenant:test", Subject: api.SubjectRef{ID: "subject:test", Type: api.SubjectType("Human"), Tenant: "tenant:test", Attributes: map[string]any{"groups": []string{"engineers"}}}, Action: "application.access", Resource: api.ResourceRef{ID: "application:forgejo", Type: "application", System: "forgejo", Tenant: "tenant:test"}, Context: map[string]any{"big": json.Number("9007199254740993"), "fraction": json.Number("0.123456789123456789")}}
|
||||
}
|
||||
func envelope(c *Client, r api.CheckRequest, key ed25519.PrivateKey, now time.Time) api.DecisionEnvelope {
|
||||
e := api.DecisionEnvelope{ID: "test-decision", RequestID: r.ID, Effect: api.DecisionEffectAllow, MatchedPolicyVersion: c.admission.PolicyVersion, Subject: r.Subject, Resource: r.Resource, Binding: api.NewDecisionBindingFor(r, r), Provenance: api.DecisionProvenance{Evaluator: c.admission.Evaluator, Mode: provenanceMode, PolicyPackage: c.admission.PolicyPackage, PolicyVersion: c.admission.PolicyVersion, PolicyPackageDigest: c.admission.PolicyDigest, RegistrySnapshotDigest: c.admission.RegistryDigest, Caller: &api.CallerProvenance{Mode: "enforce", Principal: c.admission.CallerPrincipal, Audience: c.admission.CallerAudience, NotAfter: now.Add(time.Minute).Format(time.RFC3339Nano)}}}
|
||||
api.CompleteDecision(&e, r, api.DecisionCompletion{AllowTTL: "10s", Now: now})
|
||||
if sign.Sign(&e, key, "test-key") != nil {
|
||||
panic("test sign")
|
||||
}
|
||||
return e
|
||||
}
|
||||
func respond(t *testing.T, c *Client, key ed25519.PrivateKey, now time.Time, mutate func(*api.DecisionEnvelope), resign bool) {
|
||||
t.Helper()
|
||||
c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("Access-Engine-JSON-Profile") != JSONProfile { t.Fatal("missing lossless profile") };
|
||||
if req.Header.Get("Authorization") != "Bearer synthetic-caller-token" {
|
||||
t.Fatal("wrong synthetic caller token")
|
||||
}
|
||||
var r api.CheckRequest
|
||||
d := json.NewDecoder(req.Body)
|
||||
d.UseNumber()
|
||||
if d.Decode(&r) != nil {
|
||||
t.Fatal("request decode")
|
||||
}
|
||||
if r.Context["big"] != json.Number("9007199254740993") || r.Context["fraction"] != json.Number("0.123456789123456789") {
|
||||
t.Fatal("request numeric precision lost")
|
||||
}
|
||||
if len(r.Context[nonceKey].(string)) != 64 || r.ID != r.Context[nonceKey] {
|
||||
t.Fatal("nonce")
|
||||
}
|
||||
e := envelope(c, r, key, now)
|
||||
if mutate != nil {
|
||||
mutate(&e)
|
||||
}
|
||||
if resign {
|
||||
if sign.Sign(&e, key, "test-key") != nil {
|
||||
t.Fatal("sign")
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{}}, nil
|
||||
})}
|
||||
}
|
||||
func TestAuthenticDecisionProducerCanonicalPrecision(t *testing.T) {
|
||||
c, key, now := testClient(t)
|
||||
respond(t, c, key, now, nil, false)
|
||||
e, err := c.Check(context.Background(), testRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Binding.Context["big"] != json.Number("9007199254740993") || e.Binding.Context["fraction"] != json.Number("0.123456789123456789") {
|
||||
t.Fatal("signed typed precision lost")
|
||||
}
|
||||
if sign.Verify(e, c.keys) != nil {
|
||||
t.Fatal("returned envelope not authentic producer bytes")
|
||||
}
|
||||
}
|
||||
func TestDecisionAdverseBindings(t *testing.T) {
|
||||
cases := map[string]func(*api.DecisionEnvelope){
|
||||
"unsigned": func(e *api.DecisionEnvelope) { e.Signature = api.UnsignedEnvelopeSignature() },
|
||||
"effect": func(e *api.DecisionEnvelope) { e.Effect = api.DecisionEffectDeny },
|
||||
"evaluator": func(e *api.DecisionEnvelope) { e.Provenance.Evaluator = "impostor" },
|
||||
"policy": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackage = "other" },
|
||||
"version": func(e *api.DecisionEnvelope) { e.Provenance.PolicyVersion = "v2" },
|
||||
"policy-digest": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackageDigest = "sha256:" + strings.Repeat("3", 64) },
|
||||
"registry": func(e *api.DecisionEnvelope) {
|
||||
e.Provenance.RegistrySnapshotDigest = "sha256:" + strings.Repeat("4", 64)
|
||||
},
|
||||
"tenant": func(e *api.DecisionEnvelope) { e.Binding.Tenant = "other" },
|
||||
"actor": func(e *api.DecisionEnvelope) { e.Binding.Subject.ID = "other" },
|
||||
"action": func(e *api.DecisionEnvelope) { e.Binding.Action = "write" },
|
||||
"resource": func(e *api.DecisionEnvelope) { e.Binding.Resource.ID = "other" },
|
||||
"purpose": func(e *api.DecisionEnvelope) { e.Binding.Purpose = "readback" },
|
||||
"submitted": func(e *api.DecisionEnvelope) { e.Binding.SubmittedRequestDigest = "wrong" },
|
||||
"context": func(e *api.DecisionEnvelope) { e.Binding.Context["fraction"] = json.Number("0.123456789123456788") },
|
||||
"legacy-numeric-profile": func(e *api.DecisionEnvelope) { e.Provenance.Mode = "standalone" },
|
||||
"caller-warn": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Mode = "warn" },
|
||||
"caller-principal": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Principal = "other" },
|
||||
"caller-audience": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Audience = "other" },
|
||||
"caller-expired": func(e *api.DecisionEnvelope) { e.Provenance.Caller.NotAfter = "2020-01-01T00:00:00Z" },
|
||||
"stale-decision": func(e *api.DecisionEnvelope) { e.Provenance.DecisionTime = "2020-01-01T00:00:00Z" },
|
||||
"expired": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2020-01-01T00:00:00Z" },
|
||||
"future-begin": func(e *api.DecisionEnvelope) { e.Lifetime.NotBefore = "2030-01-01T00:00:00Z" },
|
||||
"subnanosecond-expiry": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2026-10-18T01:02:02.999999999Z" },
|
||||
"missing-TTL": func(e *api.DecisionEnvelope) { e.Lifetime = nil },
|
||||
"excess-TTL": func(e *api.DecisionEnvelope) { e.Lifetime.TTL = "1h" },
|
||||
"obligation": func(e *api.DecisionEnvelope) { e.Obligations = []api.Obligation{{Type: "unimplemented"}} },
|
||||
}
|
||||
for name, mutation := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c, key, now := testClient(t)
|
||||
respond(t, c, key, now, mutation, name != "unsigned")
|
||||
if _, err := c.Check(context.Background(), testRequest()); err == nil {
|
||||
t.Fatal("accepted wrong signed binding")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSignatureForgeryAndReplay(t *testing.T) {
|
||||
t.Run("forged-payload", func(t *testing.T) {
|
||||
c, key, now := testClient(t)
|
||||
respond(t, c, key, now, func(e *api.DecisionEnvelope) { e.Reason = "changed after signature" }, false)
|
||||
if _, e := c.Check(context.Background(), testRequest()); e == nil {
|
||||
t.Fatal("forgery accepted")
|
||||
}
|
||||
})
|
||||
t.Run("wrong-key", func(t *testing.T) {
|
||||
c, _, now := testClient(t)
|
||||
_, key, _ := ed25519.GenerateKey(rand.Reader)
|
||||
respond(t, c, key, now, nil, false)
|
||||
if _, e := c.Check(context.Background(), testRequest()); e == nil {
|
||||
t.Fatal("wrong key accepted")
|
||||
}
|
||||
})
|
||||
t.Run("replay", func(t *testing.T) {
|
||||
c, key, now := testClient(t)
|
||||
var saved []byte
|
||||
c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if saved == nil {
|
||||
var r api.CheckRequest
|
||||
d := json.NewDecoder(req.Body)
|
||||
d.UseNumber()
|
||||
if d.Decode(&r) != nil {
|
||||
t.Fatal("decode")
|
||||
}
|
||||
e := envelope(c, r, key, now)
|
||||
saved, _ = json.Marshal(e)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(saved)))}, nil
|
||||
})}
|
||||
if _, e := c.Check(context.Background(), testRequest()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := c.Check(context.Background(), testRequest()); e == nil {
|
||||
t.Fatal("old decision replay accepted for new nonce")
|
||||
}
|
||||
})
|
||||
}
|
||||
func TestUnavailableAndPublicTokenFileRefuse(t *testing.T) {
|
||||
c, _, _ := testClient(t)
|
||||
c.http = &http.Client{Transport: transportFunc(func(*http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF })}
|
||||
if _, e := c.Check(context.Background(), testRequest()); e == nil {
|
||||
t.Fatal("unavailable allowed")
|
||||
}
|
||||
if e := os.Chmod(c.admission.TokenFile, 0644); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := c.Check(context.Background(), testRequest()); e == nil {
|
||||
t.Fatal("public token file allowed")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"upstream": "github.com/netkingdom/access-engine",
|
||||
"commit": "d328a2601095686f74376d87791bff01e2a3a2a3",
|
||||
"exact_unchanged_files": {
|
||||
"pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19",
|
||||
"pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3",
|
||||
"pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f",
|
||||
"internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde",
|
||||
"LICENSE": "ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6"
|
||||
},
|
||||
"module": "Standalone Go1.24 source slice with original module/import identity; no canonical/schema/signature modifications",
|
||||
"new_code": [
|
||||
"pkg/decisionpep/client.go"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/internal/decision"
|
||||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/internal/registry"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"github.com/netkingdom/access-engine/pkg/decisionpep"
|
||||
)
|
||||
|
||||
type forgejoSyntheticReviewer struct{}
|
||||
func(forgejoSyntheticReviewer)Review(_ context.Context,token string)(callerauth.Identity,error){
|
||||
if token!="synthetic-forgejo-caller"{return callerauth.Identity{},callerauth.ErrUnauthenticated}
|
||||
return callerauth.Identity{Username:"system:serviceaccount:synthetic:forgejo",Audiences:[]string{"access-engine"},NotAfter:time.Now().Add(time.Minute)},nil
|
||||
}
|
||||
func forgejoRequest(group string)api.CheckRequest{
|
||||
groups:=[]string{group};return api.CheckRequest{Tenant:"tenant:synthetic",Subject:api.SubjectRef{ID:"user:synthetic",Type:"human",Tenant:"tenant:synthetic",Attributes:map[string]any{"issuer":"https://issuer.invalid","assurance":"aal2","current_userinfo_groups":groups}},Action:"application.access",Resource:api.ResourceRef{ID:"application:forgejo",Type:"application",System:"forgejo",Tenant:"tenant:synthetic"},Context:map[string]any{"identity_tenant":"tenant:synthetic","identity_subject":"user:synthetic","identity_principal_type":"human","identity_issuer":"https://issuer.invalid","identity_assurance":"aal2","current_userinfo_groups":groups,"required_groups_any_of":[]string{"forge-admins","forge-coulomb-owners","forge-coulomb-readers"},"big":json.Number("9007199254740993"),"fraction":json.Number("0.123456789123456789")}}
|
||||
}
|
||||
|
||||
func TestForgejoActualPolicySignedLosslessAndLegacy(t *testing.T){
|
||||
ctx:=context.Background();pkg,e:=policy.LoadAndValidateFile(ctx,filepath.Join("..","..","examples","forgejo-application-access","policy.md"));if e!=nil||!pkg.Valid{t.Fatalf("policy: %v %+v",e,pkg)}
|
||||
store,e:=registry.LoadFilePreservingNumbers(filepath.Join("..","..","examples","forgejo-application-access","registry.synthetic.json"));if e!=nil{t.Fatal(e)}
|
||||
engine,e:=decision.NewEngine(store,pkg);if e!=nil{t.Fatal(e)}
|
||||
pub,key,e:=ed25519.GenerateKey(rand.Reader);if e!=nil{t.Fatal(e)};if e=engine.SetEnvelopeSigningKey(key,"synthetic-pdp");e!=nil{t.Fatal(e)}
|
||||
auth,e:=callerauth.New(callerauth.ModeEnforce,forgejoSyntheticReviewer{},"access-engine",map[string]string{"forgejo":"system:serviceaccount:synthetic:forgejo"},nil);if e!=nil{t.Fatal(e)}
|
||||
server:=httptest.NewTLSServer(newServeMuxWithCallerAuth(engine,auth));defer server.Close()
|
||||
folder:=t.TempDir();ca:=filepath.Join(folder,"ca.pem");token:=filepath.Join(folder,"token")
|
||||
os.WriteFile(ca,pem.EncodeToMemory(&pem.Block{Type:"CERTIFICATE",Bytes:server.Certificate().Raw}),0600);os.WriteFile(token,[]byte("synthetic-forgejo-caller"),0600)
|
||||
admitted:=decisionpep.Admission{Endpoint:server.URL+"/v1/check",CAFile:ca,TokenFile:token,Keys:map[string]string{"synthetic-pdp":base64.RawURLEncoding.EncodeToString(pub)},Evaluator:"flex-auth/local",PolicyPackage:pkg.Metadata.ID,PolicyVersion:pkg.Metadata.Version,PolicyDigest:pkg.Digest(),RegistryDigest:store.Digest(),CallerPrincipal:"system:serviceaccount:synthetic:forgejo",CallerAudience:"access-engine",MaxTTL:"30s",MaxClockSkew:"2s"}
|
||||
client,e:=decisionpep.New(admitted);if e!=nil{t.Fatal(e)}
|
||||
for _,g:=range []string{"forge-admins","forge-coulomb-owners","forge-coulomb-readers"}{t.Run(g,func(t *testing.T){got,e:=client.Check(ctx,forgejoRequest(g));if e!=nil{t.Fatal(e)};if got.Binding.Context["big"]!=json.Number("9007199254740993")||got.Binding.Context["fraction"]!=json.Number("0.123456789123456789"){t.Fatal("actual producer numeric precision lost")};if got.Provenance.Mode!="standalone/lossless-json-v1"||sign.Verify(got,map[string]ed25519.PublicKey{"synthetic-pdp":pub})!=nil{t.Fatal("actual producer profile not authenticated")}})}
|
||||
for name,mutate:=range map[string]func(*api.CheckRequest){"outsider":func(r *api.CheckRequest){r.Context["current_userinfo_groups"]=[]string{"outsider"};r.Subject.Attributes["current_userinfo_groups"]=[]string{"outsider"}},"empty-current-groups":func(r *api.CheckRequest){r.Context["current_userinfo_groups"]=[]string{};r.Subject.Attributes["current_userinfo_groups"]=[]string{}},"wrong-tenant":func(r *api.CheckRequest){r.Tenant="tenant:other";r.Subject.Tenant=r.Tenant;r.Resource.Tenant=r.Tenant;r.Context["identity_tenant"]=r.Tenant},"wrong-issuer":func(r *api.CheckRequest){r.Subject.Attributes["issuer"]="https://other.invalid";r.Context["identity_issuer"]="https://other.invalid"},"wrong-assurance":func(r *api.CheckRequest){r.Subject.Attributes["assurance"]="aal1";r.Context["identity_assurance"]="aal1"},"wrong-action":func(r *api.CheckRequest){r.Action="source.operation"},"wrong-resource":func(r *api.CheckRequest){r.Resource.ID="application:unregistered"},"claim-shortcut":func(r *api.CheckRequest){r.Context["required_groups_any_of"]=[]string{"outsider"};r.Context["current_userinfo_groups"]=[]string{"outsider"};r.Subject.Attributes["current_userinfo_groups"]=[]string{"outsider"}}}{t.Run(name,func(t *testing.T){r:=forgejoRequest("forge-admins");mutate(&r);if _,e:=client.Check(ctx,r);e==nil{t.Fatal("actual PDP allowed adverse input")}})}
|
||||
// A caller-provided JSON key cannot install the producer's private Go context
|
||||
// key. This path uses the actual normal handler, not a constructed envelope.
|
||||
r:=forgejoRequest("forge-admins");r.Context["decisionpep_request_nonce"]=strings.Repeat("0",64);r.Context["decisionpep_json_profile"]=decisionpep.JSONProfile;r.Context["jsonProfileContextKey"] = decisionpep.JSONProfile
|
||||
raw,_:=json.Marshal(r)
|
||||
for _,profile:=range []string{"",decisionpep.JSONProfile,"unknown"}{
|
||||
req,_:=http.NewRequest(http.MethodPost,server.URL+"/v1/check",bytes.NewReader(raw));req.Header.Set("Authorization","Bearer synthetic-forgejo-caller");if profile!=""{req.Header.Set("Access-Engine-JSON-Profile",profile)}
|
||||
response,e:=server.Client().Do(req);if e!=nil{t.Fatal(e)};body,_:=io.ReadAll(response.Body);response.Body.Close()
|
||||
if profile=="unknown"{if response.StatusCode!=400{t.Fatal("unknown profile not refused")};continue}
|
||||
e1,e:=sign.DecodeEnvelope(body);if e!=nil{t.Fatal(e)};if sign.Verify(e1,map[string]ed25519.PublicKey{"synthetic-pdp":pub})!=nil{t.Fatal("producer signature")}
|
||||
if profile==""{if e1.Provenance.Mode!="standalone"{t.Fatal("caller JSON spoofed producer profile")};if e1.Binding.SubmittedRequestDigest==api.RequestDigest(r){t.Fatal("legacy numeric projection unexpectedly changed")}}else{if e1.Provenance.Mode!="standalone/lossless-json-v1"||e1.Binding.SubmittedRequestDigest!=api.RequestDigest(r){t.Fatal("lossless submitted binding mismatch")}}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,766 @@
|
|||
// Command flex-auth is the CLI entry point for the flex-auth authorization
|
||||
// registry and control plane.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/audit"
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
decisioncore "github.com/netkingdom/access-engine/internal/decision"
|
||||
"github.com/netkingdom/access-engine/internal/emission"
|
||||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/internal/registry"
|
||||
"github.com/netkingdom/access-engine/internal/taskexecution"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X main.version=...".
|
||||
var version = "0.0.0-dev"
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 64
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version", "--version", "-version":
|
||||
fmt.Fprintln(stdout, version)
|
||||
return 0
|
||||
case "validate":
|
||||
return runValidate(args[1:], stdout, stderr)
|
||||
case "load-registry":
|
||||
return runLoadRegistry(args[1:], stdout, stderr)
|
||||
case "test-policy":
|
||||
return runTestPolicy(args[1:], stdout, stderr)
|
||||
case "check":
|
||||
return runCheck(args[1:], stdout, stderr)
|
||||
case "batch-check":
|
||||
return runBatchCheck(args[1:], stdout, stderr)
|
||||
case "list-allowed":
|
||||
return runListAllowed(args[1:], stdout, stderr)
|
||||
case "explain":
|
||||
return runExplain(args[1:], stdout, stderr)
|
||||
case "serve":
|
||||
return runServe(args[1:], stdout, stderr)
|
||||
case "serve-readback":
|
||||
return runServeReadback(args[1:], stdout, stderr)
|
||||
case "help", "-h", "--help":
|
||||
printUsage(stdout)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "access-engine: unknown subcommand %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 64
|
||||
}
|
||||
}
|
||||
|
||||
func runValidate(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("validate", stderr)
|
||||
kind := fs.String("kind", "", "resource-manifest, subject-manifest, protected-system, relationship, access-descriptor, or policy")
|
||||
file := fs.String("file", "", "YAML/JSON file to validate")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *kind == "" || *file == "" {
|
||||
fmt.Fprintln(stderr, "validate requires --kind and --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
switch *kind {
|
||||
case "resource-manifest":
|
||||
var manifest api.ResourceManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().ImportResourceManifest(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "subject-manifest":
|
||||
var manifest api.SubjectManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().ImportSubjectManifest(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "protected-system":
|
||||
var manifest api.ProtectedSystemManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().PutProtectedSystem(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "relationship":
|
||||
var fact api.RelationshipFact
|
||||
if err := readYAML(*file, &fact); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().PutRelationship(fact); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "access-descriptor":
|
||||
var descriptor api.CaringAccessDescriptor
|
||||
if err := readYAML(*file, &descriptor); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := validateDescriptor(descriptor); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "policy", "policy-package":
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, pkg.Validation); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !pkg.Valid {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unsupported validate kind %q\n", *kind)
|
||||
return 64
|
||||
}
|
||||
|
||||
return writeStatus(stdout, "valid", map[string]any{"kind": *kind, "file": *file})
|
||||
}
|
||||
|
||||
func runLoadRegistry(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("load-registry", stderr)
|
||||
file := fs.String("file", "", "registry snapshot JSON file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *file == "" {
|
||||
fmt.Fprintln(stderr, "load-registry requires --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
store, err := registry.LoadFile(*file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
snapshot := store.Snapshot()
|
||||
return writeStatus(stdout, "loaded", map[string]any{
|
||||
"file": *file,
|
||||
"systems": len(snapshot.Systems),
|
||||
"resource_manifests": len(snapshot.ResourceManifests),
|
||||
"subjects": len(snapshot.Subjects),
|
||||
"groups": len(snapshot.Groups),
|
||||
"teams": len(snapshot.Teams),
|
||||
"tenants": len(snapshot.Tenants),
|
||||
"relationships": len(snapshot.Relationships),
|
||||
})
|
||||
}
|
||||
|
||||
func runTestPolicy(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("test-policy", stderr)
|
||||
file := fs.String("file", "", "policy package Markdown file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *file == "" {
|
||||
fmt.Fprintln(stderr, "test-policy requires --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, pkg.Validation); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !pkg.Valid {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCheck(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("check", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
requestPath := fs.String("request", "", "check request YAML/JSON file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
|
||||
fmt.Fprintln(stderr, "check requires --registry, --policy, and --request")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := readYAML(*requestPath, &request); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decision, err := engine.Check(context.Background(), request)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decision); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runBatchCheck(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("batch-check", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
requestPath := fs.String("request", "", "batch check request YAML/JSON file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
|
||||
fmt.Fprintln(stderr, "batch-check requires --registry, --policy, and --request")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var request api.BatchCheckRequest
|
||||
if err := readYAML(*requestPath, &request); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decisions, err := engine.BatchCheck(context.Background(), request)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decisions); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runListAllowed(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("list-allowed", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
subject := fs.String("subject", "", "subject id")
|
||||
action := fs.String("action", "", "action name")
|
||||
system := fs.String("system", "", "protected system id")
|
||||
resourceType := fs.String("resource-type", "", "resource type")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
var filters keyValueFlags
|
||||
fs.Var(&filters, "filter", "resource filter as key=value; may be repeated")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *subject == "" || *action == "" {
|
||||
fmt.Fprintln(stderr, "list-allowed requires --registry, --policy, --subject, and --action")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decisions, err := engine.ListAllowed(context.Background(), decisioncore.ListAllowedRequest{
|
||||
Subject: api.SubjectRef{ID: *subject},
|
||||
Action: *action,
|
||||
System: *system,
|
||||
ResourceType: *resourceType,
|
||||
Filters: filters.Map(),
|
||||
})
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decisions); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runExplain(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("explain", stderr)
|
||||
logPath := fs.String("decision-log", "", "JSONL decision log path")
|
||||
decisionID := fs.String("decision-id", "", "decision id to explain")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *logPath == "" || *decisionID == "" {
|
||||
fmt.Fprintln(stderr, "explain requires --decision-log and --decision-id")
|
||||
return 64
|
||||
}
|
||||
|
||||
log := audit.NewJSONLDecisionLog(*logPath)
|
||||
envelope, ok, err := log.Find(*decisionID)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !ok {
|
||||
return fail(stderr, fmt.Errorf("decision %q not found", *decisionID))
|
||||
}
|
||||
if err := writeJSON(stdout, decisioncore.ExplainEnvelope(envelope)); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runServe(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("serve", stderr)
|
||||
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
signingKeyFile := fs.String("envelope-signing-key-file", "", "owner-mounted base64url Ed25519 seed file")
|
||||
signingKID := fs.String("envelope-signing-kid", "", "independently admitted envelope signing key id")
|
||||
approvalSourceFile := fs.String("approval-source-file", "", "owner fixed Approval HTTPS reader configuration file")
|
||||
outboxMaxBytes := fs.Int64("outbox-max-bytes", emission.MaxOutboxBytes, "bounded outbox payload bytes, maximum67108864")
|
||||
outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log")
|
||||
emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine")
|
||||
auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats")
|
||||
auditTokenFile := fs.String("audit-token-file", "", "mounted sender token file, never a token value")
|
||||
callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce")
|
||||
callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience")
|
||||
callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview")
|
||||
callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential")
|
||||
callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle")
|
||||
var callerBindings keyValueFlags
|
||||
fs.Var(&callerBindings, "caller-binding", "resource system=authenticated Kubernetes principal (repeatable)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" {
|
||||
fmt.Fprintln(stderr, "serve requires --registry and --policy")
|
||||
return 64
|
||||
}
|
||||
|
||||
if *outboxDir != "" && (*logPath != "" || *emissionSource == "") {
|
||||
fmt.Fprintln(stderr, "serve --outbox-dir requires --emission-source and excludes --log")
|
||||
return 64
|
||||
}
|
||||
delivery := emission.DeliveryConfig{URL: *auditURL, TokenFile: *auditTokenFile}
|
||||
if *auditURL != "" || *auditTokenFile != "" {
|
||||
if *outboxDir == "" {
|
||||
fmt.Fprintln(stderr, "audit delivery requires --outbox-dir")
|
||||
return 64
|
||||
}
|
||||
if err := delivery.Validate(); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 64
|
||||
}
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := configureEnvelopeSigning(engine, *signingKeyFile, *signingKID); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var outbox *emission.Outbox
|
||||
if *outboxDir != "" {
|
||||
outbox, err = emission.OpenBounded(*outboxDir, *emissionSource, *outboxMaxBytes)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
defer outbox.Close()
|
||||
engine.SetDecisionLog(outbox)
|
||||
}
|
||||
|
||||
authenticator, err := buildCallerAuthenticator(
|
||||
callerauth.Mode(*callerAuthMode),
|
||||
*callerAudience,
|
||||
*callerKubernetesURL,
|
||||
*callerReviewerTokenFile,
|
||||
*callerCAFile,
|
||||
callerBindings.StringMap(),
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
|
||||
serviceInputs, err := buildServicePilotInputs(engine, callerauth.Mode(*callerAuthMode), *callerAudience, *callerKubernetesURL, *callerReviewerTokenFile, *callerCAFile, callerBindings.StringMap())
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var approvalReader *taskexecution.ApprovalReader
|
||||
if engine.IsTrustedServicePilot() {
|
||||
if *signingKeyFile == "" || *approvalSourceFile == "" {
|
||||
return fail(stderr, fmt.Errorf("trusted task source requires signing and fixed Approval reader configuration"))
|
||||
}
|
||||
approvalReader, err = taskexecution.LoadApprovalReader(*approvalSourceFile)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
} else if *approvalSourceFile != "" {
|
||||
return fail(stderr, fmt.Errorf("Approval reader requires exact trusted task source package"))
|
||||
}
|
||||
mux := newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, approvalReader)
|
||||
if outbox != nil {
|
||||
mountEmission(mux, outbox)
|
||||
}
|
||||
if *auditURL != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runEmissionDelivery(ctx, outbox, delivery, stderr)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "access-engine serving on http://%s\n", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runEmissionDelivery(ctx context.Context, outbox *emission.Outbox, config emission.DeliveryConfig, stderr io.Writer) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := outbox.AppendDueHeartbeats(); err != nil {
|
||||
fmt.Fprintf(stderr, "emission heartbeat commit: %v\n", err)
|
||||
}
|
||||
n, err := outbox.Drain(ctx, config, 100)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintf(stderr, "emission drain: %v\n", err)
|
||||
}
|
||||
if err == nil && n == 100 {
|
||||
continue // Drain the next bounded batch without imposing a rate cap.
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
|
||||
return newServeMuxWithCallerAuth(engine, callerauth.Disabled())
|
||||
}
|
||||
|
||||
func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux {
|
||||
return newServeMuxWithServicePilotInputs(engine, authenticator, nil)
|
||||
}
|
||||
|
||||
func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs) *http.ServeMux {
|
||||
return newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, nil)
|
||||
}
|
||||
func newServeMuxWithTaskFacts(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs, approvalReader *taskexecution.ApprovalReader) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if engine.IsTaskPin() {
|
||||
if serviceInputs == nil {
|
||||
http.Error(w, "service input unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
decode := taskexecution.DecodeServiceRequest
|
||||
if engine.IsTrustedServicePilot() {
|
||||
decode = taskexecution.DecodeTrustedServiceRequest
|
||||
}
|
||||
request, err := decode(r.Body)
|
||||
if err != nil || request.Purpose != "" {
|
||||
http.Error(w, "invalid service task request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
input, err := serviceInputs.Prepare(r.Context(), r.Header.Get("Authorization"), request, time.Now().UTC())
|
||||
if err != nil {
|
||||
http.Error(w, "service task input refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var decision api.DecisionEnvelope
|
||||
if engine.IsTrustedServicePilot() {
|
||||
decision, err = engine.CheckTrustedService(r.Context(), input, approvalReader)
|
||||
} else {
|
||||
decision, err = engine.CheckServicePilot(r.Context(), input)
|
||||
}
|
||||
writeHTTP(w, decision, err)
|
||||
return
|
||||
}
|
||||
profile := r.Header.Get("Access-Engine-JSON-Profile")
|
||||
if profile != "" && profile != decisioncore.LosslessJSONProfile {
|
||||
http.Error(w, "unsupported JSON numeric profile", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var request api.CheckRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if profile == decisioncore.LosslessJSONProfile { decoder.UseNumber() }
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.Purpose != "" {
|
||||
// Only the readback pin issues purpose-bearing decisions
|
||||
// (FLEX-DEC-2026-019); an enforcement pin never does.
|
||||
http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System})
|
||||
if err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decisionContext := callerauth.WithRecord(r.Context(), authenticator.Record(identity))
|
||||
if profile == decisioncore.LosslessJSONProfile { decisionContext = decisioncore.WithLosslessJSONProfile(decisionContext) }
|
||||
decision, err := engine.Check(decisionContext, request)
|
||||
writeHTTP(w, decision, err)
|
||||
})
|
||||
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if engine.IsTaskPin() {
|
||||
http.Error(w, "service task batch unavailable", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request api.BatchCheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
systems := make([]string, 0, len(request.Resources))
|
||||
for _, resource := range request.Resources {
|
||||
systems = append(systems, resource.System)
|
||||
}
|
||||
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems)
|
||||
if err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||
writeHTTP(w, decisions, err)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// Only the existing exact service pilot package selects this source path.
|
||||
// This is not an eligibility toggle; successful preparation still only DENYs.
|
||||
func buildServicePilotInputs(engine *decisioncore.Engine, mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string) (*taskexecution.ServicePilotInputs, error) {
|
||||
if !engine.IsTaskPin() {
|
||||
return nil, nil
|
||||
}
|
||||
if mode != callerauth.ModeEnforce || audience != "flex-auth" || len(bindings) != 1 || bindings["secrets-engine"] != taskexecution.ServicePilotPrincipal {
|
||||
return nil, fmt.Errorf("service pilot requires exact enforced retained caller mapping")
|
||||
}
|
||||
reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if engine.IsTrustedServicePilot() {
|
||||
return taskexecution.NewTrustedServiceInputs(reviewer)
|
||||
}
|
||||
return taskexecution.NewServicePilotInputs(reviewer)
|
||||
}
|
||||
|
||||
func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) {
|
||||
if mode == callerauth.ModeDisabled {
|
||||
return callerauth.New(mode, nil, "", nil, nil)
|
||||
}
|
||||
reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return callerauth.New(mode, reviewer, audience, bindings, func(format string, args ...any) {
|
||||
fmt.Fprintf(stderr, format+"\n", args...)
|
||||
})
|
||||
}
|
||||
|
||||
func writeCallerAuthError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, callerauth.ErrUnauthenticated):
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
case errors.Is(err, callerauth.ErrForbidden):
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, "caller authentication unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) {
|
||||
pkg, err := policy.LoadAndValidateFile(ctx, policyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
load := registry.LoadFile
|
||||
if pkg.Metadata.ID == taskexecution.ServicePilotPackageID && pkg.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion {
|
||||
load = registry.LoadFilePreservingNumbers
|
||||
}
|
||||
store, err := load(registryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := decisioncore.NewEngine(store, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logPath != "" {
|
||||
engine.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func validateDescriptor(descriptor api.CaringAccessDescriptor) error {
|
||||
if descriptor.Profile != api.CaringProfileCaring040RC2 {
|
||||
return fmt.Errorf("unsupported CARING profile %q", descriptor.Profile)
|
||||
}
|
||||
if descriptor.SubjectType == "" {
|
||||
return fmt.Errorf("subject_type is required")
|
||||
}
|
||||
if descriptor.OrganizationRelation == "" {
|
||||
return fmt.Errorf("organization_relation is required")
|
||||
}
|
||||
if descriptor.CanonicalRole == "" {
|
||||
return fmt.Errorf("canonical_role is required")
|
||||
}
|
||||
if descriptor.Scope.Level == "" || descriptor.Scope.ID == "" {
|
||||
return fmt.Errorf("scope.level and scope.id are required")
|
||||
}
|
||||
if len(descriptor.Planes) == 0 {
|
||||
return fmt.Errorf("at least one plane is required")
|
||||
}
|
||||
if len(descriptor.Capabilities) == 0 {
|
||||
return fmt.Errorf("at least one capability is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readYAML(path string, out any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("unmarshal %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStatus(w io.Writer, status string, extra map[string]any) int {
|
||||
out := map[string]any{"status": status}
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
if err := writeJSON(w, out); err != nil {
|
||||
return fail(w, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mountEmission exposes the local half of reconciliation: committed and
|
||||
// released-uncommitted counts per class. Counts only, never records.
|
||||
func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) {
|
||||
mux.HandleFunc("/v1/emission", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeHTTP(w, outbox.Status(), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func writeHTTP(w http.ResponseWriter, value any, err error) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
if errors.Is(err, decisioncore.ErrRecordNotCommitted) || errors.Is(err, decisioncore.ErrServicePilotUnavailable) {
|
||||
// Unavailable evaluation is not an authorization verdict (GH-DEC-2026-024).
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
func fail(stderr io.Writer, err error) int {
|
||||
fmt.Fprintln(stderr, "access-engine:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage: access-engine <command> [options]")
|
||||
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve, serve-readback")
|
||||
}
|
||||
|
||||
type keyValueFlags []string
|
||||
|
||||
func (f *keyValueFlags) String() string {
|
||||
return strings.Join(*f, ",")
|
||||
}
|
||||
|
||||
func (f *keyValueFlags) Set(value string) error {
|
||||
if !strings.Contains(value, "=") {
|
||||
return fmt.Errorf("filter must be key=value")
|
||||
}
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f keyValueFlags) Map() map[string]any {
|
||||
out := make(map[string]any, len(f))
|
||||
for _, item := range f {
|
||||
key, value, _ := strings.Cut(item, "=")
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f keyValueFlags) StringMap() map[string]string {
|
||||
out := make(map[string]string, len(f))
|
||||
for _, item := range f {
|
||||
key, value, _ := strings.Cut(item, "=")
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
id: forgejo.application-access
|
||||
name: Forgejo authenticated application access
|
||||
namespace: forgejo:application
|
||||
version: v1
|
||||
status: draft
|
||||
package: flexauth.forgejo.application
|
||||
allow_ttl: 10s
|
||||
actions: [application.access]
|
||||
owner: railiance-apps
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
---
|
||||
|
||||
This source policy enforces the original three-group any-of in the sole PDP.
|
||||
The registry identifies the application tenant and admitted issuer. This policy requires aal2 assurance.
|
||||
The PEP independently verifies the ID token and supplies fresh UserInfo groups;
|
||||
the authenticated workload caller may represent only the Forgejo system. A
|
||||
decision authorizes one login callback, never a later HTTP/Git/SSH/API operation.
|
||||
The accompanying registry is synthetic test data, not an estate admission.
|
||||
|
||||
```rego
|
||||
import rego.v1
|
||||
|
||||
default decision := {"effect": "deny", "reason": "application_access_refused"}
|
||||
|
||||
original_groups := {"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}
|
||||
|
||||
decision := {"effect": "allow", "reason": "current_authenticated_application_access"} if {
|
||||
input.action == "application.access"
|
||||
object.get(input, "purpose", "") == ""
|
||||
input.resource.id == "application:forgejo"
|
||||
input.resource.type == "application"
|
||||
input.resource.system == "forgejo"
|
||||
input.tenant == input.resource.tenant
|
||||
input.tenant == input.resource.attributes.tenant
|
||||
input.subject.tenant == input.tenant
|
||||
input.context.identity_tenant == input.tenant
|
||||
input.subject.id == input.context.identity_subject
|
||||
input.subject.type == input.context.identity_principal_type
|
||||
input.subject.type == "human"
|
||||
input.subject.attributes.issuer == input.context.identity_issuer
|
||||
input.context.identity_issuer == input.resource.attributes.identity_issuer
|
||||
input.subject.attributes.assurance == input.context.identity_assurance
|
||||
input.context.identity_assurance == "aal2"
|
||||
count(input.context.required_groups_any_of) == 3
|
||||
{g | g := input.context.required_groups_any_of[_]} == original_groups
|
||||
input.subject.attributes.current_userinfo_groups == input.context.current_userinfo_groups
|
||||
some group in input.context.current_userinfo_groups
|
||||
group in original_groups
|
||||
is_string(input.context.decisionpep_request_nonce)
|
||||
count(input.context.decisionpep_request_nonce) == 64
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue