Prepare dedicated Backup account cutover with quota and recovery verification

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
codex 2026-09-05 20:35:13 +02:00
parent 5ef016be01
commit 8fb4407832
3 changed files with 235 additions and 1 deletions

View file

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Attended Backup-account cutover. Provider credentials never leave captured memory."""
import argparse
import base64
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from state_hub_preflight_lane import ROOT, LaneError, bao, data
HOST = 'https://nx4069.your-storageshare.de'
OPERATOR_PATH = 'operators/data/nextcloud/backup'
LANE = 'platform/data/workloads/railiance/backup/offsite-lane'
FOLDER = '/railiance-backups'
LABEL = 'railiance-backup-upload-only'
QUOTA = 10 * 1024**3
PREFIXES = ['forgejo', 'core-apps-pg', 'core-gitea-db', 'core-net-kingdom-pg',
'core-state-hub-db', 'r01-forgejo-db', 'r01-platform-pg',
'r01-net-kingdom-pg', 'r01-state-hub-db', 'r01-user-engine-pg']
def require(ok, message):
if not ok:
raise LaneError(message)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def request(url, method='GET', body=None, auth=None, headers=None):
require(url.startswith(HOST + '/'), 'unapproved_provider_origin')
headers = dict(headers or {})
if auth:
headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode()
req = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.build_opener(NoRedirect()).open(req, timeout=60) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
return error.code, b''
def quota(auth):
body = b'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/><d:quota-used-bytes/></d:prop></d:propfind>'
status, result = request(HOST + '/remote.php/dav/files/Backup/', 'PROPFIND', body, auth,
{'Depth':'0','Content-Type':'application/xml'})
require(status == 207, 'backup_account_auth_or_quota_failed')
root = ET.fromstring(result)
available = int(root.findtext('.//{DAV:}quota-available-bytes'))
used = int(root.findtext('.//{DAV:}quota-used-bytes'))
require(available >= 0 and used >= 0 and available + used == QUOTA, 'quota_contract_mismatch')
return {'quota_bytes': QUOTA, 'used_bytes': used, 'available_bytes': available}
def ocs(auth, suffix='', method='GET', fields=None):
body = urllib.parse.urlencode(fields).encode() if fields is not None else None
code, content = request(HOST + '/ocs/v2.php/apps/files_sharing/api/v1/shares' + suffix,
method, body, auth, {'OCS-APIRequest':'true','Accept':'application/json',
'Content-Type':'application/x-www-form-urlencoded'})
require(code in (200, 201), 'share_api_failed')
response = json.loads(content)['ocs']
require(response['meta']['statuscode'] in (100, 200), 'share_operation_rejected')
return response['data']
def subprocess_bytes(argv, value):
result = subprocess.run(argv, input=value, capture_output=True, timeout=60)
require(result.returncode == 0, 'protected_crypto_failed')
return result.stdout
def run(args, receipt):
identity = data(bao(['token', 'lookup', '-format=json']))['data']
require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required')
entry = data(bao(['read', '-format=json', OPERATOR_PATH]))['data']['data']
require(entry['BACKUP_USERNAME'] == 'Backup' and bool(entry['BACKUP_PASSWORD']), 'operator_account_contract_mismatch')
auth = (entry['BACKUP_USERNAME'], entry['BACKUP_PASSWORD'])
receipt['quota_before'] = quota(auth)
old = data(bao(['read', '-format=json', LANE]))['data']
expected_version = old['metadata']['version']
if args.expected_version is not None:
require(expected_version == args.expected_version, 'kv_version_changed')
receipt['previous_kv_version'] = expected_version
values = old['data']
require(bool(values.get('AGE_PRIVATE_KEY')), 'recovery_escrow_missing')
recipient = re.search(r'age1[0-9a-z]+', (ROOT / 'lib/railiance-backup-common.sh').read_text()).group(0)
owner_root = HOST + '/remote.php/dav/files/Backup' + FOLDER
for folder in [FOLDER] + [FOLDER + '/' + name for name in PREFIXES]:
code, _ = request(HOST + '/remote.php/dav/files/Backup' + folder, 'MKCOL', auth=auth)
require(code in (201, 405), 'backup_folder_creation_failed')
shares = ocs(auth, '?path=' + urllib.parse.quote(FOLDER, safe=''))
matches = [s for s in shares if s.get('label') == LABEL]
require(len(matches) <= 1, 'ambiguous_existing_share')
created = False
if matches:
share = matches[0]
else:
share = ocs(auth, method='POST', fields={'path':FOLDER,'shareType':3,'permissions':4,'publicUpload':'true','label':LABEL,'sendMail':'false'})
created = True
share_id = str(share['id'])
receipt['share_id'] = share_id
if share.get('uid_owner') != 'Backup' or share.get('path') != FOLDER or int(share['permissions']) != 4:
if created:
ocs(auth, '/' + share_id, 'DELETE')
raise LaneError('create_only_share_contract_failed')
receipt.update(share_owner='Backup', share_permissions=4)
token = share['token']
# Existing backup clients already implement this file-drop endpoint and auth.
upload_root = HOST + '/public.php/dav/filesdrop/' + token
filename = 'account-acceptance-' + secrets.token_hex(12) + '.age'
receipt['fixture_name'] = filename
fixture_path = '/forgejo/' + filename
plain = b'railiance-backup-account-acceptance-v1\n' + secrets.token_bytes(64)
encrypted = subprocess_bytes(['age', '-r', recipient], plain)
require(len(encrypted) < receipt['quota_before']['available_bytes'], 'insufficient_quota')
code, _ = request(upload_root + fixture_path, 'PUT', encrypted, (token, ''), {'Content-Type':'application/octet-stream','If-None-Match':'*'})
receipt['upload_http_status'] = code
require(code in (200, 201, 204), 'upload_fixture_failed')
code, downloaded = request(owner_root + fixture_path, auth=auth)
require(code == 200 and downloaded == encrypted, 'owner_download_mismatch')
with tempfile.TemporaryDirectory(prefix='backup-account-acceptance-') as tmp:
cipher = Path(tmp) / 'fixture.age'
cipher.write_bytes(downloaded)
restored = subprocess_bytes(['age', '-d', '-i', '/dev/stdin', str(cipher)], (values['AGE_PRIVATE_KEY'].strip() + '\n').encode())
require(restored == plain, 'decrypted_fixture_mismatch')
for method in ['GET', 'DELETE']:
code, _ = request(HOST + '/public.php/webdav' + fixture_path, method, auth=(token, ''))
require(code in (401, 403, 404), 'upload_grant_exceeds_create_only')
code, present = request(owner_root + fixture_path, auth=auth)
require(code == 200 and present == encrypted, 'fixture_not_preserved_by_negative_check')
receipt.update(encrypted_upload=True, owner_download=True, escrow_decryption=True,
runtime_read_denied=True, runtime_delete_denied=True)
# Only replace upload coordinates; preserve recovery escrow and other fields.
updated = dict(values, NC_WEBDAV_TOKEN=token, NC_WEBDAV_URL=upload_root)
result = data(bao(['write', '-format=json', LANE, '-'], payload={'options':{'cas':expected_version}, 'data':updated}))
receipt['kv_version'] = result['data']['version']
receipt['escrow_preserved'] = True
code, _ = request(owner_root + fixture_path, 'DELETE', auth=auth)
require(code == 204, 'fixture_cleanup_failed')
receipt['fixture_removed'] = True
receipt['quota_after'] = quota(auth)
receipt['status'] = 'account_cutover_verified_pending_consumer_refresh'
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--expected-version', type=int)
p.add_argument('--receipt', required=True)
p.add_argument('--confirm', required=True)
args = p.parse_args()
receipt = {'schema':'platform.nextcloud-backup-account-cutover.v1', 'status':'failed'}
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
require(args.confirm == 'MIGRATE TO Backup', 'confirmation_mismatch')
run(args, receipt)
except Exception as error:
receipt['error'] = str(error) if isinstance(error, LaneError) else 'internal_error'
finally:
with os.fdopen(fd, 'w') as out:
json.dump(receipt, out, indent=2)
out.write('\n')
return 0 if receipt['status'] != 'failed' else 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,40 @@
import json
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import urllib.error
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
import migrate_nextcloud_backup_account as migration
class BackupAccountTests(unittest.TestCase):
def test_requests_refuse_other_provider_origins(self):
with patch.object(migration.urllib.request, 'build_opener') as opener:
with self.assertRaises(migration.LaneError):
migration.request('https://different.invalid/remote.php/dav', auth=('Backup','fixture'))
opener.assert_not_called()
def test_http_failure_exposes_only_status_not_credential_body(self):
with patch.object(migration.urllib.request, 'build_opener') as opener:
opener.return_value.open.side_effect = urllib.error.HTTPError('https://fixture.invalid/fixture-secret',403,'fixture-secret',{},None)
self.assertEqual(migration.request(migration.HOST+'/probe'),(403,b''))
def test_wrong_quota_rejects_before_cutover(self):
body=b'<d:multistatus xmlns:d="DAV:"><d:quota-available-bytes>50</d:quota-available-bytes><d:quota-used-bytes>2</d:quota-used-bytes></d:multistatus>'
with patch.object(migration,'request',return_value=(207,body)):
with self.assertRaisesRegex(migration.LaneError,'quota_contract_mismatch'):
migration.quota(('Backup','fixture'))
def test_expected_version_mismatch_stops_before_provider_mutation(self):
responses=[{'data':{'policies':['platform-admin']}}, {'data':{'data':{'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'fixture'}}}, {'data':{'metadata':{'version':2},'data':{}}}]
with patch.object(migration,'bao',side_effect=[SimpleNamespace(stdout=json.dumps(x).encode()) for x in responses]), patch.object(migration,'quota',return_value={}), patch.object(migration,'request') as provider:
with self.assertRaisesRegex(migration.LaneError,'kv_version_changed'):
migration.run(SimpleNamespace(expected_version=99),{})
provider.assert_not_called()
if __name__=='__main__':
unittest.main()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Remove backup credential default and verify governed replacement"
domain: financials
repo: railiance-platform
status: blocked
status: active
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
@ -59,3 +59,21 @@ S1 backup scheduling belongs to RAIL-HO-WP-0012; forge backup orchestration and
artifact retention belong to railiance-forge. RPF-WP-0036-T06 will obtain an
accepted compatibility handoff, but this exposure obligation stays visible
here until its evidence is accepted. No rotation was executed in this review.
## Move future backups to the dedicated Backup account
```task
id: RPF-WP-0029-T03
status: progress
priority: high
```
User explicitly selected Nextcloud user `Backup` with 10 GB quota and operator
credentials at UI `secrets/operators/nextcloud/backup`. Live resolution is KVv2
`operators/nextcloud/backup`, fields BACKUP_USERNAME/BACKUP_PASSWORD. Native
WebDAV login verified; actual quota is 10737418240 bytes (10 GiB). Keep the
account password in operator custody, create a Backup-owned create-only share
for workload delivery, and preserve the existing age escrow and retained data.
Prove encrypted upload/download/decryption and workload delivery. No automatic
pruning or personal-account revocation is inferred from this account change.
The historical predecessor invalidation obligation in T02 remains separate.