diff --git a/scripts/migrate_nextcloud_backup_account.py b/scripts/migrate_nextcloud_backup_account.py deleted file mode 100644 index 2b8735e..0000000 --- a/scripts/migrate_nextcloud_backup_account.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/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 deleted file mode 100644 index 26ebc51..0000000 --- a/tests/test_nextcloud_backup_account.py +++ /dev/null @@ -1,40 +0,0 @@ -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 da1cf6a..e546dbf 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: active +status: blocked owner: codex created: "2026-09-05" updated: "2026-09-05" @@ -59,22 +59,3 @@ 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 -state_hub_task_id: "f85b1b4e-9a20-56e2-9e7d-d1d6f1c77bd9" -``` - -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.