#!/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']