From 8fb4407832a389c57c4169d5d29fad2a98c2f21f Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 5 Sep 2026 20:35:13 +0200 Subject: [PATCH] Prepare dedicated Backup account cutover with quota and recovery verification Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883 --- scripts/migrate_nextcloud_backup_account.py | 176 ++++++++++++++++++ tests/test_nextcloud_backup_account.py | 40 ++++ ...-0029-backup-credential-default-removal.md | 20 +- 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 scripts/migrate_nextcloud_backup_account.py create mode 100644 tests/test_nextcloud_backup_account.py diff --git a/scripts/migrate_nextcloud_backup_account.py b/scripts/migrate_nextcloud_backup_account.py new file mode 100644 index 0000000..2b8735e --- /dev/null +++ b/scripts/migrate_nextcloud_backup_account.py @@ -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'' + 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()) diff --git a/tests/test_nextcloud_backup_account.py b/tests/test_nextcloud_backup_account.py new file mode 100644 index 0000000..26ebc51 --- /dev/null +++ b/tests/test_nextcloud_backup_account.py @@ -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'502' + 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() diff --git a/workplans/RPF-WP-0029-backup-credential-default-removal.md b/workplans/RPF-WP-0029-backup-credential-default-removal.md index e546dbf..5b57a2a 100644 --- a/workplans/RPF-WP-0029-backup-credential-default-removal.md +++ b/workplans/RPF-WP-0029-backup-credential-default-removal.md @@ -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.