net-kingdom/tools/bootstrap-recovery/identity-credential-setup.py
tegwick 8857d9ff6f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
security(recovery): fence agent reads before publishing backup credentials
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 07:49:48 +02:00

254 lines
14 KiB
Python

#!/usr/bin/env python3
"""Attended Platform/Backup owner ceremony for two exact new backup shares.
Requires approved, hash-bound CCRs. No operator password/age key is delivered to
the runtime; only a folder-scoped share token is stored with initial KV CAS=0.
"""
import argparse
import base64
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
HERE=Path(__file__).resolve().parent
PLATFORM=HERE.parents[1].parent/'railiance-platform'
sys.path.insert(0,str(PLATFORM/'scripts'))
from state_hub_preflight_lane import bao,data,capabilities,revoke
from repair_eso_kubernetes_auth import role_payload,check_role
from telemetry_smtp_delivery import ensure
def load(name,path):
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
inventory=load('storage_inventory',HERE/'inventory-storage-share.py')
offsite=load('offsite',HERE/'identity-offsite.py')
FOLDER='/railiance-backups/net-kingdom-identity'
PROFILES={'publisher':(48,21),'retention':(49,25)}
class Refused(Exception):pass
def contract(action,expected):
number,permission=PROFILES[action]
path=PLATFORM/f'credential-change-requests/CCR-2026-{number:04d}-net-kingdom-backup-{action}.yaml'
raw=path.read_bytes()
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
module=load('credential_change',PLATFORM/'scripts/credential-change.py')
ccr,errors,_=module.validate_ccr(path)
if errors or ccr['status'] not in {'approved','applied','verified','active'}:raise Refused()
required={'platform-owner','net-kingdom-owner','backup-share-owner'}
approved={r['reviewer'].removeprefix('User (').removesuffix(')') for r in ccr.get('review',{}).get('comments',[])
if r.get('decision')=='approved' and r.get('source_ref')}
name='net-kingdom-backup-'+action+'-eso'
expected_path='platform/workloads/net-kingdom/identity-backup/'+action
a=ccr['openbao'];provider=ccr['provider']
if (not required<=approved or a['kv_path']!=expected_path or a['fields']!=['NC_WEBDAV_TOKEN']
or a['policy_name']!=name or a['auth']['role']!=name
or a['auth']['bound_claims']!={'service_account_names':[name],'service_account_namespaces':['sso']}
or provider['folder']!=FOLDER or provider['share_permissions']!=permission or provider['share_type']!=3
or provider['label']!='net-kingdom-identity-'+action
or provider.get('provider_added_resharing_approved') is not True):raise Refused()
if (a.get('metadata_read') is not False or a.get('token_self_lifecycle') is not True
or a['auth'].get('audience')!='openbao' or a['auth'].get('token_no_default_policy') is not True
or any(a['auth'].get(k)!='15m' for k in ['ttl','token_max_ttl','token_explicit_max_ttl'])
or a['auth'].get('policies')!=[name] or provider.get('host')!='nx4069.your-storageshare.de'):raise Refused()
lane={'service_account':name,'namespace':'sso','role':name,'policy':name,
'kv_path':expected_path.replace('platform/','platform/data/',1)}
wanted=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
if (PLATFORM/a['policy_file']).read_text()!=wanted:raise Refused()
return ccr,lane
def request(client,url,auth,method='GET',body=None,extra=None):
if not url.startswith(offsite.HOST+'/'):raise Refused()
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode(),
'X-Requested-With':'XMLHttpRequest'};h.update(extra or {})
try:
with client.open(urllib.request.Request(url,data=body,method=method,headers=h),timeout=60) as response:
if response.url!=url:raise Refused()
raw=response.read(1024*1024+1)
if len(raw)>1024*1024:raise Refused()
return response.status,raw
except urllib.error.HTTPError as error:return error.code,b''
def ocs(client,auth,suffix='',fields=None):
url=offsite.HOST+'/ocs/v2.php/apps/files_sharing/api/v1/shares'+suffix
body=urllib.parse.urlencode(fields).encode() if fields is not None else None
status,raw=request(client,url,auth,'POST' if fields is not None else 'GET',body,
{'OCS-APIRequest':'true','Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'})
if status not in (200,201):raise Refused()
parsed=json.loads(raw)['ocs']
if parsed['meta']['statuscode'] not in (100,200):raise Refused()
return parsed['data']
def share(client,auth,action,receipt):
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER
status,_=request(client,owner,auth,'MKCOL')
if status not in (201,405):raise Refused()
receipt['folder_created']=status==201
matches=[s for s in ocs(client,auth,'?path='+urllib.parse.quote(FOLDER,safe=''))
if s.get('label')=='net-kingdom-identity-'+action]
if len(matches)>1:raise Refused()
if matches:result=matches[0];receipt['share_created']=False
else:
result=ocs(client,auth,fields={'path':FOLDER,'shareType':3,'permissions':PROFILES[action][1],
'label':'net-kingdom-identity-'+action});receipt['share_created']=True
if (result.get('path')!=FOLDER or int(result.get('permissions',-1))!=PROFILES[action][1]
or int(result.get('share_type',-1))!=3 or not result.get('token')):raise Refused()
offsite.headers(result['token'])
receipt.update(share_id=str(result['id']),share_permissions=int(result['permissions']),folder=FOLDER)
return result['token']
def boundary(client,auth,token,action,receipt):
name='nk49-boundary-'+uuid.uuid4().hex+'.age'
payload=subprocess.run(['age','-r',json.loads((HERE/'identity-backup-profile.json').read_text())['public_recipient']],
input=b'NK49 disposable provider boundary fixture',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
if payload.returncode:raise Refused()
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+name
public=offsite.BASE+name;created=False;deleted=False
receipt['provider_test_artifacts']=[name]
receipt['boundary_fixture_cleanup_verified']=False
try:
receipt['boundary_phase']='create_owner_fixture'
status,_=request(client,owner,auth,'PUT',payload.stdout,{'If-None-Match':'*'})
if status!=201:raise Refused()
created=True
receipt['boundary_phase']='read_public_fixture'
status,raw=request(client,public,(token,''))
if status!=200 or raw!=payload.stdout:raise Refused()
receipt['boundary_phase']='deny_public_overwrite'
status,_=request(client,public,(token,''),'PUT',payload.stdout)
if status not in (401,403):raise Refused()
denied_name='nk49-absent-'+uuid.uuid4().hex+'.age'
if action=='retention':receipt['provider_test_artifacts'].append(denied_name)
status,_=request(client,offsite.BASE+denied_name,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'}) if action=='retention' else (403,b'')
if status not in (401,403):raise Refused()
receipt['boundary_phase']='public_delete'
status,_=request(client,public,(token,''),'DELETE')
if action=='publisher':
if status not in (401,403):raise Refused()
new=offsite.BASE+'nk49-boundary-'+uuid.uuid4().hex+'.age'
receipt['provider_test_artifacts'].append(new.rsplit('/',1)[-1])
receipt['boundary_phase']='create_public_fixture'
code,_=request(client,new,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'})
if code!=201:raise Refused()
# This fixture belongs to the current ceremony, not retained data.
receipt['boundary_phase']='cleanup_public_fixture'
remove_fixture(client,offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+new.rsplit('/',1)[-1],auth,payload.stdout)
else:
if status!=204:raise Refused()
deleted=True
receipt['boundary_phase']='deny_parent_path'
status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,''))
receipt['parent_probe_status']=status
if status not in (400,401,403,404):raise Refused()
receipt['parent_path_rejected']=True
# A malformed-path400 proves request rejection, not authorization.
# Separately probe a known existing account folder with only this share
# token: it must not authenticate as the Backup account.
receipt['boundary_phase']='deny_other_account_folder'
status,_=request(client,offsite.HOST+'/remote.php/dav/files/Backup/r01-net-kingdom-pg/',(token,''))
receipt['other_account_folder_status']=status
if status not in (401,403,404):raise Refused()
receipt['provider_boundary_verified']=True
finally:
if created and not deleted:
remove_fixture(client,owner,auth,payload.stdout)
receipt['owner_fixture_cleanup_verified']=True
# A rejected operation may have had an unexpected side effect. Only a
# successful complete boundary test claims cleanup; names remain in the
# receipt for owner reconciliation on every failure.
receipt['boundary_fixture_cleanup_verified']=receipt.get('provider_boundary_verified',False)
def remove_fixture(client,url,auth,expected):
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode()}
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
version=offsite.etag(response,url,len(expected))
code,raw=request(client,url,auth,extra={'If-Match':version})
if code!=200 or raw!=expected:raise Refused()
code,_=request(client,url,auth,'DELETE',extra={'If-Match':version})
if code!=204:raise Refused()
def run(action,expected,receipt):
ccr,lane=contract(action,expected)
identity,credentials=inventory.caller_credentials()
receipt['caller_entity_id']=identity.get('entity_id');receipt['caller_auth_path']=identity['path']
client=offsite.opener();auth=(credentials['BACKUP_USERNAME'],credentials['BACKUP_PASSWORD'])
# Use the owner quota accessor only with proxies removed from its environment.
quota=load('owner_quota',PLATFORM/'scripts/migrate_nextcloud_backup_account.py')
quota.request=lambda url,method='GET',body=None,auth=None,headers=None:request(client,url,auth,method,body,headers)
receipt['quota_before']=quota.quota(auth)
if receipt['quota_before']['available_bytes']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
token=share(client,auth,action,receipt)
boundary(client,auth,token,action,receipt)
if action=='publisher':
receipt['publisher_quota_available']=offsite.quota(client,token)
if receipt['publisher_quota_available']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
# Fence agent reads before any initial secret publication.
boundary_path='sys/policies/acl/agent-high-risk-boundary'
current=data(bao(['read','-format=json',boundary_path]))['data']['policy'];added=''
for path in [lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]:
stanza='path "'+path+'" { capabilities = ["deny"] }\n'
if '"'+path+'"' not in current:added+=stanza
elif stanza.strip() not in current:raise Refused()
if added:
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current:raise Refused()
bao(['write',boundary_path,'-'],payload={'policy':current+'\n'+added})
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current+'\n'+added:raise Refused()
receipt['coding_agent_fence_verified_before_publication']=True
existing=bao(['read','-format=json',lane['kv_path']],allow_failure=True)
if existing.returncode==0:
native=data(existing)['data']
if native['data']!={'NC_WEBDAV_TOKEN':token}:raise Refused()
receipt['kv_version']=native['metadata']['version'];receipt['existing_identical_custody']=True
else:
if b'No value found' not in existing.stdout+existing.stderr:raise Refused()
native=data(bao(['write','-format=json',lane['kv_path'],'-'],payload={'options':{'cas':0},'data':{'NC_WEBDAV_TOKEN':token}}))
receipt['kv_version']=native['data']['version']
policy=(PLATFORM/ccr['openbao']['policy_file']).read_text()
expected_policy=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
if policy!=expected_policy:raise Refused()
ensure('sys/policies/acl/'+lane['policy'],{'policy':policy},lambda actual: None if actual['policy']==policy else (_ for _ in ()).throw(Refused()))
ensure('auth/kubernetes/role/'+lane['role'],role_payload(lane),lambda actual:check_role(actual,lane))
child=data(bao(['token','create','-format=json','-policy='+lane['policy'],'-policy=agent-high-risk-boundary','-no-default-policy','-ttl=60s']))['auth']['client_token']
try:
if any(v!=['deny'] for v in capabilities(child,[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]).values()):raise Refused()
finally:revoke(child)
receipt.update(status='provider_and_reader_prepared',coding_agents_denied=True,escrow_read=False,
operator_password_delivered=False,retained_backups_deleted=False,source_ccr=ccr['id'],ccr_sha256=expected,
eso_delivery_and_native_login_verification_pending=True)
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=PROFILES)
p.add_argument('--ccr-sha256',required=True);p.add_argument('--receipt',required=True);a=p.parse_args()
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
r={'status':'failed','action':a.action,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
try:run(a.action,a.ccr_sha256,r)
except Exception:r.update(status='failed',error='contained_setup_failed_inspect_partial_state_before_retry')
finally:
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
return int(r['status']=='failed')
if __name__=='__main__':raise SystemExit(main())