Record off-host database recovery and prepare identity backup coverage
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
This commit is contained in:
parent
a1939a7d06
commit
dca999222f
13 changed files with 596 additions and 29 deletions
|
|
@ -52,3 +52,12 @@ NK49_RUN_DISPOSABLE_POSTGRES_TEST=1 python3 -m pytest -q \
|
|||
tests/test_privacyidea_offhost_recovery.py \
|
||||
tests/test_storage_share_inventory.py tests/test_scaleway_backup_inventory.py
|
||||
```
|
||||
|
||||
`identity-backup-profile.json` prepares missing full service coverage and names
|
||||
the exact current workload/PVC/credential dependencies, proposed provider paths
|
||||
and remaining implementation/admission gates. It is a proposal, not a new live
|
||||
publisher grant. `prepare-identity-coverage.py --receipt NEW_PATH` verifies the
|
||||
primary cluster and current deployment/PVC/reference bindings using metadata
|
||||
only. It refuses replacement resources, unobserved/unready deployments and new
|
||||
unreviewed references. It has no Secret-data, pod-exec, mutation or upload route.
|
||||
Its receipt says `prepared_not_admitted`; it cannot prove a backup exists.
|
||||
|
|
|
|||
152
tools/bootstrap-recovery/identity-backup-profile.json
Normal file
152
tools/bootstrap-recovery/identity-backup-profile.json
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"schema": "netkingdom.identity-backup-profile.v1",
|
||||
"status": "proposed",
|
||||
"owner": "net-kingdom with railiance-platform/rapp-postgres runtime and storage admission",
|
||||
"public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
|
||||
"archive_profile": "netkingdom-identity-v1; new profile, not the bootstrap secrets-only tar profile",
|
||||
"components": [
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "lldap",
|
||||
"deployment_uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "lldap-data",
|
||||
"uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"lldap-secrets"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /data/users.db with integrity check",
|
||||
"remaining non-backup durable /data files, including any private identity material",
|
||||
"current deployment configuration and exact lldap-secrets fields"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"directory user/group/membership preservation",
|
||||
"existing-user password authentication in isolated environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "authelia",
|
||||
"deployment_uid": "dc40a925-2306-4a3b-89c0-e714187503c3",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "authelia-data",
|
||||
"uid": "86826554-b014-4df4-8b28-866aa64f6413"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"authelia-secrets",
|
||||
"authelia-smtp"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"authelia-config"
|
||||
],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /var/authelia/data/db.sqlite3 with integrity check",
|
||||
"current configuration.yml and all Secret-mounted encryption/OIDC/session fields",
|
||||
"explicit disposition for notifier credentials and remaining durable files"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"storage decryption",
|
||||
"same OIDC signing identity and preserved client configuration",
|
||||
"isolated authentication flow with outbound notification disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "keycape",
|
||||
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "keycape-authentication-policy",
|
||||
"uid": "423f558f-8c11-4edf-956b-243559df4a8e"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"keycape-approval-engine-operator-client",
|
||||
"keycape-config",
|
||||
"keycape-factor-read",
|
||||
"keycape-informed-decision-sitting-requester-client",
|
||||
"keycape-rapp-qonto-client",
|
||||
"keycape-secrets-engine-approval-client",
|
||||
"keycape-secrets-engine-openbao-client",
|
||||
"keycape-secrets-engine-requester-client"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"exact current keycape-config config.yaml and key.pem",
|
||||
"complete stable policy/receipt PVC snapshot",
|
||||
"current deployment and owner disposition for every referenced credential: encrypted recovery or independently recoverable provider reissue path"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"same issuer/JWKS signing identity",
|
||||
"registry, authentication policy and receipt continuity",
|
||||
"current consumer positive/negative authentication under isolated test endpoints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "mfa",
|
||||
"deployment": "privacyidea",
|
||||
"deployment_uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "privacyidea-data",
|
||||
"uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9"
|
||||
},
|
||||
{
|
||||
"name": "privacyidea-logs",
|
||||
"uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"privacyidea-config"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"factor-recovery-01119187d90d7b76",
|
||||
"keycape-onboarding-9465e5e0b98f7986",
|
||||
"privacyidea-cfg"
|
||||
],
|
||||
"required_assets": [
|
||||
"original /etc/privacyidea/enckey, private.pem and public.pem with matching RSA pair validation",
|
||||
"current privacyidea-config and mounted configuration/scripts",
|
||||
"bounded native PostgreSQL custom dump with exact database identity and key-generation linkage",
|
||||
"explicit disposition for service logs; audit database/signatures must be preserved"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"decrypt existing retained token material without exporting it",
|
||||
"verify pre-backup audit signatures and create/verify a new isolated audit entry",
|
||||
"existing-token MFA and negative-key refusal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"capture_contract": {
|
||||
"plaintext": "private task staging outside Git; never process output, State Hub or logs; verified cleanup after encryption",
|
||||
"consistency": "native SQLite online-backup and PostgreSQL logical snapshot; explicit capture interval per asset; no global atomic snapshot claim; before/after source identities/config versions and directory inventories must match",
|
||||
"key_policy": "preserve original service keys; no age private key in the payload, no replacement generation, no root/unseal collection",
|
||||
"encryption": "public-recipient-only streaming archive to age; private fresh ciphertext, no overwrite; manifest inside ciphertext with source identities, times, complete required paths, digests and omissions",
|
||||
"limits": "measure first; proposed 512 MiB encrypted admission ceiling, no silent truncation/exclusions",
|
||||
"historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt"
|
||||
},
|
||||
"proposed_destinations": {
|
||||
"primary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/",
|
||||
"secondary": "Storage Share Backup/railiance-backups/net-kingdom-identity/",
|
||||
"admitted": false,
|
||||
"publisher": "new exact prefix-scoped owner-delivered publisher; do not borrow another workload reader or bootstrap API key",
|
||||
"retention": "initial single verified generation; later seven daily/two weekly only after measured quota/overlap/headroom and separately admitted retention executor; never expire existing good backups",
|
||||
"schedule": "not installed; propose daily capture after logical database backup with per-generation linkage, separate failure alert and operator-approved RPO/RTO"
|
||||
},
|
||||
"admission_gates": [
|
||||
"Railiance owner accepts exact read/capture executor, immutable helper images, PVC access and task-scoped caller attribution; no production stop/restart inferred",
|
||||
"NetKingdom/service owners accept full asset manifest, original key preservation, consistency interval, bounded recovery environment and native acceptance",
|
||||
"Platform/reef-storage admits both new archive prefixes, immutable create/readback, scoped publisher custody and current quota/headroom; current application archive profiles do not include net-kingdom",
|
||||
"Resolve every referenced credential through encrypted recovery or an independent owner reissue path; no undocumented grant propagation",
|
||||
"Implement and test capture/sealing/transfer adapters against this new profile before live execution; existing bootstrap tar checker is insufficient",
|
||||
"Retrieve by immutable object/version from each admitted provider; digest check, attended independent-key decryption, native service recovery, failed-key case and verified cleanup",
|
||||
"Only then enable recurring capture/alerts and measured retention; preserve T02 legacy provider-credential and deferred-rotation boundaries"
|
||||
]
|
||||
}
|
||||
94
tools/bootstrap-recovery/prepare-identity-coverage.py
Normal file
94
tools/bootstrap-recovery/prepare-identity-coverage.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare exact identity backup coverage from read-only workload/PVC metadata.
|
||||
|
||||
This produces a proposal, never reads Secrets, executes in pods, changes cluster
|
||||
resources, captures private files, uploads, schedules or grants credentials.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
CLUSTER_UID='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def query(namespace,kind,name):
|
||||
# Closed read-only types; Secret/ConfigMap data and arbitrary API paths have
|
||||
# no transport in this preparation command.
|
||||
if kind not in {'namespace','deployment','pvc'}:raise Refused()
|
||||
args=['kubectl','--request-timeout=20s']
|
||||
if namespace:args+=['-n',namespace]
|
||||
r=subprocess.run(args+['get',kind,name,'-o','json'],capture_output=True,timeout=25)
|
||||
if r.returncode:raise Refused()
|
||||
return json.loads(r.stdout)
|
||||
|
||||
|
||||
def references(spec):
|
||||
secrets=set();configmaps=set();pvcs=set()
|
||||
for c in spec.get('containers',[])+spec.get('initContainers',[]):
|
||||
for e in c.get('env',[]):
|
||||
v=e.get('valueFrom',{})
|
||||
if 'secretKeyRef' in v:secrets.add(v['secretKeyRef']['name'])
|
||||
if 'configMapKeyRef' in v:configmaps.add(v['configMapKeyRef']['name'])
|
||||
for e in c.get('envFrom',[]):
|
||||
if 'secretRef' in e:secrets.add(e['secretRef']['name'])
|
||||
if 'configMapRef' in e:configmaps.add(e['configMapRef']['name'])
|
||||
for v in spec.get('volumes',[]):
|
||||
if 'secret' in v:secrets.add(v['secret']['secretName'])
|
||||
if 'configMap' in v:configmaps.add(v['configMap']['name'])
|
||||
if 'persistentVolumeClaim' in v:pvcs.add(v['persistentVolumeClaim']['claimName'])
|
||||
for p in v.get('projected',{}).get('sources',[]):
|
||||
if 'secret' in p:secrets.add(p['secret']['name'])
|
||||
if 'configMap' in p:configmaps.add(p['configMap']['name'])
|
||||
return secrets,configmaps,pvcs
|
||||
|
||||
|
||||
def prepare(profile,get=query):
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
bindings=[]
|
||||
for component in profile['components']:
|
||||
ns=component['namespace'];name=component['deployment']
|
||||
d=get(ns,'deployment',name);m=d['metadata']
|
||||
if (m['uid']!=component['deployment_uid'] or m.get('deletionTimestamp') or
|
||||
d['status'].get('observedGeneration')!=m['generation'] or d['status'].get('readyReplicas')!=1):raise Refused()
|
||||
refs=references(d['spec']['template']['spec'])
|
||||
expected_pvcs={p['name']:p['uid'] for p in component['pvc_refs']}
|
||||
if refs[0]!=set(component['secret_refs']) or refs[1]!=set(component['configmap_refs']) or refs[2]!=set(expected_pvcs):raise Refused()
|
||||
pvcs=[]
|
||||
for pvc,expected in expected_pvcs.items():
|
||||
obj=get(ns,'pvc',pvc)
|
||||
if obj['metadata']['uid']!=expected or obj['metadata'].get('deletionTimestamp') or obj['status'].get('phase')!='Bound':raise Refused()
|
||||
pvcs.append({'name':pvc,'uid':expected})
|
||||
bindings.append({'namespace':ns,'deployment':name,'uid':m['uid'],'generation':m['generation'],
|
||||
'pvc_bindings':pvcs,'secret_names_only':sorted(refs[0]),'configmap_names_only':sorted(refs[1])})
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
return {'schema':'netkingdom.identity-backup-preparation.v1','status':'prepared_not_admitted',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'cluster_uid':CLUSTER_UID,
|
||||
'profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'bindings':bindings,'secret_values_read':False,'backup_captured':False,'uploaded':False,
|
||||
'runtime_changed':False,'credential_grant_created':False,
|
||||
'owner_admission_required':profile['admission_gates']}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args()
|
||||
try:
|
||||
r=prepare(json.loads(PROFILE.read_text()))
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
except Exception:
|
||||
print('ERROR: coverage preparation refused; private diagnostics suppressed.');return 1
|
||||
print('OK: exact coverage proposal prepared; capture, delivery and owner admission remain pending.');return 0
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue