From 72c598e7de99b165bc27c48dc00dbd0d460d3fe3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 07:48:36 +0200 Subject: [PATCH] fix(recovery): distinguish malformed paths from outside-folder denial Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2 --- docs/bootstrap-recovery-ceremony.md | 11 + .../capture-ready-plan.json | 380 ++++++++++++++++++ .../publisher-attempt-r3.json | 24 ++ .../rollout-review.json | 13 +- tests/test_identity_offsite.py | 25 ++ .../identity-credential-setup.py | 8 + ...-bootstrap-secret-free-recovery-handoff.md | 11 + 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/2026-10-16-identity-backup-live/capture-ready-plan.json create mode 100644 docs/evidence/2026-10-16-identity-backup-live/publisher-attempt-r3.json diff --git a/docs/bootstrap-recovery-ceremony.md b/docs/bootstrap-recovery-ceremony.md index 3acd1e8..07e2a6f 100644 --- a/docs/bootstrap-recovery-ceremony.md +++ b/docs/bootstrap-recovery-ceremony.md @@ -409,3 +409,14 @@ no periodic capture/expiry is enabled by installation alone. One subsequent OIDC attempt timed out before owner-command handoff; it did not run mutations, and its possible session revocation was unconfirmed. Only the contained owner login lane is retried, without switching to root or inspecting captured bytes. + + +The native publisher fixture subsequently proved read/create, denied overwrite +and delete, and exact fixture cleanup. Its malformed parent path was rejected +withHTTP400. The checker now records that separately and also requires a valid +outside-account-folder request to fail authentication; accepting a malformed +path rejection alone would not establish authorization confinement.16 provider +checks pass after this correction. A fresh metadata capture plan confirms zero +allow policies matching helper Pods. The next owner login timed out before +handoff; no corrected provider probe or KV/ESO publication ran in that attempt. +The installed runtime's timers remain disabled while attended admission resumes. diff --git a/docs/evidence/2026-10-16-identity-backup-live/capture-ready-plan.json b/docs/evidence/2026-10-16-identity-backup-live/capture-ready-plan.json new file mode 100644 index 0000000..ca3d5fc --- /dev/null +++ b/docs/evidence/2026-10-16-identity-backup-live/capture-ready-plan.json @@ -0,0 +1,380 @@ +{ + "schema": "netkingdom.identity-capture-plan.v1", + "status": "requires_owner_execution_admission", + "created_at": "2026-10-16T05:46:40.122854+00:00", + "run_id": "ba725bea599b", + "binding": { + "schema": "netkingdom.identity-backup-preparation.v1", + "status": "prepared_not_admitted", + "observed_at": "2026-10-16T05:46:39.447927+00:00", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "profile_sha256": "40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1", + "source_sha256": "cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1", + "bindings": [ + { + "namespace": "sso", + "deployment": "lldap", + "uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "generation": 7, + "pvc_bindings": [ + { + "name": "lldap-data", + "uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da" + } + ], + "secret_names_only": [ + "lldap-secrets" + ], + "configmap_names_only": [] + }, + { + "namespace": "sso", + "deployment": "authelia", + "uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "generation": 28, + "pvc_bindings": [ + { + "name": "authelia-data", + "uid": "86826554-b014-4df4-8b28-866aa64f6413" + } + ], + "secret_names_only": [ + "authelia-secrets", + "authelia-smtp" + ], + "configmap_names_only": [ + "authelia-config" + ] + }, + { + "namespace": "sso", + "deployment": "keycape", + "uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f", + "generation": 65, + "pvc_bindings": [ + { + "name": "keycape-authentication-policy", + "uid": "423f558f-8c11-4edf-956b-243559df4a8e" + } + ], + "secret_names_only": [ + "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_names_only": [] + }, + { + "namespace": "mfa", + "deployment": "privacyidea", + "uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375", + "generation": 11, + "pvc_bindings": [ + { + "name": "privacyidea-data", + "uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9" + }, + { + "name": "privacyidea-logs", + "uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9" + } + ], + "secret_names_only": [ + "privacyidea-config" + ], + "configmap_names_only": [ + "factor-recovery-01119187d90d7b76", + "keycape-onboarding-9465e5e0b98f7986", + "privacyidea-cfg" + ] + } + ], + "secret_values_read": false, + "backup_captured": false, + "uploaded": false, + "runtime_changed": false, + "credential_grant_created": false, + "owner_admission_required": [ + "Approve exact CCR-2026-0048/0049 grants and node runtime packet; three required owner reviews, confirmed native SA/namespace/audience boundaries before acceptance", + "Exclude helper capture label from existing broad DNS allow policies; validate read-only helper manifests and cluster/PVC/source bindings before private capture", + "Preserve every referenced credential encrypted with original keys; fresh native SQLite/PG snapshots and exact manifest, with documented omissions and no global atomicity claim", + "Create fresh ciphertext and exact Storage Share object, read back ciphertext and receipt by strong ETag/hash, independently retrieve using operator custody", + "Attended independent age-key proof: wrong-key denial, complete archive hashes, SQLite/RSA checks, disposable native PostgreSQL restore and verified cleanup before timer activation", + "Full NK49 closure separately requires native directory/issuer/MFA authentication, retained token decryption, audit signature continuity, current callers and legacy credential dispositions; archive acceptance does not close those gates", + "Host-local health cannot report a dead host; external outage monitoring and second-provider copy remain separate coverage gaps" + ] + }, + "kubeconfig": "/home/worsch/.kube/config", + "sources": [ + { + "path": "capture-identity.py", + "sha256": "47dca7c4dc96fa7eb9fb02c81fca9ca8dc5281aac918979eb7367148168ba705" + }, + { + "path": "identity-files.py", + "sha256": "d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d" + }, + { + "path": "identity-bundle.py", + "sha256": "524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6" + }, + { + "path": "prepare-identity-coverage.py", + "sha256": "cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1" + }, + { + "path": "identity-backup-profile.json", + "sha256": "40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1" + } + ], + "database": { + "namespace": "databases", + "cluster": "net-kingdom-pg", + "cluster_uid": "0cd04a7c-a183-48ac-975a-9f244ea82c73", + "pod": "net-kingdom-pg-1", + "pod_uid": "0404c66b-338b-4446-a767-622d248ab0d3", + "database": "privacyidea_db" + }, + "create_resources": [ + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "nk49-capture-ba725bea599b", + "namespace": "sso" + }, + "spec": { + "podSelector": { + "matchLabels": { + "net-kingdom/capture": "ba725bea599b" + } + }, + "policyTypes": [ + "Ingress", + "Egress" + ], + "ingress": [], + "egress": [] + } + }, + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "nk49-capture-ba725bea599b", + "namespace": "sso", + "labels": { + "net-kingdom/capture": "ba725bea599b" + } + }, + "spec": { + "restartPolicy": "Never", + "activeDeadlineSeconds": 900, + "automountServiceAccountToken": false, + "securityContext": { + "runAsUser": 0, + "runAsGroup": 0 + }, + "containers": [ + { + "name": "capture", + "image": "ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473", + "imagePullPolicy": "IfNotPresent", + "command": [ + "python3", + "-c", + "import time; time.sleep(900)" + ], + "securityContext": { + "allowPrivilegeEscalation": false, + "readOnlyRootFilesystem": true, + "capabilities": { + "drop": [ + "ALL" + ], + "add": [ + "DAC_OVERRIDE" + ] + } + }, + "resources": { + "requests": { + "cpu": "50m", + "memory": "128Mi" + }, + "limits": { + "cpu": "1", + "memory": "2Gi" + } + }, + "volumeMounts": [ + { + "name": "lldap", + "mountPath": "/source/lldap", + "readOnly": true + }, + { + "name": "authelia", + "mountPath": "/source/authelia", + "readOnly": true + }, + { + "name": "keycape", + "mountPath": "/source/keycape", + "readOnly": true + }, + { + "name": "scratch", + "mountPath": "/scratch" + } + ] + } + ], + "volumes": [ + { + "name": "lldap", + "persistentVolumeClaim": { + "claimName": "lldap-data", + "readOnly": true + } + }, + { + "name": "authelia", + "persistentVolumeClaim": { + "claimName": "authelia-data", + "readOnly": true + } + }, + { + "name": "keycape", + "persistentVolumeClaim": { + "claimName": "keycape-authentication-policy", + "readOnly": true + } + }, + { + "name": "scratch", + "emptyDir": { + "medium": "Memory", + "sizeLimit": "512Mi" + } + } + ] + } + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "nk49-capture-ba725bea599b", + "namespace": "mfa" + }, + "spec": { + "podSelector": { + "matchLabels": { + "net-kingdom/capture": "ba725bea599b" + } + }, + "policyTypes": [ + "Ingress", + "Egress" + ], + "ingress": [], + "egress": [] + } + }, + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "nk49-capture-ba725bea599b", + "namespace": "mfa", + "labels": { + "net-kingdom/capture": "ba725bea599b" + } + }, + "spec": { + "restartPolicy": "Never", + "activeDeadlineSeconds": 900, + "automountServiceAccountToken": false, + "securityContext": { + "runAsUser": 0, + "runAsGroup": 0 + }, + "containers": [ + { + "name": "capture", + "image": "ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473", + "imagePullPolicy": "IfNotPresent", + "command": [ + "python3", + "-c", + "import time; time.sleep(900)" + ], + "securityContext": { + "allowPrivilegeEscalation": false, + "readOnlyRootFilesystem": true, + "capabilities": { + "drop": [ + "ALL" + ], + "add": [ + "DAC_OVERRIDE" + ] + } + }, + "resources": { + "requests": { + "cpu": "50m", + "memory": "128Mi" + }, + "limits": { + "cpu": "1", + "memory": "2Gi" + } + }, + "volumeMounts": [ + { + "name": "privacyidea", + "mountPath": "/source/privacyidea", + "readOnly": true + }, + { + "name": "scratch", + "mountPath": "/scratch" + } + ] + } + ], + "volumes": [ + { + "name": "privacyidea", + "persistentVolumeClaim": { + "claimName": "privacyidea-data", + "readOnly": true + } + }, + { + "name": "scratch", + "emptyDir": { + "medium": "Memory", + "sizeLimit": "512Mi" + } + } + ] + } + } + ], + "service_changes": false, + "credential_grants": false, + "matching_network_allow_policies": [], + "network_policy_enforcement_requires_owner_confirmation": true, + "offsite_delivery_included": false, + "global_atomic_snapshot_claimed": false, + "finished_at": "2026-10-16T05:46:40.884474+00:00" +} diff --git a/docs/evidence/2026-10-16-identity-backup-live/publisher-attempt-r3.json b/docs/evidence/2026-10-16-identity-backup-live/publisher-attempt-r3.json new file mode 100644 index 0000000..fec1bb3 --- /dev/null +++ b/docs/evidence/2026-10-16-identity-backup-live/publisher-attempt-r3.json @@ -0,0 +1,24 @@ +{ + "status": "refused_malformed_parent_status_before_kv_publication", + "action": "publisher", + "caller_entity_id": "4a1f663c-0621-0483-edd0-f852274f4b6b", + "caller_auth_path": "auth/netkingdom/oidc/callback", + "quota_before": { + "quota_bytes": 10737418240, + "used_bytes": 7094108469, + "available_bytes": 3643309771 + }, + "folder_created": false, + "share_created": false, + "share_id": "26", + "share_permissions": 21, + "folder": "/railiance-backups/net-kingdom-identity", + "provider_test_artifacts": [ + "nk49-boundary-b7c9df967f844454ba87fe73a5582bd8.age", + "nk49-boundary-7c9e4e216c124a1ea3178e56e63cce67.age" + ], + "boundary_fixture_cleanup_verified": false, + "boundary_phase": "deny_parent_path", + "parent_probe_status": 400, + "owner_fixture_cleanup_verified": true +} diff --git a/docs/evidence/2026-10-16-identity-backup-live/rollout-review.json b/docs/evidence/2026-10-16-identity-backup-live/rollout-review.json index 783f4f6..c962e07 100644 --- a/docs/evidence/2026-10-16-identity-backup-live/rollout-review.json +++ b/docs/evidence/2026-10-16-identity-backup-live/rollout-review.json @@ -1,5 +1,5 @@ { - "status": "approved_native_provider_delivery_checks_in_progress", + "status": "runtime_installed_provider_admission_waiting_attended_login", "observed_at": "2026-10-15T23:56:00.293983+00:00", "approval_decision": "40bfae40-6108-4e51-8613-073339f5345f", "approved_platform_source_commit": "69a792c", @@ -38,5 +38,14 @@ "focused_tests_passed": 52, "temporary_dns_selector_exclusions_applied": true, "network_policy_rule_lines_present": 649, - "installed_runtime_release": "bf8b38f185037515cfad9eebcddefc9f60ace724c32e10d89ae8307dc95a4eb7" + "installed_runtime_release": "bf8b38f185037515cfad9eebcddefc9f60ace724c32e10d89ae8307dc95a4eb7", + "current_login_session_active": false, + "latest_login_failure": "callback_timeout_before_handoff", + "latest_timeout_possible_session_revocation_confirmed": false, + "publisher_read_create_and_overwrite_delete_denial_verified": true, + "publisher_fixture_cleanup_verified": true, + "parent_path_rejection_status": 400, + "corrected_boundary_tests_passed": 16, + "corrected_native_boundary_and_delivery_not_yet_run": true, + "helper_network_allow_count": 0 } diff --git a/tests/test_identity_offsite.py b/tests/test_identity_offsite.py index 4bf89e3..1861587 100644 --- a/tests/test_identity_offsite.py +++ b/tests/test_identity_offsite.py @@ -157,3 +157,28 @@ def test_bootstrap_dispatcher_handoff_help_and_missing_checkout(tmp_path): env=dict(os.environ,NETKINGDOM_RECOVERY_ROOT=str(tmp_path/'missing')) failure=subprocess.run(['bash',str(bootstrap/'bin/railiance'),'identity-recovery','--transfer-receipt','missing','--receipt','new'],env=env,capture_output=True) assert failure.returncode==1 and b'Missing reviewed NetKingdom' in failure.stderr + + +@pytest.mark.parametrize('parent_status,outside_status,accepted',[(400,401,True),(400,200,False),(500,401,False)]) +def test_provider_parent_400_requires_independent_outside_folder_denial(monkeypatch,parent_status,outside_status,accepted): + store={};owner_auth=('owner','fixture');token=TOKEN + def request(client,url,auth,method='GET',body=None,extra=None): + if '%2e%2e' in url:return parent_status,b'' + if url.endswith('/r01-net-kingdom-pg/'):return outside_status,b'' + name=url.rsplit('/',1)[-1] + if method=='PUT': + if name in store:return 403,b'' + store[name]=body;return 201,b'' + if method=='DELETE':return 403,b'' + return 200,store[name] + def remove(client,url,auth,expected): + name=url.rsplit('/',1)[-1];assert auth==owner_auth and store[name]==expected;del store[name] + monkeypatch.setattr(setup,'request',request);monkeypatch.setattr(setup,'remove_fixture',remove) + receipt={} + if accepted: + setup.boundary(None,owner_auth,token,'publisher',receipt) + assert receipt['provider_boundary_verified'] and receipt['other_account_folder_status']==401 + else: + with pytest.raises(setup.Refused):setup.boundary(None,owner_auth,token,'publisher',receipt) + assert not receipt.get('provider_boundary_verified') + assert not store diff --git a/tools/bootstrap-recovery/identity-credential-setup.py b/tools/bootstrap-recovery/identity-credential-setup.py index a63eec0..25549b8 100644 --- a/tools/bootstrap-recovery/identity-credential-setup.py +++ b/tools/bootstrap-recovery/identity-credential-setup.py @@ -157,6 +157,14 @@ def boundary(client,auth,token,action,receipt): receipt['boundary_phase']='deny_parent_path' status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,'')) receipt['parent_probe_status']=status + if status not in (400,401,403,404):raise Refused() + receipt['parent_path_rejected']=True + # A malformed-path400 proves request rejection, not authorization. + # Separately probe a known existing account folder with only this share + # token: it must not authenticate as the Backup account. + receipt['boundary_phase']='deny_other_account_folder' + status,_=request(client,offsite.HOST+'/remote.php/dav/files/Backup/r01-net-kingdom-pg/',(token,'')) + receipt['other_account_folder_status']=status if status not in (401,403,404):raise Refused() receipt['provider_boundary_verified']=True finally: diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 7e9aff1..e1d6758 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -547,6 +547,17 @@ Provider boundary testing and ESO/native login delivery remain in progress. No identity capture or scheduled retention has occurred. T02 progress/T03 wait. Evidence: `docs/evidence/2026-10-16-identity-backup-live/`. +2026-10-16 native publisher checkpoint: actual fixture read/create worked and +publisher overwrite/delete were denied. Both exact fixture objects were cleaned; +the remaining parent-path check returned400 for a malformed path. Corrected the +checker to distinguish that rejection from a separate valid outside-folder +request that must fail authentication.16 provider checks pass after correction. +The next attended login timed out before handoff, so corrected native checks, +KV/ESO delivery and first full capture remain pending the human identity act. +Fresh capture metadata reports zero helper allow policies. Installed timers are +disabled. No new coding-agent credential or root fallback; partial owner share26 +is undistributed. Pending browser status from operator, without duplicate logins. + ## Verify owner-approved recovery and current native callers ```task