Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
254 lines
14 KiB
Python
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())
|