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:
parent
5ef016be01
commit
8fb4407832
3 changed files with 235 additions and 1 deletions
176
scripts/migrate_nextcloud_backup_account.py
Normal file
176
scripts/migrate_nextcloud_backup_account.py
Normal 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())
|
||||
40
tests/test_nextcloud_backup_account.py
Normal file
40
tests/test_nextcloud_backup_account.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue