diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 073d7f3e289..f1a4a69fd3c 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,7 @@ on: mode: description: plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; app_key_version_verify reads the gunbai-ci App private key at the EXACT Secret Manager version named by app_key_version, mints an installation token with it, and refuses unless GitHub accepts it and the key's rotation deadline has not passed -- no add, disable or destroy; microvm_host_converge installs the cited Firecracker release on the selected host and reads the kvm grant back, refusing by name when the grant has not landed (the grant itself is applied by the full-host apply spine); guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_census_image_publish, microvm_controller_app_key_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_census_image_publish, host_credential_custody_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -25,6 +25,11 @@ on: required: false options: [srv1, srv2, srv3, srv4] type: choice + credential: + description: "host_credential_custody_converge only: which rostered custody row to deliver (gunbc.host_credential_custody_converge). A closed choice, never a free-text secret name: each option carries its own SecretRef, path, owner, group, mode and directory, and a row scoped to one host refuses any other" + required: false + options: [controller_app_key, approval_ntfy_publisher_token] + type: choice app_key_version: description: "app_key_version_verify only: the Secret Manager version NUMBER of ci-github-app-private-key to verify. The alias latest is refused -- it names whatever is newest when it resolves, so a mint through it proves nothing about a specific key" required: false @@ -231,7 +236,7 @@ jobs: echo "fleet-key: agent loaded (identity fleet-automation@gunbc; secret versions/1 pinned; fingerprint verified against modeled authority; key file wiped)" env: WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} - if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_native_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'approval_broker_dark_install' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'site_pxe_edge_observe' || github.event.inputs.mode == 'site_pxe_edge_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' || github.event.inputs.mode == 'approval_keyring_converge' || github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'microvm_controller_app_key_converge' + if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_native_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'approval_broker_dark_install' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'site_pxe_edge_observe' || github.event.inputs.mode == 'site_pxe_edge_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' || github.event.inputs.mode == 'approval_keyring_converge' || github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'host_credential_custody_converge' timeout-minutes: 5 - name: Fleet converge plan (membership_reconcile → artifact) id: plan @@ -1016,26 +1021,27 @@ jobs: retention-days: 30 if: always() && (github.event.inputs.mode == 'runner_password_session_tool_converge') timeout-minutes: 10 - - name: "MicroVM controller App key: deliver root:root 0400 from Secret Manager, read owner, mode, bytes and staging back" - id: microvm_controller_app_key_converge + - name: "Host credential custody: deliver the chosen rostered credential from Secret Manager at its row's owner, group and mode, read owner, mode, directory, bytes and staging back" + id: host_credential_custody_converge run: | ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) - "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/runner/microvm_controller_app_key_converge.dag --function microvm_controller_app_key_converge_ci_wet - cat "$ROOT/target/microvm-controller-app-key-converge-receipt.txt" + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/host_credential_custody_converge.dag --function host_credential_custody_converge_ci_wet + cat "$ROOT/target/host-credential-custody-converge-receipt.txt" env: FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + FLEET_CONVERGE_CUSTODY_CREDENTIAL: ${{ github.event.inputs.credential }} WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} - if: github.event.inputs.mode == 'microvm_controller_app_key_converge' + if: github.event.inputs.mode == 'host_credential_custody_converge' timeout-minutes: 15 - - name: Upload microVM controller App key custody receipt - id: microvm_controller_app_key_receipt_upload + - name: Upload host credential custody receipt + id: host_credential_custody_receipt_upload uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: - name: microvm-controller-app-key-converge-receipt - path: target/microvm-controller-app-key-converge-receipt.txt + name: host-credential-custody-converge-receipt + path: target/host-credential-custody-converge-receipt.txt if-no-files-found: error retention-days: 30 - if: always() && (github.event.inputs.mode == 'microvm_controller_app_key_converge') + if: always() && (github.event.inputs.mode == 'host_credential_custody_converge') timeout-minutes: 10 - name: Upload runner host file converge receipt id: runner_host_file_receipt_upload diff --git a/dag/extdeps/posix/identity.dag b/dag/extdeps/posix/identity.dag index 6df1af3e1c5..79fc2da4df2 100644 --- a/dag/extdeps/posix/identity.dag +++ b/dag/extdeps/posix/identity.dag @@ -1,6 +1,6 @@ module extdeps.posix.identity -import std.types { String, Int, Bool } +import std.types { String, NonEmptyStr, Int, Bool } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.uri { Uri, Https } @@ -107,9 +107,25 @@ fn posix_group_id_text(group: PosixGroupId) -> String { integer_nat_to_decimal_string(value: group.value) } +// THE ONE SPELLING OF chown(1)'s OWNER[:GROUP] SEPARATOR. Both forms below -- numeric ids and names +// -- render through this, so the grammar lives here once whichever form a caller holds. +fn posix_owner_pair_text(user: String, group: String) -> String { + concat(user, concat(":", group)) +} + fn posix_owner_spec_text(spec: PosixOwnerSpec) -> String { - concat( - posix_user_id_text(user: spec.user), - concat(":", posix_group_id_text(group: spec.group)), - ) + posix_owner_pair_text(user: posix_user_id_text(user: spec.user), group: posix_group_id_text(group: spec.group)) +} + +// THE NAME FORM OF THE SAME PAIR. chown(1) accepts OWNER[:GROUP] as names or as numeric ids, and the +// two are different facts (getpwnam maps one to the other on ONE host). A service account's ids are +// allocated per host, so a consumer that declares principals across hosts can only declare NAMES. +// It renders through posix_owner_pair_text like the numeric form. +type PosixOwnerNames { + user: NonEmptyStr + group: NonEmptyStr +} + +fn posix_owner_names_text(names: PosixOwnerNames) -> String { + posix_owner_pair_text(user: names.user as String, group: names.group as String) } diff --git a/dag/extdeps/tools/chown.dag b/dag/extdeps/tools/chown.dag index ca7dfb11be2..5760be2245a 100644 --- a/dag/extdeps/tools/chown.dag +++ b/dag/extdeps/tools/chown.dag @@ -4,7 +4,7 @@ import std.types { String, NonEmptyStr, List } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.uri { Uri, Https } -import extdeps.posix.identity { PosixOwnerSpec, posix_owner_spec_text } +import extdeps.posix.identity { PosixOwnerSpec, posix_owner_spec_text, PosixOwnerNames, posix_owner_names_text } data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { @@ -30,7 +30,21 @@ data chown_binary_path: NonEmptyStr = "/usr/bin/chown" // THE OWNER ARGUMENT IS A TYPED PAIR, NOT A STRING THE CALLER ASSEMBLED. Callers used to pass an // `owner: String` built as uid + ":" + gid at the call site, which put chown's argument grammar in // the caller and left every caller free to get it wrong silently. Here the ":" form is produced -// once, from a PosixOwnerSpec that could only be constructed from decoded ids. -fn chown_argv(owner: PosixOwnerSpec, path: String) -> List { - [chown_binary_path as String, posix_owner_spec_text(spec: owner), path] +// once (extdeps.posix.identity posix_owner_pair_text), from either a PosixOwnerSpec that could only +// be constructed from decoded ids, or PosixOwnerNames for principals declared by name because their +// ids are allocated per host. chown(1) accepts both; one argv serves both. +type ChownOwner + = ChownOwnerIds { spec: PosixOwnerSpec } + | ChownOwnerNames { names: PosixOwnerNames } + +fn chown_owner_text(owner: ChownOwner) -> String { + match owner { + ChownOwnerIds { spec: s } => posix_owner_spec_text(spec: s) + ChownOwnerNames { names: n } => posix_owner_names_text(names: n) + } } + +fn chown_argv(owner: ChownOwner, path: String) -> List { + [chown_binary_path as String, chown_owner_text(owner: owner), path] +} + diff --git a/dag/gunbc/auth/approval_keyring_converge.dag b/dag/gunbc/auth/approval_keyring_converge.dag index 9d8aa2abbf2..81867901908 100644 --- a/dag/gunbc/auth/approval_keyring_converge.dag +++ b/dag/gunbc/auth/approval_keyring_converge.dag @@ -12,6 +12,8 @@ import gunbc.auth.approval_request_submission { import extdeps.filesystem.filesystem_io { Filesystem } import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } import extdeps.sudo.elevation { sudo_elevate } +import extdeps.tools.chown { chown_argv, ChownOwnerNames } +import extdeps.posix.identity { PosixOwnerNames } import gunbc.fleet_intent_network { operator_host_srv1 } import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name, actions_variable_trimmed } import gunbc.auth.approval_keyring_pins { @@ -143,8 +145,9 @@ fn claim_store_root_mkdir_argv() -> List { ] } +// Through extdeps.tools.chown chown_argv's name arm, so the OWNER:GROUP grammar is not spelled here. fn keyring_chown_argv(path: NonEmptyStr) -> List { - ["chown", concat(fleet_posix_root_user.name as String, concat(":", fleet_posix_operator_user.name as String)), path as String] + chown_argv(owner: ChownOwnerNames { names: PosixOwnerNames { user: fleet_posix_root_user.name, group: fleet_posix_operator_user.name } }, path: path as String) } fn ntfy_group_admit_argv() -> List { diff --git a/dag/gunbc/auth/approval_ntfy_deployment.dag b/dag/gunbc/auth/approval_ntfy_deployment.dag index 33b323239af..b93e64ce77e 100644 --- a/dag/gunbc/auth/approval_ntfy_deployment.dag +++ b/dag/gunbc/auth/approval_ntfy_deployment.dag @@ -18,6 +18,8 @@ import gunbc.executor_privileged_operation { } import gunbc.fleet_posix_accounts { fleet_posix_nologin_shell_path } import gunbc.fleet_intent_network { fleet_intent_network, operator_host_srv1 } +import extdeps.cloud.gcp.secret_ref { SecretRef, secret_ref_version_resource } +import gunbc.secret_provision { fleet_secret_ref, secret_ref_pin_version } // ── The self-hosted notification server on srv1 ────────────────────────────────────────────── // It follows gunbc-publication exactly: its own system account, its own state directory owned by @@ -187,12 +189,39 @@ fn approval_ntfy_server_install_intervention() -> HumanIntervention { ) } -fn approval_ntfy_publisher_token_intervention() -> HumanIntervention { +// THE PUBLISHER TOKEN'S HOME IS SECRET MANAGER, AND DELIVERY IS NOT A HUMAN STEP. It reaches srv1 +// through gunbc.host_credential_custody_converge ApprovalNtfyPublisherToken -- root:gunbc-ntfy 0640 +// in a root:gunbc-ntfy 0750 directory, read back -- so the earlier row that had an operator write +// the token where the unit could read it is DELETED, not narrowed beside the custody row. +// +// `latest`, NOT A PIN, and deliberately unlike the approval MAC keys: a pin protects signatures in +// flight from a rotation, and a publisher token signs nothing -- a rotation re-delivered by the +// custody mode is the whole remedy. +data approval_ntfy_publisher_token_secret_ref: SecretRef = secret_ref_pin_version( + ref: fleet_secret_ref(secret_id: "approval-ntfy-publisher-token"), + version: "latest", +) + +// WHAT STAYS HUMAN IS THE MINT, and it is a different fact from delivery. ntfy issues the token to a +// user that must first exist on the server, and the token must not be minted by anything the +// requester can read; so a person creates the user, issues the token, and adds it as a version of +// the secret above. Delivery then runs from the model. +fn approval_ntfy_publisher_token_mint_intervention() -> HumanIntervention { approval_ntfy_intervention( - identity: "approval-ntfy-publisher-token", + identity: "approval-ntfy-publisher-token-mint", frequency: FleetOnce, - subject: "gunbc-ntfy publisher credential", - instruction: "On srv1, create the ntfy user the broker publishes as and record its access token where the unit can read it as a systemd credential. The token must NOT be committed, placed in the unit's Environment=, or passed on any argv: extdeps.ntfy.ntfy makes the credential a required input precisely so an unauthenticated publish is not expressible, and gunbc.credential_argv_exposure forbids the argv form. A machine cannot do this because the token does not exist until a person creates it, and it must not be minted by anything the requester can read.", + subject: "gunbc-ntfy publisher credential, minted into Secret Manager", + instruction: join([ + "On srv1, create the ntfy user the broker publishes as and issue its access token; add the", + " token as a new version of ", + secret_ref_version_resource(ref: approval_ntfy_publisher_token_secret_ref), + " and nowhere else -- not committed, not in the unit's Environment=, not on any argv", + " (gunbc.credential_argv_exposure). Delivery to srv1 is NOT part of this step: dispatch", + " fleet-converge mode host_credential_custody_converge with credential", + " approval_ntfy_publisher_token on srv1, after the accessor row in", + " gunbc.auth.fleet_secret_accessor_roster has been ensured. A machine cannot mint because the", + " token does not exist until a person creates it on the server.", + ], "") as NonEmptyStr, ) } @@ -247,7 +276,7 @@ fn approval_ntfy_human_burden() -> ProvisioningHumanBurden { interventions: [ approval_ntfy_account_intervention(), approval_ntfy_server_install_intervention(), - approval_ntfy_publisher_token_intervention(), + approval_ntfy_publisher_token_mint_intervention(), approval_ntfy_subscriber_grant_intervention(), approval_ntfy_phone_subscription_intervention(), approval_ntfy_relay_withdrawal_intervention(), diff --git a/dag/gunbc/auth/approval_request_submission.dag b/dag/gunbc/auth/approval_request_submission.dag index a8bc4f4311e..e8427402fff 100644 --- a/dag/gunbc/auth/approval_request_submission.dag +++ b/dag/gunbc/auth/approval_request_submission.dag @@ -70,10 +70,11 @@ data approval_submission_mac_key_id: MacKeyId = "approval-submission-mac-key/1" data approval_submission_mac_key_path: NonEmptyStr = "/etc/gunbc-roadmap/approval-submission-mac-key" -// THE PUBLISHER TOKEN IS THE NTFY SERVER'S, AT THE PATH ITS DEPLOYMENT WROTE -// (gunbc.auth.approval_ntfy_deployment approval_ntfy_publisher_token_intervention): root:gunbc-ntfy -// 0640 today, so the roadmap unit's user cannot read it until the converge step (part D) admits -// that user to the group. An unreadable token is a typed refusal below, never a fabricated publish. +// THE PUBLISHER TOKEN IS THE NTFY SERVER'S, DELIVERED FROM SECRET MANAGER by the custody roster +// (gunbc.host_credential_custody_converge ApprovalNtfyPublisherToken): root:gunbc-ntfy 0640 in a +// root:gunbc-ntfy 0750 directory, so the roadmap unit's user reads it only once +// gunbc.auth.approval_keyring_converge admits that user to the group. An unreadable token is a +// typed refusal below, never a fabricated publish. data approval_publisher_token_path: NonEmptyStr = "/etc/gunbc-ntfy/publisher-token" fn read_submission_keyring() -> ApprovalKeyringRead { diff --git a/dag/gunbc/auth/fleet_secret_accessor_roster.dag b/dag/gunbc/auth/fleet_secret_accessor_roster.dag index 2e66ddd67c5..57e5086c468 100644 --- a/dag/gunbc/auth/fleet_secret_accessor_roster.dag +++ b/dag/gunbc/auth/fleet_secret_accessor_roster.dag @@ -13,6 +13,7 @@ import gunbc.spark.credential_workflow { spark_administrator_password_secret_ref import gunbc.secret_provision { mtcollins1_bmc_gunbc_secret_ref } import gunbc.auth.approval_decision_store { approval_mac_key_secret_ref } import gunbc.auth.approval_request_submission { approval_submission_mac_key_secret_ref } +import gunbc.auth.approval_ntfy_deployment { approval_ntfy_publisher_token_secret_ref } import gunbc.auth.gcp_secret_access { SecretAccessGrant, secret_accessor_grant, secret_access_ensure_for } import gunbc.auth.access_token_source { AccessTokenSource, OperatorSuppliedToken, read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable, @@ -40,11 +41,17 @@ data mtcollins1_bmc_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow data approval_capability_mac_key_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow { lane: "approval capability MAC key (server)", target: approval_mac_key_secret_ref } data approval_submission_mac_key_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow { lane: "approval submission MAC key (runner + server)", target: approval_submission_mac_key_secret_ref } +// THE NTFY PUBLISHER TOKEN, read by gunbc.host_credential_custody_converge over WIF. NOT YET +// APPLIED: until this ensure runs under the operator's token, the custody mode's Secret Manager +// read refuses and the receipt names the secret -- loud, never a guessed grant. +data approval_ntfy_publisher_token_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow { lane: "approval ntfy publisher token (srv1 custody)", target: approval_ntfy_publisher_token_secret_ref } + data fleet_accessor_grant_roster: List = [ spark_accessor_row, mtcollins1_bmc_accessor_row, approval_capability_mac_key_accessor_row, approval_submission_mac_key_accessor_row, + approval_ntfy_publisher_token_accessor_row, ] fn fleet_accessor_grant(row: FleetAccessorGrantRow) -> SecretAccessGrant { diff --git a/dag/gunbc/auth/privileged_effect_census.dag b/dag/gunbc/auth/privileged_effect_census.dag index 5a05e40b735..f579dfa6482 100644 --- a/dag/gunbc/auth/privileged_effect_census.dag +++ b/dag/gunbc/auth/privileged_effect_census.dag @@ -285,8 +285,8 @@ data privileged_effect_census: List = [ divergence_reason: none, }, PrivilegedEffectSite { - site: site(module_path: "gunbc.microvm_controller_app_key_converge", decl_name: "microvm_controller_app_key_converge_ci_wet"), - effect: recurring_credential_read(subject: "read the gunbai-ci App key over WIF and place it on one runner host as root:root 0400, outside every jail root, for the microVM lifecycle controller" as NonEmptyStr), + site: site(module_path: "gunbc.host_credential_custody_converge", decl_name: "host_credential_custody_converge_ci_wet"), + effect: recurring_credential_read(subject: "read one rostered custody credential over WIF -- the gunbai-ci App key for the microVM lifecycle controller, or the approval ntfy publisher token -- and place it on one fleet host at its row's owner, group and mode" as NonEmptyStr), realized: RealizedFederatedGrant, divergence_reason: none, }, diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 15548c65cad..9e6806a21d5 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -887,9 +887,9 @@ data gunbc_ci_runner_password_session_tool_converge_target: GunbcRunStepTarget = function: "runner_password_session_tool_converge_ci_wet", } -data gunbc_ci_microvm_controller_app_key_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { - entry: "dag/gunbc/runner/microvm_controller_app_key_converge.dag", - function: "microvm_controller_app_key_converge_ci_wet", +data gunbc_ci_host_credential_custody_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/fleet/host_credential_custody_converge.dag", + function: "host_credential_custody_converge_ci_wet", } // THE OBSERVER SIDE OF ONE RESET ATTEMPT. It takes no arguments: the dispatch names the OBSERVER, the @@ -1147,7 +1147,7 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_site_pxe_edge_observe_target, gunbc_ci_site_pxe_edge_converge_target, gunbc_ci_runner_password_session_tool_converge_target, - gunbc_ci_microvm_controller_app_key_converge_target, + gunbc_ci_host_credential_custody_converge_target, gunbc_ci_runner_microvm_boot_probe_target, gunbc_ci_host_reset_return_target, gunbc_ci_host_reset_dispatch_admission_target, @@ -2361,13 +2361,13 @@ fn gunbc_ci_runner_password_session_tool_converge_invoke() -> String { ) } -fn gunbc_ci_microvm_controller_app_key_converge_invoke() -> String { +fn gunbc_ci_host_credential_custody_converge_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, - entry: gunbc_ci_microvm_controller_app_key_converge_target.entry, - function: gunbc_ci_microvm_controller_app_key_converge_target.function, + entry: gunbc_ci_host_credential_custody_converge_target.entry, + function: gunbc_ci_host_credential_custody_converge_target.function, claim_run: false, - receipt_rel: "target/microvm-controller-app-key-converge-receipt.txt" + receipt_rel: "target/host-credential-custody-converge-receipt.txt" ) } diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index e98305bb89a..ce5619160f4 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -128,6 +128,7 @@ import gunbc.ci_spec { } import gunbc.fleet_converge_receipt { fleet_converge_plan_run_id_env_name } import gunbc.actions_run_binding { rlm_expected_revision_env_name, fleet_converge_expected_host_env_name } +import gunbc.host_credential_custody_converge { host_custody_credential_options, host_credential_custody_env_name } import gunbc.spark.credential_workflow { spark_administrator_credential_roster_hosts } import gunbc.fleet_runner_connectivity { fleet_hosts } import gunbc.runner_slot_allocation { runner_host_label } @@ -221,7 +222,7 @@ type FleetConvergeWorkflowMode | SitePxeEdgeObserve | SitePxeEdgeConverge | RunnerPasswordSessionToolConverge - | MicrovmControllerAppKeyConverge + | HostCredentialCustodyConverge | R2MintPreflight | R2BucketEnsure | R2BucketAdminMint @@ -261,7 +262,7 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String SitePxeEdgeObserve => "site_pxe_edge_observe" SitePxeEdgeConverge => "site_pxe_edge_converge" RunnerPasswordSessionToolConverge => "runner_password_session_tool_converge" - MicrovmControllerAppKeyConverge => "microvm_controller_app_key_converge" + HostCredentialCustodyConverge => "host_credential_custody_converge" R2MintPreflight => "r2_mint_preflight" R2BucketEnsure => "r2_bucket_ensure" R2BucketAdminMint => "r2_bucket_admin_mint" @@ -296,7 +297,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, DashboardDeploy, ApprovalBrokerDarkInstall, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, MtCollins1Boot, MtCollins1FanObserve, MtCollins1CensusImagePublish, MicrovmControllerAppKeyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, DashboardDeploy, ApprovalBrokerDarkInstall, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, MtCollins1Boot, MtCollins1FanObserve, MtCollins1CensusImagePublish, HostCredentialCustodyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // // EVERY MODE IS NAMED, AND THE WILDCARD IS DELIBERATELY ABSENT. A `_ => none` would read the same @@ -345,7 +346,7 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc SitePxeEdgeObserve => none SitePxeEdgeConverge => none RunnerPasswordSessionToolConverge => none - MicrovmControllerAppKeyConverge => none + HostCredentialCustodyConverge => none R2MintPreflight => none R2BucketEnsure => none R2BucketAdminMint => none @@ -454,7 +455,7 @@ fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> SitePxeEdgeObserve => FleetSshKeyConsumed SitePxeEdgeConverge => FleetSshKeyConsumed RunnerPasswordSessionToolConverge => FleetSshKeyConsumed - MicrovmControllerAppKeyConverge => FleetSshKeyConsumed + HostCredentialCustodyConverge => FleetSshKeyConsumed R2MintPreflight => FleetSshKeyConsumed R2ObjectWriteMint => FleetSshKeyConsumed ApprovalKeyringConverge => FleetSshKeyConsumed @@ -1285,45 +1286,48 @@ fn fleet_converge_runner_password_session_tool_converge_step() -> Step { ) } -// THE CONTROLLER APP KEY MODE (gunbc.microvm_controller_app_key_converge). A step in the host-pinned -// job like the password-session mode, but NOT through fleet_converge_runner_host_file_step: it reads -// the key from Secret Manager in-process, so it carries the run's workload-identity token beside the -// host binding. Dispatching it delivers custody only; nothing selects the microVM path for the floor -// job. -data fleet_converge_microvm_controller_app_key_converge_step_if: String = fleet_converge_mode_step_if(mode: MicrovmControllerAppKeyConverge) +// THE HOST CREDENTIAL CUSTODY MODE (gunbc.host_credential_custody_converge). ONE mode for every +// custody row: the credential is the `credential` dispatch choice over that module's closed roster +// (host_custody_credential_options), not a mode per secret. A step in the host-pinned job like the +// password-session mode, but NOT through fleet_converge_runner_host_file_step: it reads the +// credential from Secret Manager in-process, so it carries the run's workload-identity token beside +// the host binding. Dispatching the controller-key row delivers custody only; nothing selects the +// microVM path for the floor job. +data fleet_converge_host_credential_custody_converge_step_if: String = fleet_converge_mode_step_if(mode: HostCredentialCustodyConverge) -fn fleet_converge_microvm_controller_app_key_converge_step() -> Step { +fn fleet_converge_host_credential_custody_converge_step() -> Step { RunStep { - name: Present { value: "MicroVM controller App key: deliver root:root 0400 from Secret Manager, read owner, mode, bytes and staging back" }, - id: Present { value: "microvm_controller_app_key_converge" }, - run: gunbc_ci_microvm_controller_app_key_converge_invoke(), + name: Present { value: "Host credential custody: deliver the chosen rostered credential from Secret Manager at its row's owner, group and mode, read owner, mode, directory, bytes and staging back" }, + id: Present { value: "host_credential_custody_converge" }, + run: gunbc_ci_host_credential_custody_converge_invoke(), shell: none, env: Present { value: [ kv(key: fleet_converge_expected_host_env_name, value: yaml_string(s: "${{ github.event.inputs.host }}")), + kv(key: host_credential_custody_env_name, value: yaml_string(s: "${{ github.event.inputs.credential }}")), kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), ] }, working_directory: none, - if_condition: Present { value: fleet_converge_microvm_controller_app_key_converge_step_if }, + if_condition: Present { value: fleet_converge_host_credential_custody_converge_step_if }, continue_on_error: none, timeout_minutes: Present { value: gunbc_ci_runner_host_file_step_timeout_minutes } } } -fn fleet_converge_microvm_controller_app_key_receipt_upload_step() -> Step { +fn fleet_converge_host_credential_custody_receipt_upload_step() -> Step { UsesStep { - name: Present { value: "Upload microVM controller App key custody receipt" }, - id: Present { value: "microvm_controller_app_key_receipt_upload" }, + name: Present { value: "Upload host credential custody receipt" }, + id: Present { value: "host_credential_custody_receipt_upload" }, uses: upload_artifact_action, with: Present { value: [ - kv(key: "name", value: yaml_string(s: "microvm-controller-app-key-converge-receipt")), - kv(key: "path", value: yaml_string(s: "target/microvm-controller-app-key-converge-receipt.txt")), + kv(key: "name", value: yaml_string(s: "host-credential-custody-converge-receipt")), + kv(key: "path", value: yaml_string(s: "target/host-credential-custody-converge-receipt.txt")), kv(key: "if-no-files-found", value: yaml_string(s: "error")), kv(key: "retention-days", value: yaml_int(n: 30)), ] }, env: none, - if_condition: Present { value: concat("always() && (", concat(fleet_converge_microvm_controller_app_key_converge_step_if, ")")) }, + if_condition: Present { value: concat("always() && (", concat(fleet_converge_host_credential_custody_converge_step_if, ")")) }, continue_on_error: none, timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } } @@ -2445,8 +2449,8 @@ fn fleet_converge_job() -> Job { fleet_converge_runner_host_file_converge_step(), fleet_converge_runner_password_session_tool_converge_step(), fleet_converge_runner_password_session_tool_receipt_upload_step(), - fleet_converge_microvm_controller_app_key_converge_step(), - fleet_converge_microvm_controller_app_key_receipt_upload_step(), + fleet_converge_host_credential_custody_converge_step(), + fleet_converge_host_credential_custody_receipt_upload_step(), fleet_converge_runner_host_file_receipt_upload_step(), fleet_converge_site_pxe_edge_observe_step(), fleet_converge_site_pxe_edge_converge_step(), @@ -2613,6 +2617,13 @@ data fleet_converge_workflow: Workflow = Workflow { default: none, type: InputChoice { options: fleet_converge_reset_observer_options }, }, + DispatchInput { + name: "credential", + description: Present { value: "host_credential_custody_converge only: which rostered custody row to deliver (gunbc.host_credential_custody_converge). A closed choice, never a free-text secret name: each option carries its own SecretRef, path, owner, group, mode and directory, and a row scoped to one host refuses any other" }, + required: false, + default: none, + type: InputChoice { options: host_custody_credential_options }, + }, DispatchInput { name: "app_key_version", description: Present { value: "app_key_version_verify only: the Secret Manager version NUMBER of ci-github-app-private-key to verify. The alias latest is refused -- it names whatever is newest when it resolves, so a mint through it proves nothing about a specific key" }, diff --git a/dag/gunbc/fleet/host_credential_custody_converge.dag b/dag/gunbc/fleet/host_credential_custody_converge.dag new file mode 100644 index 00000000000..6871d470901 --- /dev/null +++ b/dag/gunbc/fleet/host_credential_custody_converge.dag @@ -0,0 +1,1133 @@ +module gunbc.host_credential_custody_converge + +import std.types { String, NonEmptyStr, List, Bool, Int, FilePath } +import std.algebra { trim } +import std.resources { Network } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.cloud.gcp.secret_ref { SecretRef, secret_ref_version_resource } +import extdeps.cloud.gcp.secret_manager { SmResolvedVersionIdentity } +import extdeps.tools.chown { chown_argv, ChownOwnerNames } +import extdeps.posix.identity { PosixOwnerNames, posix_owner_names_text } +import product.placement_supply { HostIdentity } +import gunbc.clock_read { clock_now_probed_at, probed_at_word } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name } +import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } +import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext, SshTarget } +import gunbc.fleet_intent_network { operator_host_srv1 } +import gunbc.fleet_posix_accounts { fleet_posix_root_user } +import gunbc.file_access { FileTreeScope, path_within_file_tree_scope } +import gunbc.auth.github_apps { gunbai_ci_app_pem_secret } +import gunbc.auth.approval_ntfy_deployment { approval_ntfy_principal_name, approval_ntfy_publisher_token_secret_ref } +import gunbc.auth.approval_request_submission { approval_publisher_token_path } +import gunbc.auth.access_token_source { WorkloadIdentityToken } +import gunbc.auth.secret_ref_credential { + fetch_secret_ref_credential, + SecretCredentialReady, + SecretCredentialAccessUpsertRequired, + SecretCredentialWireDecodeRefused, + SecretCredentialFetchRefused, + SecretCredentialResolvedVersionMismatch, +} +import gunbc.github_effect_perform { lifecycle_controller_app_key } +import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile } +import gunbc.fabric_cell_converge { fabric_cell_base_dir } +import gunbc.typed_remote_file_write { + RemoteFileMode, + remote_file_mode_octal, + RemoteFileModeOwnerRead, + RemoteFileModeOwnerReadWriteGroupRead, + RemoteFileViaNonInteractiveSudo, + TypedRemoteFileWrite, + TypedRemoteFileWriteAdmitted, + TypedRemoteFileWriteAdmissionRefused, + TypedRemoteFileConverged, + TypedRemoteFileConvergenceRefused, + TypedRemoteFileStepRan, + TypedRemoteFileStepArgvRefused, + TypedRemoteFileStepLegRefused, + typed_remote_file_write_seal, + typed_remote_file_staging_path, + typed_remote_file_compare_argv, + typed_remote_file_step, + classify_typed_remote_file_comparison, + remote_file_privileged_argv, + converge_typed_remote_file, +} +import gunbc.runner_host_deploy { runner_host_spec_for, RunnerHostSpecFound, RunnerHostSpecAbsent } +import gunbc.runner_host_grants { bootstrap_principal_is_not_the_job_user } +import gunbc.fleet_bootstrap_principal { executor_bootstrap_principal } +import gunbc.live_deploy.member_observe { path_parent, path_basename, parent_listing_argv, listing_contains_entry } +import gunbc.runner_host_file_converge { + administrator_ssh_target, + RunnerHostFileStanding, + HostFileIdentical, + HostFileDiffers, + HostFileAbsent, + HostFileUnobservable, + runner_host_file_standing_of_report, + RunnerHostPathMetadata, + PathsRootOnly, + PathsNotRootOnly, + PathsUnobservable, + PathsNotApplicable, + runner_host_path_ancestors, + runner_host_file_scope, + runner_host_root_only_find_argv, + classify_root_only_paths, + runner_host_path_metadata_wire, +} + +// ONE CUSTODY AUTHORITY FOR CREDENTIALS DELIVERED FROM SECRET MANAGER TO A FLEET HOST FILE. +// +// gunbc#11679 landed this module as the microVM controller's App-key converge, with root:root 0400 +// and a root-only directory spelled as constants. The second credential with the same shape -- the +// ntfy publisher token the approval broker publishes with (gunbc.auth.approval_request_submission +// read_publisher_token) -- was a HumanIntervention: an operator created it and wrote it where the +// unit could read it. Rather than a sibling module per credential (a second custody home, DESIGN +// §3) or one fleet-converge mode per credential (a hand-enumerated mode roster that grows with +// every secret), the constants became the FIELDS OF A ROW and the credential became a DISPATCH +// CHOICE over a CLOSED ROSTER of rows. The controller key is the first row; its root:root 0400 is +// that row's data, not a special case beside the roster. +// +// WHAT A ROW CARRIES. The SecretRef, the path, the host scope, the file's owner and group +// principals and its MODE ARM, and the DIRECTORY's owner, group and mode arm. The directory is a +// row field because a group-readable token is unreadable by that group unless the group can +// TRAVERSE the directory: a 0640 root:gunbc-ntfy token needs a 0750 root:gunbc-ntfy directory, and +// the predecessor's check refused every group bit on the directory. That refusal was right for a +// root-only key and wrong for a service token, so it is now DERIVED from the row's directory arm +// rather than relaxed. +// +// MODE IS A CLOSED ARM, NEVER A CALLER OCTAL. Both far-side legs are derived from the arm: the +// exact-mode leg (`-perm 0400` / `-perm 0640`) and the forbidden-bits leg (`-perm /077` / +// `-perm /037`), and likewise for the directory (`0700` + `/077`, `0750` + `/027`). The owner leg +// takes `-user`/`-group` from the row. +// +// ROW ADMISSION REFUSES THE INCOHERENT PAIRING BEFORE ANY FETCH. A row whose file grants its group +// read under a directory that denies that group traversal would deliver a token its reader cannot +// open while the readback reported the file exactly as declared -- green on every leg and useless. +// custody_row_placement refuses it, typed and located (CustodyGroupCannotTraverse), rather than +// widening the directory to make it work (DESIGN §5 absorbing fallback). +// +// WHAT THE READBACK ESTABLISHES, AND WHAT IT DOES NOT. This caveat is load-bearing and must not be +// lost in a later edit. The find-metadata readback below establishes which principals HOLD +// PERMISSION BITS on the file and its directories. It does NOT establish that a principal without +// those bits cannot open the file -- an ACL, a capability, a bind mount or a second hard link are +// all outside what `find -perm -user -group` observes -- and establishing that needs a leg that +// EXECUTES AS ANOTHER USER and is refused. gunbc.rung_drop approval_store_single_writer_by_agreement +// names exactly that stronger observation as its restoration trigger and names a permission-bits +// readback among the things that do NOT retire it. So this module is NECESSARY-BUT-NOT-SUFFICIENT +// for that trigger; citing a HELD receipt from here as retiring it is §4b(1) rung inflation. +// +// THE CUSTODY THE CONTROLLER ROW NARROWS. The shared-slot path still reads gunbc.runner_unit_file +// runner_host_app_pem_path (/etc/actions-runner/app.pem), owned by the RUNNER USER. That file is +// deliberately NOT touched here: the shared-slot path is X in a replacement migration (DESIGN §3), +// still load-bearing. The controller row establishes the Y-side custody -- a copy whose permission +// bits admit root alone, outside every jail and attempt root. Nothing here selects the microVM path. +// +// THE FETCH IS TYPED, NOT A PRELUDE: gunbc.auth.secret_ref_credential fetch_secret_ref_credential +// over the run's workload-identity token, refusing a response about a different version than the +// one requested. The plaintext never touches the near-side disk. +// +// DIGEST, STATED AS WHAT IS ACTUALLY CHECKED. .dag has no SHA-256 over a String, so the comparison +// is converge_typed_remote_file's far-side `cmp -s -` against the fetched version's exact bytes on +// stdin -- byte equality, which is stronger than a digest match. The receipt names the resolved +// version identity, never the bytes. +// +// PLAINTEXT STAGING. The only staging is the far-side sibling .gunbc-staging, inside the row's +// directory, verified BEFORE the stage leg runs. For a root-only row no non-root uid can traverse +// to it. For a group row the staging file is briefly root:root with the far side's umask before its +// chmod; the directory admits only root and the row's group, which is the principal the token is +// delivered TO, so the window exposes nothing beyond the declared reader. It is removed after every +// attempt WHOSE ANCESTOR AND DIRECTORY AUTHORITY HAS BEEN ESTABLISHED -- never beneath a path the +// route declined to trust, because `rm` is a root mutation through that same path (see +// StagingCleanupAuthorization, carried over from gunbc#11902). Its absence is READ BACK from a +// listing of its parent; a failed removal, a surviving sibling, or a withheld cleanup refuses the +// run (the cleanup-is-part-of-the-mint rule of gunbc.secret_provision_actuator). + +// ── The closed roster (pure) ───────────────────────────────────────────────────────────────────── + +// A CLOSED COPRODUCT, NOT A STRING. The dispatch input is a choice whose options are this roster's +// wire names, and the entry maps the chosen word back through the roster, so an unrostered word is +// a typed refusal and there is no free-text path to a SecretRef. +type HostCustodyCredential + = ControllerAppKey + | ApprovalNtfyPublisherToken + +data host_custody_credential_roster: List = [ControllerAppKey, ApprovalNtfyPublisherToken] + +fn host_custody_credential_wire(c: HostCustodyCredential) -> String { + match c { + ControllerAppKey => "controller_app_key" + ApprovalNtfyPublisherToken => "approval_ntfy_publisher_token" + } +} + +data host_custody_credential_options: List = map(host_custody_credential_roster, c => host_custody_credential_wire(c: c)) + +type HostCustodyCredentialSelection + = HostCustodyCredentialSelected { credential: HostCustodyCredential } + | HostCustodyCredentialUnrostered { supplied: String } + +fn select_host_custody_credential(supplied: String) -> HostCustodyCredentialSelection { + let word = trim(s: supplied) + fold(host_custody_credential_roster, init: HostCustodyCredentialUnrostered { supplied: word }, f: (acc, c) => + match acc { + HostCustodyCredentialSelected { credential: _ } => acc + HostCustodyCredentialUnrostered { supplied: _ } => + if host_custody_credential_wire(c: c) == word { HostCustodyCredentialSelected { credential: c } } else { acc } + } + ) +} + +// THE FILE'S MODE ARM. Each arm derives the write mode, the exact-mode leg, the forbidden-bits leg, +// and whether it grants the group read -- four facts, one match each, no octal parameter anywhere. +type CustodyFileMode + = CustodyFileOwnerRead + | CustodyFileOwnerReadWriteGroupRead + +fn custody_file_remote_mode(m: CustodyFileMode) -> RemoteFileMode { + match m { + CustodyFileOwnerRead => RemoteFileModeOwnerRead + CustodyFileOwnerReadWriteGroupRead => RemoteFileModeOwnerReadWriteGroupRead + } +} + +// THE EXACT-MODE LEG IS DERIVED FROM THE WRITE MODE, NOT RE-SPELLED BESIDE IT (review 69522). This +// used to match the arm and return its own octal literal, which made the mode a file is WRITTEN at +// and the mode it is ASSERTED at two independent facts that could drift apart -- and the drift would +// have been silent in the source and visible only as a readback refusal on a live host. The write +// mode's octal is owned by gunbc.typed_remote_file_write remote_file_mode_octal, so the readback leg +// asks that authority and prefixes the leading zero find(1) wants. One home, one octal (DESIGN §3). +fn custody_file_exact_perm(m: CustodyFileMode) -> String { + join(["0", remote_file_mode_octal(mode: custody_file_remote_mode(m: m))], "") +} + +fn custody_file_forbidden_perm(m: CustodyFileMode) -> String { + match m { + CustodyFileOwnerRead => "/077" + CustodyFileOwnerReadWriteGroupRead => "/037" + } +} + +fn custody_file_grants_group(m: CustodyFileMode) -> Bool { + match m { + CustodyFileOwnerRead => false + CustodyFileOwnerReadWriteGroupRead => true + } +} + +// THE DIRECTORY'S MODE ARM, the field the predecessor did not have. +type CustodyDirMode + = CustodyDirOwnerOnly + | CustodyDirOwnerAllGroupTraverse + +fn custody_dir_exact_perm(m: CustodyDirMode) -> String { + match m { + CustodyDirOwnerOnly => "0700" + CustodyDirOwnerAllGroupTraverse => "0750" + } +} + +fn custody_dir_forbidden_perm(m: CustodyDirMode) -> String { + match m { + CustodyDirOwnerOnly => "/077" + CustodyDirOwnerAllGroupTraverse => "/027" + } +} + +fn custody_dir_grants_group_traverse(m: CustodyDirMode) -> Bool { + match m { + CustodyDirOwnerOnly => false + CustodyDirOwnerAllGroupTraverse => true + } +} + +// Principals by NAME (extdeps.posix.identity PosixOwnerNames), because find(1)'s -user/-group and +// chown(1) both take names and a service account's numeric ids are allocated per host +// (gunbc.fleet_posix_accounts), not declared. +type CustodyHostScope + = CustodyAnyRunnerHost + | CustodyOnlyOnHost { host: HostIdentity } + +type HostCredentialCustodyRow { + credential: HostCustodyCredential + secret: SecretRef + path: NonEmptyStr + host_scope: CustodyHostScope + file_ownership: PosixOwnerNames + file_mode: CustodyFileMode + dir_ownership: PosixOwnerNames + dir_mode: CustodyDirMode +} + +// The controller key's path is the signer's own key reference (gunbc.github_effect_perform +// lifecycle_controller_app_key), never re-spelled; HostKeyFile is the only arm that names a file. +fn signing_key_file_path(key: JwsSigningKeyRef) -> NonEmptyStr { + match key { + HostKeyFile { path: p } => p + } +} + +data custody_root_only: PosixOwnerNames = PosixOwnerNames { + user: fleet_posix_root_user.name, + group: fleet_posix_root_user.name, +} + +// The directory's ownership and mode here restate gunbc.auth.approval_ntfy_converge +// NtfyConfigDirectory (root:gunbc-ntfy 0750), which ensures the same directory for the server's +// config; test.claim.host_credential_custody_converge_witness holds the two equal, because two +// converges asserting different modes on one directory would flap it on every run. +data custody_root_owned_ntfy_group: PosixOwnerNames = PosixOwnerNames { + user: fleet_posix_root_user.name, + group: approval_ntfy_principal_name, +} + +fn host_credential_custody_row(c: HostCustodyCredential) -> HostCredentialCustodyRow { + match c { + ControllerAppKey => HostCredentialCustodyRow { + credential: c, + secret: gunbai_ci_app_pem_secret, + path: signing_key_file_path(key: lifecycle_controller_app_key), + host_scope: CustodyAnyRunnerHost, + file_ownership: custody_root_only, + file_mode: CustodyFileOwnerRead, + dir_ownership: custody_root_only, + dir_mode: CustodyDirOwnerOnly, + } + ApprovalNtfyPublisherToken => HostCredentialCustodyRow { + credential: c, + secret: approval_ntfy_publisher_token_secret_ref, + path: approval_publisher_token_path, + host_scope: CustodyOnlyOnHost { host: operator_host_srv1 }, + file_ownership: custody_root_owned_ntfy_group, + file_mode: CustodyFileOwnerReadWriteGroupRead, + dir_ownership: custody_root_owned_ntfy_group, + dir_mode: CustodyDirOwnerAllGroupTraverse, + } + } +} + +// ── Row admission (pure) ───────────────────────────────────────────────────────────────────────── + +data custody_forbidden_tree: FileTreeScope = FileTreeScope { root_path: fabric_cell_base_dir as NonEmptyStr } + +type CustodyPlacementRefusal + = CustodyPathInForbiddenTree { path: NonEmptyStr } + | CustodyPathOutsideScope { path: NonEmptyStr } + | CustodyPathNoDedicatedDirectory { path: NonEmptyStr } + | CustodyGroupCannotTraverse { path: NonEmptyStr, dir: NonEmptyStr, group: NonEmptyStr, file_perm: String, dir_perm: String } + | CustodyDirectoryGroupDiffers { path: NonEmptyStr, file_group: NonEmptyStr, dir_group: NonEmptyStr } + +type CustodyPlacement + = CustodyPlaced { row: HostCredentialCustodyRow, key_dir: NonEmptyStr, upper_ancestors: List } + | CustodyPlacementRefused { cause: CustodyPlacementRefusal } + +fn custody_placement_refusal_wire(r: CustodyPlacementRefusal) -> String { + match r { + CustodyPathInForbiddenTree { path: p } => + join(["custody path ", p as String, " is inside the fabric-cell tree ", fabric_cell_base_dir, ", which roots every attempt and jail"], "") + CustodyPathOutsideScope { path: p } => + join(["custody path ", p as String, " is outside ", runner_host_file_scope.root_path as String], "") + CustodyPathNoDedicatedDirectory { path: p } => + join(["custody path ", p as String, " has no dedicated directory below ", runner_host_file_scope.root_path as String, " whose mode the row can own"], "") + CustodyGroupCannotTraverse { path: p, dir: d, group: g, file_perm: fp, dir_perm: dp } => + join(["custody row for ", p as String, " grants group ", g as String, " read (", fp, ") but its directory ", d as String, " is declared ", dp, ", which denies that group traversal -- the reader could not open the file the readback would report as held"], "") + CustodyDirectoryGroupDiffers { path: p, file_group: fg, dir_group: dg } => + join(["custody row for ", p as String, " grants group ", fg as String, " read but its directory is group ", dg as String, ", so the file's group has no traversal through it"], "") + } +} + +fn last_or_empty(xs: List) -> String { + fold(xs, init: "", f: fn(_, x) { x }) +} + +// The directory and its ancestors derive from the row's one path, never re-spelled. The group +// clauses are the row-model repair: a group-readable file requires a group-traversable directory +// owned by THAT group, and neither half is widened to make the pairing work. +fn custody_row_placement(row: HostCredentialCustodyRow) -> CustodyPlacement { + let key_path = row.path + let ancestors = runner_host_path_ancestors(path: key_path as String) + let dir = last_or_empty(xs: ancestors) + if path_within_file_tree_scope(scope: custody_forbidden_tree, path: key_path) { + CustodyPlacementRefused { cause: CustodyPathInForbiddenTree { path: key_path } } + } else if !path_within_file_tree_scope(scope: runner_host_file_scope, path: key_path) { + CustodyPlacementRefused { cause: CustodyPathOutsideScope { path: key_path } } + } else if dir == "" || dir == (runner_host_file_scope.root_path as String) { + CustodyPlacementRefused { cause: CustodyPathNoDedicatedDirectory { path: key_path } } + } else if custody_file_grants_group(m: row.file_mode) && !custody_dir_grants_group_traverse(m: row.dir_mode) { + CustodyPlacementRefused { + cause: CustodyGroupCannotTraverse { + path: key_path, + dir: dir as NonEmptyStr, + group: row.file_ownership.group, + file_perm: custody_file_exact_perm(m: row.file_mode), + dir_perm: custody_dir_exact_perm(m: row.dir_mode), + }, + } + } else if custody_file_grants_group(m: row.file_mode) && (row.file_ownership.group as String) != (row.dir_ownership.group as String) { + CustodyPlacementRefused { + cause: CustodyDirectoryGroupDiffers { path: key_path, file_group: row.file_ownership.group, dir_group: row.dir_ownership.group }, + } + } else { + CustodyPlaced { + row: row, + key_dir: dir as NonEmptyStr, + upper_ancestors: filter(ancestors, a => a != dir), + } + } +} + +// ── Readback vocabulary (pure) ─────────────────────────────────────────────────────────────────── + +// THE THREE FAR-SIDE QUESTIONS ONE PATH IS ASKED, each derived from the row. One find per clause so +// a refusal says WHICH clause failed; `%` cannot cross the SSH edge +// (gunbc.fleet_known_hosts_anchor portable_remote_word_char_allowed), so a stat format cannot. +type CustodyPathLegs { + kind: String + ownership: PosixOwnerNames + exact_perm: String + forbidden_perm: String +} + +fn custody_file_legs(row: HostCredentialCustodyRow) -> CustodyPathLegs { + CustodyPathLegs { + kind: "f", + ownership: row.file_ownership, + exact_perm: custody_file_exact_perm(m: row.file_mode), + forbidden_perm: custody_file_forbidden_perm(m: row.file_mode), + } +} + +fn custody_dir_legs(row: HostCredentialCustodyRow) -> CustodyPathLegs { + CustodyPathLegs { + kind: "d", + ownership: row.dir_ownership, + exact_perm: custody_dir_exact_perm(m: row.dir_mode), + forbidden_perm: custody_dir_forbidden_perm(m: row.dir_mode), + } +} + +fn custody_owner_clause(o: PosixOwnerNames) -> List { + ["-user", o.user as String, "-group", o.group as String] +} + +fn custody_perm_clause(perm: String) -> List { + ["-perm", perm] +} + +type CustodyFindReading { + owned_as_declared: Bool + forbidden_bits: Bool + exact_mode: Bool +} + +type CustodyPathMetadata + = CustodyPathAsDeclared + | CustodyPathWrongOwner + | CustodyPathForbiddenBits + | CustodyPathModeNotDeclared + | CustodyPathAbsent + | CustodyPathUnobservable { reason: String } + +// Owner first: a file owned by the wrong principal is the most dangerous state regardless of its +// mode, and reporting it as a mode defect would send the reader to chmod a file they do not own. +fn classify_custody_path(reading: CustodyFindReading) -> CustodyPathMetadata { + if !reading.owned_as_declared { + CustodyPathWrongOwner + } else if reading.forbidden_bits { + CustodyPathForbiddenBits + } else if !reading.exact_mode { + CustodyPathModeNotDeclared + } else { + CustodyPathAsDeclared + } +} + +fn custody_path_metadata_wire(m: CustodyPathMetadata, legs: CustodyPathLegs) -> String { + let declared = join(["owner=", posix_owner_names_text(names: legs.ownership), " mode=", legs.exact_perm], "") + match m { + CustodyPathAsDeclared => declared + CustodyPathWrongOwner => concat("WRONG-OWNER (declared ", concat(declared, ")")) + CustodyPathForbiddenBits => join(["FORBIDDEN-BITS (mode has bits in ", legs.forbidden_perm, ")"], "") + CustodyPathModeNotDeclared => concat("MODE-NOT-", legs.exact_perm) + CustodyPathAbsent => "ABSENT" + CustodyPathUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + } +} + +// THE STAGING SIBLING'S ABSENCE IS ESTABLISHED BY A SUCCESSFUL LISTING OF ITS PARENT, never by a +// failed read of the child (gunbc#11902): GNU `test -e` answers 1 for ENOENT, EACCES on an ancestor, +// ELOOP, ENOTDIR and EIO alike, so "gone" and "could not look" arrived as one value, and absence +// feeds CustodyHeld. The argv and the membership read are gunbc.live_deploy.member_observe's +// (parent_listing_argv, listing_contains_entry); only the TRANSPORT is this module's, because the +// parent admits root and at most the row's group, so an unelevated entry_presence could only ever +// answer Indeterminate -- a departure stated rather than silent (DESIGN §3b). +type StagingResidue + = StagingAbsent + | StagingResiduePresent { path: String } + | StagingUnobservable { reason: String } + +// CLEANUP IS A ROOT MUTATION, so it is entitled to the same path authority the WRITE is (gunbc#11902). +// The gate is PATH AUTHORITY and deliberately not the whole action: a content or file-metadata +// observation may be unobservable while the path is safely contained, and cleanup is still +// desirable there. So the seal joins three facts and nothing else -- upper ancestors PathsRootOnly, +// the directory exactly as the row declares it, and the staging path's parent being that admitted +// directory. sole_constructor makes an unauthorized cleanup UNWRITABLE: clear_staging DERIVES +// subject, parent, path and the residue predicate's key from the seal. +type StagingCleanupAuthorization sole_constructor { + subject: CustodyHost + parent: String + staging: String +} + +type StagingCleanupAdmission + = StagingCleanupAuthorized { authorization: StagingCleanupAuthorization } + | StagingCleanupWithheld { reason: String } + +// THE ONLY MINT. PURE, over readings the caller already has: no leg runs here. +fn admit_staging_cleanup( + subject: CustodyHost, + upper_ancestors: RunnerHostPathMetadata, + dir: CustodyPathMetadata, + key_dir: String, + staging: String, +) -> StagingCleanupAdmission { + match upper_ancestors { + PathsRootOnly => + match dir { + CustodyPathAsDeclared => + if path_parent(path: staging) != key_dir { + StagingCleanupWithheld { reason: join(["staging parent ", path_parent(path: staging), " is not the admitted directory ", key_dir], "") } + } else { + StagingCleanupAuthorized { + authorization: StagingCleanupAuthorization { subject: subject, parent: key_dir, staging: staging }, + } + } + CustodyPathUnobservable { reason: r } => StagingCleanupWithheld { reason: concat("directory metadata unobservable: ", r) } + _ => StagingCleanupWithheld { reason: concat("directory is not as the row declares it: ", key_dir) } + } + _ => StagingCleanupWithheld { reason: concat("an ancestor directory is not root-only: ", runner_host_path_metadata_wire(m: upper_ancestors)) } + } +} + +// THE PREFIX, not the whole reason: the withheld arm carries WHICH joined fact failed through. +data staging_cleanup_withheld_reason: String = "staging cleanup withheld because path authority was not established" + +// THE RESIDUE PREDICATE'S KEY IS DERIVED FROM THE SEAL, never supplied beside it. Carried from +// gunbc.microvm_controller_app_key_converge, which this module replaces, and hoisted to module-item +// grain there by #11941 just before the replacement deleted it -- the rationale outlives the module +// because the construction it explains does. As a free parameter, `name` was the one value of the +// three a caller could still spell freely, and it is exactly what this function keys on: a name that +// does not match the staging path's basename reads a listing CONTAINING the residue as +// StagingAbsent, and that false absence feeds a HELD verdict. The sole caller derives it as +// path_basename of the staging path for that reason (DESIGN §4b meta-obligation 1; §5, construction +// over validation). +// +// PURE, over a listing that SUCCEEDED; there is no exit code here to misread. +fn classify_staging_residue(path: String, name: String, stdout: String) -> StagingResidue { + if listing_contains_entry(stdout: stdout, name: name) { + StagingResiduePresent { path: path } + } else { + StagingAbsent + } +} + +fn staging_residue_wire(s: StagingResidue) -> String { + match s { + StagingAbsent => "absent" + StagingResiduePresent { path: p } => concat("RESIDUE ", p) + StagingUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + } +} + +fn content_standing_wire(s: RunnerHostFileStanding) -> String { + match s { + HostFileIdentical => "identical-to-sm-version" + HostFileDiffers => "DIFFERS-FROM-SM-VERSION" + HostFileAbsent => "ABSENT" + HostFileUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + } +} + +// THE READBACK AND ITS VERDICT. Held requires every component, observed after the last mutation, +// and means only what the caveat at the top says: the declared principals hold exactly the declared +// permission bits. It is not an observation that anyone else was refused. +type CustodyReadback { + file: CustodyPathMetadata + dir: CustodyPathMetadata + upper_ancestors: RunnerHostPathMetadata + content: RunnerHostFileStanding + staging: StagingResidue +} + +type CustodyStanding + = CustodyHeld + | CustodyNotHeld { causes: List } + +fn host_credential_custody(row: HostCredentialCustodyRow, readback: CustodyReadback) -> CustodyStanding { + let causes = concat(concat(concat(concat( + match readback.file { + CustodyPathAsDeclared => [] + _ => [concat("file ", custody_path_metadata_wire(m: readback.file, legs: custody_file_legs(row: row)))] + }, + match readback.dir { + CustodyPathAsDeclared => [] + _ => [concat("dir ", custody_path_metadata_wire(m: readback.dir, legs: custody_dir_legs(row: row)))] + }), + match readback.upper_ancestors { + PathsRootOnly => [] + _ => [concat("ancestors ", runner_host_path_metadata_wire(m: readback.upper_ancestors))] + }), + match readback.content { + HostFileIdentical => [] + _ => [concat("content ", content_standing_wire(s: readback.content))] + }), + match readback.staging { + StagingAbsent => [] + _ => [concat("staging ", staging_residue_wire(s: readback.staging))] + }) + if length(causes) == 0 { CustodyHeld } else { CustodyNotHeld { causes: causes } } +} + +// ── The decision before any write (pure) ───────────────────────────────────────────────────────── + +type CustodyAction + = CustodyLeaveInPlace + | CustodyWrite + | CustodyActionRefused { reason: String } + +// The directory and the ancestors are decided first because a write cannot repair them and writing +// into a directory that is not as declared could publish the credential where an undeclared +// principal holds bits on it. An unsafe FILE is repaired by the write: the staged sibling is +// created by root, moded from the arm, renamed over it, and then owned from the row. +fn custody_action( + dir: CustodyPathMetadata, + upper_ancestors: RunnerHostPathMetadata, + content: RunnerHostFileStanding, + file: CustodyPathMetadata, +) -> CustodyAction { + match upper_ancestors { + PathsRootOnly => + match dir { + CustodyPathAsDeclared => + match content { + HostFileUnobservable { reason: r } => CustodyActionRefused { reason: concat("content unobservable: ", r) } + HostFileAbsent => CustodyWrite + HostFileDiffers => CustodyWrite + HostFileIdentical => + match file { + CustodyPathAsDeclared => CustodyLeaveInPlace + CustodyPathUnobservable { reason: r } => CustodyActionRefused { reason: concat("file metadata unobservable: ", r) } + _ => CustodyWrite + } + } + CustodyPathUnobservable { reason: r } => CustodyActionRefused { reason: concat("directory metadata unobservable: ", r) } + _ => CustodyActionRefused { reason: "directory is not as the row declares it" } + } + _ => CustodyActionRefused { reason: concat("an ancestor directory is not root-only: ", runner_host_path_metadata_wire(m: upper_ancestors)) } + } +} + +fn custody_action_wire(a: CustodyAction) -> String { + match a { + CustodyLeaveInPlace => "left-in-place" + CustodyWrite => "written" + CustodyActionRefused { reason: r } => concat("REFUSED ", r) + } +} + +// ── Remote legs (argv pure, execution effectful) ───────────────────────────────────────────────── + +fn custody_find_argv(path: String, kind: String, clause: List) -> List { + concat(["find", path, "-maxdepth", "0", "-type", kind], clause) +} + +fn custody_dir_ensure_argv(row: HostCredentialCustodyRow, dir: String) -> List { + ["install", "-d", "-o", row.dir_ownership.user as String, "-g", row.dir_ownership.group as String, "-m", custody_dir_exact_perm(m: row.dir_mode), dir] +} + +// converge_typed_remote_file writes as root and carries no ownership, so the row's owner and group +// are applied after the rename. Between the two the file is root:root at the arm's mode -- narrower +// than declared, never wider. +fn custody_file_chown_argv(row: HostCredentialCustodyRow) -> List { + chown_argv(owner: ChownOwnerNames { names: row.file_ownership }, path: row.path as String) +} + +fn staging_remove_argv(staging: String) -> List { + ["rm", "-f", "--", staging] +} + +fn elevated(argv: List) -> List { + remote_file_privileged_argv(privilege: RemoteFileViaNonInteractiveSudo, argv: argv) +} + +// THE SUBJECT OF A CUSTODY CONVERGE IS A HOST, NOT A SLOT POPULATION (gunbc#11902). A credential's +// location, its administrator target and its custody do not depend on how many runner slots a host +// admits, so the subject resolves from gunbc.runner_host_deploy runner_host_spec_for rather than from +// an admitted RunnerHostDeploy, which refuses a host whose width is unresolved. The privilege law is +// kept: the writer may not be the account CI jobs run as, asked of gunbc.runner_host_grants over the +// spec's job_user rather than re-spelled (DESIGN §3). +type CustodyHost { + host: HostIdentity + target: SshTarget +} + +type CustodyHostSubject + = CustodyHostSubjectAdmitted { subject: CustodyHost } + | CustodyHostSubjectRefused { cause: String } + +fn custody_host(host: HostIdentity) -> CustodyHostSubject { + match runner_host_spec_for(host: host) { + RunnerHostSpecAbsent { host: h } => + CustodyHostSubjectRefused { + cause: join(["host_credential_custody_converge: ", h as String, " has no runner host spec, so it is not a runner host"], ""), + } + RunnerHostSpecFound { spec: spec } => + if !bootstrap_principal_is_not_the_job_user(bootstrap: executor_bootstrap_principal, job_user: spec.job_user) { + CustodyHostSubjectRefused { + cause: join([ + "host_credential_custody_converge: the administrator principal ", executor_bootstrap_principal.login as String, + " is the job user on ", host as String, + ", so the account that places a credential would be the one CI jobs run as", + ], ""), + } + } else { + CustodyHostSubjectAdmitted { + subject: CustodyHost { host: host, target: administrator_ssh_target(host: host) }, + } + } + } +} + +type ElevatedLeg + = ElevatedLegRan { exit_code: Int, stdout: String, stderr: String } + | ElevatedLegRefused { reason: String } + +// Every leg is elevated: the directory admits root and at most the row's group, so an unprivileged +// find or test inside it would report the permission boundary as an answer. +fn run_elevated_leg( + subject: CustodyHost, + context: FleetSshExecutionContext, + argv: List, + stdin_payload: String, +) -> ElevatedLeg + uses net: Network +{ + match typed_remote_file_step(target: subject.target, context: context, raws: elevated(argv: argv), stdin_payload: stdin_payload) { + TypedRemoteFileStepArgvRefused => ElevatedLegRefused { reason: "argv refused as non-portable under the far-side shell re-parse allowlist" } + TypedRemoteFileStepLegRefused { cause: why } => ElevatedLegRefused { reason: why as String } + TypedRemoteFileStepRan { result: r } => ElevatedLegRan { exit_code: r.exit_code, stdout: r.stdout, stderr: r.stderr } + } +} + +type FindAnswer + = FindPrinted + | FindSilent + | FindUnobservable { reason: String } + +fn classify_find_answer(path: String, exit_code: Int, stdout: String, stderr: String) -> FindAnswer { + if exit_code != 0 { + FindUnobservable { reason: join(["find exit=", to_string(exit_code), " ", trim(s: stderr)], "") } + } else if any(split(s: stdout, delimiter: "\n"), l => trim(s: l) == path) { + FindPrinted + } else { + FindSilent + } +} + +fn find_answer( + subject: CustodyHost, + context: FleetSshExecutionContext, + path: String, + kind: String, + clause: List, +) -> FindAnswer + uses net: Network +{ + match run_elevated_leg(subject: subject, context: context, argv: custody_find_argv(path: path, kind: kind, clause: clause), stdin_payload: "") { + ElevatedLegRefused { reason: r } => FindUnobservable { reason: r } + ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => classify_find_answer(path: path, exit_code: c, stdout: o, stderr: e) + } +} + +fn find_printed(a: FindAnswer) -> Bool { + match a { + FindPrinted => true + FindSilent => false + FindUnobservable { reason: _ } => false + } +} + +fn observe_custody_path( + subject: CustodyHost, + context: FleetSshExecutionContext, + path: String, + legs: CustodyPathLegs, +) -> CustodyPathMetadata + uses net: Network +{ + match find_answer(subject: subject, context: context, path: path, kind: legs.kind, clause: []) { + FindUnobservable { reason: r } => + match run_elevated_leg(subject: subject, context: context, argv: ["test", "-e", path], stdin_payload: "") { + ElevatedLegRan { exit_code: c, stdout: _, stderr: _ } => + if c == 1 { CustodyPathAbsent } else { CustodyPathUnobservable { reason: r } } + ElevatedLegRefused { reason: _ } => CustodyPathUnobservable { reason: r } + } + FindSilent => CustodyPathUnobservable { reason: concat(concat("exists but is not of type ", legs.kind), ", or find printed nothing") } + FindPrinted => { + let owned = find_answer(subject: subject, context: context, path: path, kind: legs.kind, clause: custody_owner_clause(o: legs.ownership)) + let open = find_answer(subject: subject, context: context, path: path, kind: legs.kind, clause: custody_perm_clause(perm: legs.forbidden_perm)) + let exact = find_answer(subject: subject, context: context, path: path, kind: legs.kind, clause: custody_perm_clause(perm: legs.exact_perm)) + match owned { + FindUnobservable { reason: r } => CustodyPathUnobservable { reason: r } + _ => match open { + FindUnobservable { reason: r } => CustodyPathUnobservable { reason: r } + _ => match exact { + FindUnobservable { reason: r } => CustodyPathUnobservable { reason: r } + _ => classify_custody_path(reading: CustodyFindReading { + owned_as_declared: find_printed(a: owned), + forbidden_bits: find_printed(a: open), + exact_mode: find_printed(a: exact), + }) + } + } + } + } + } +} + +fn observe_upper_ancestors(subject: CustodyHost, context: FleetSshExecutionContext, paths: List) -> RunnerHostPathMetadata + uses net: Network +{ + match run_elevated_leg(subject: subject, context: context, argv: runner_host_root_only_find_argv(paths: paths, kind: "d"), stdin_payload: "") { + ElevatedLegRefused { reason: r } => PathsUnobservable { reason: r } + ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => classify_root_only_paths(requested: paths, exit_code: c, stdout: o, stderr: e) + } +} + +// PRESENCE FROM THE PARENT LISTING, NOT `test -e`. gunbc#11845 deleted classify_host_file_presence +// because `test -e` exit 1 collapses ENOENT with EACCES, ELOOP and EIO; absence is now read from an +// elevated listing of the parent that SUCCEEDED (member_observe's argv and membership read), and a +// listing that did not succeed is unobservable, never absent. A present file is then compared by the +// same far-side `cmp -s -` judgment converge_typed_remote_file mints from, so observation and apply +// agree on "identical". +fn observe_custody_content(subject: CustodyHost, context: FleetSshExecutionContext, write: TypedRemoteFileWrite) -> RunnerHostFileStanding + uses net: Network +{ + let path = write.path as String + match run_elevated_leg(subject: subject, context: context, argv: parent_listing_argv(parent: path_parent(path: path)), stdin_payload: "") { + ElevatedLegRefused { reason: r } => HostFileUnobservable { reason: concat("parent not listed: ", r) } + ElevatedLegRan { exit_code: lc, stdout: lo, stderr: le } => + if lc != 0 { + HostFileUnobservable { reason: join(["parent listing exit=", to_string(lc), " ", trim(s: le)], "") } + } else if !listing_contains_entry(stdout: lo, name: path_basename(path: path)) { + HostFileAbsent + } else { + match run_elevated_leg(subject: subject, context: context, argv: typed_remote_file_compare_argv(write: write), stdin_payload: write.content) { + ElevatedLegRefused { reason: r } => HostFileUnobservable { reason: r } + ElevatedLegRan { exit_code: cc, stdout: _, stderr: ce } => + runner_host_file_standing_of_report( + report: classify_typed_remote_file_comparison(write: write, compare_exit_code: cc, compare_stderr: ce), + ) + } + } + } +} + +// REMOVE, THEN READ THE ABSENCE BACK FROM THE PARENT LISTING. Every value the legs use derives from +// the seal, including the residue predicate's key, so a mismatched name cannot read a listing that +// CONTAINS the residue as absence. +fn clear_staging( + authorization: StagingCleanupAuthorization, + context: FleetSshExecutionContext, +) -> StagingResidue + uses net: Network +{ + let subject = authorization.subject + let staging = authorization.staging + let staging_parent = authorization.parent + let staging_name = path_basename(path: staging) + match run_elevated_leg(subject: subject, context: context, argv: staging_remove_argv(staging: staging), stdin_payload: "") { + ElevatedLegRefused { reason: r } => StagingUnobservable { reason: concat("staging removal not run: ", r) } + ElevatedLegRan { exit_code: rc, stdout: _, stderr: re } => + if rc != 0 { + StagingUnobservable { reason: join(["staging removal exit=", to_string(rc), " ", trim(s: re)], "") } + } else { + match run_elevated_leg(subject: subject, context: context, argv: parent_listing_argv(parent: staging_parent), stdin_payload: "") { + ElevatedLegRefused { reason: r } => StagingUnobservable { reason: concat("staging parent not listed: ", r) } + ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => + if c != 0 { + StagingUnobservable { reason: join(["staging parent listing exit=", to_string(c), " ", trim(s: e)], "") } + } else { + classify_staging_residue(path: staging, name: staging_name, stdout: o) + } + } + } + } +} + +// ── The converge for one host ──────────────────────────────────────────────────────────────────── + +type CustodyRun { + host: HostIdentity + row: HostCredentialCustodyRow + sm_version: SmResolvedVersionIdentity + action: CustodyAction + write_refusal: String? + readback: CustodyReadback +} + +fn ensure_custody_dir(subject: CustodyHost, context: FleetSshExecutionContext, row: HostCredentialCustodyRow, dir: String) -> CustodyPathMetadata + uses net: Network +{ + match observe_custody_path(subject: subject, context: context, path: dir, legs: custody_dir_legs(row: row)) { + CustodyPathAsDeclared => CustodyPathAsDeclared + CustodyPathUnobservable { reason: r } => CustodyPathUnobservable { reason: r } + _ => + match run_elevated_leg(subject: subject, context: context, argv: custody_dir_ensure_argv(row: row, dir: dir), stdin_payload: "") { + ElevatedLegRefused { reason: r } => CustodyPathUnobservable { reason: concat("directory ensure not run: ", r) } + ElevatedLegRan { exit_code: c, stdout: _, stderr: e } => + if c != 0 { + CustodyPathUnobservable { reason: join(["directory ensure exit=", to_string(c), " ", trim(s: e)], "") } + } else { + observe_custody_path(subject: subject, context: context, path: dir, legs: custody_dir_legs(row: row)) + } + } + } +} + +fn write_and_own(subject: CustodyHost, context: FleetSshExecutionContext, row: HostCredentialCustodyRow, write: TypedRemoteFileWrite) -> String? + uses net: Network +{ + match converge_typed_remote_file( + target: subject.target, + context: context, + admission: TypedRemoteFileWriteAdmitted { write: write }, + mode: custody_file_remote_mode(m: row.file_mode), + privilege: RemoteFileViaNonInteractiveSudo, + ) { + TypedRemoteFileConvergenceRefused { step: st, reason: why } => Present { value: join(["step=", st, " ", why], "") } + TypedRemoteFileConverged { converged: _ } => + match run_elevated_leg(subject: subject, context: context, argv: custody_file_chown_argv(row: row), stdin_payload: "") { + ElevatedLegRefused { reason: r } => Present { value: concat("step=owner not run: ", r) } + ElevatedLegRan { exit_code: c, stdout: _, stderr: e } => + if c != 0 { Present { value: join(["step=owner exit=", to_string(c), " ", trim(s: e)], "") } } else { none } + } + } +} + +// THE ANCESTORS ARE OBSERVED BEFORE ANY MUTATION (gunbc#11902): ensure_custody_dir creates, so it +// runs only once the ancestors read root-only; otherwise the directory is only observed and the same +// pure custody_action decides. +fn converge_host_credential_custody( + subject: CustodyHost, + context: FleetSshExecutionContext, + placement_row: HostCredentialCustodyRow, + key_dir: NonEmptyStr, + upper_ancestors: List, + write: TypedRemoteFileWrite, + sm_version: SmResolvedVersionIdentity, +) -> CustodyRun + uses net: Network +{ + let row = placement_row + let staging = typed_remote_file_staging_path(write: write) + let ancestors_before = observe_upper_ancestors(subject: subject, context: context, paths: upper_ancestors) + let dir_before = match ancestors_before { + PathsRootOnly => ensure_custody_dir(subject: subject, context: context, row: row, dir: key_dir as String) + _ => observe_custody_path(subject: subject, context: context, path: key_dir as String, legs: custody_dir_legs(row: row)) + } + let content_before = observe_custody_content(subject: subject, context: context, write: write) + let file_before = observe_custody_path(subject: subject, context: context, path: row.path as String, legs: custody_file_legs(row: row)) + let action = custody_action(dir: dir_before, upper_ancestors: ancestors_before, content: content_before, file: file_before) + let write_refusal = match action { + CustodyWrite => write_and_own(subject: subject, context: context, row: row, write: write) + _ => none + } + let staging_after = match admit_staging_cleanup( + subject: subject, + upper_ancestors: ancestors_before, + dir: dir_before, + key_dir: key_dir as String, + staging: staging, + ) { + StagingCleanupAuthorized { authorization: z } => clear_staging(authorization: z, context: context) + StagingCleanupWithheld { reason: r } => StagingUnobservable { reason: join([staging_cleanup_withheld_reason, ": ", r], "") } + } + CustodyRun { + host: subject.host, + row: row, + sm_version: sm_version, + action: action, + write_refusal: write_refusal, + readback: CustodyReadback { + file: observe_custody_path(subject: subject, context: context, path: row.path as String, legs: custody_file_legs(row: row)), + dir: observe_custody_path(subject: subject, context: context, path: key_dir as String, legs: custody_dir_legs(row: row)), + upper_ancestors: observe_upper_ancestors(subject: subject, context: context, paths: upper_ancestors), + content: observe_custody_content(subject: subject, context: context, write: write), + staging: staging_after, + }, + } +} + +fn custody_run_settled(run: CustodyRun) -> Bool { + let refused_before = match run.action { + CustodyActionRefused { reason: _ } => true + CustodyLeaveInPlace => false + CustodyWrite => false + } + let write_failed = match run.write_refusal { + Present { value: _ } => true + Absent => false + } + let held = match host_credential_custody(row: run.row, readback: run.readback) { + CustodyHeld => true + CustodyNotHeld { causes: _ } => false + } + !refused_before && !write_failed && held +} + +// NEVER THE BYTES. Every field below is metadata or a judgment; the content is named only by the +// Secret Manager version it was compared against. The last line restates the caveat on every +// receipt, because a receipt is what gets cited. +fn custody_receipt(run: CustodyRun) -> String { + join([ + "HostCredentialCustodyReceipt credential=", host_custody_credential_wire(c: run.row.credential), + " host=", run.host as String, + " path=", run.row.path as String, + " sm_version=", run.sm_version as String, + "\n action=", custody_action_wire(a: run.action), + match run.write_refusal { + Present { value: w } => concat(" write=REFUSED ", w) + Absent => "" + }, + "\n file=", custody_path_metadata_wire(m: run.readback.file, legs: custody_file_legs(row: run.row)), + "\n dir=", custody_path_metadata_wire(m: run.readback.dir, legs: custody_dir_legs(row: run.row)), + "\n ancestors=", runner_host_path_metadata_wire(m: run.readback.upper_ancestors), + "\n content=", content_standing_wire(s: run.readback.content), + "\n staging=", staging_residue_wire(s: run.readback.staging), + "\n custody=", match host_credential_custody(row: run.row, readback: run.readback) { + CustodyHeld => "HELD" + CustodyNotHeld { causes: cs } => concat("NOT-HELD ", join(cs, "; ")) + }, + "\n scope=permission-bits readback only; NOT an observation that a non-declared principal is refused open", + "\n", + ], "") +} + +// ── CI entry ───────────────────────────────────────────────────────────────────────────────────── + +// The dispatch input the fleet-converge workflow binds to this variable is a CHOICE over +// host_custody_credential_options, so the word that arrives here is a roster wire name or the run +// was never admitted by GitHub; select_host_custody_credential still refuses anything else. +data host_credential_custody_env_name: String = "FLEET_CONVERGE_CUSTODY_CREDENTIAL" + +data host_credential_custody_attempt_raw: String = "host-credential-custody-converge-ci" + +data host_credential_custody_receipt_path: String = "target/host-credential-custody-converge-receipt.txt" + +fn write_receipt_then_exit(body: String, ok: Bool) -> ProcessExit { + let w = Filesystem.Write(path: host_credential_custody_receipt_path, content: body) + if w.success == false { + exit_failure(reason: join(["host_credential_custody_converge: receipt write failed: ", w.error, "\n", body], "")) + } else if ok { + ExitSuccess + } else { + exit_failure(reason: body) + } +} + +type CustodyHostAdmission + = CustodyHostAdmitted + | CustodyHostRefused { cause: String } + +fn custody_host_admission(row: HostCredentialCustodyRow, host: HostIdentity) -> CustodyHostAdmission { + match row.host_scope { + CustodyAnyRunnerHost => CustodyHostAdmitted + CustodyOnlyOnHost { host: only } => + if (only as String) == (host as String) { + CustodyHostAdmitted + } else { + CustodyHostRefused { + cause: join(["host_credential_custody_converge: ", host_custody_credential_wire(c: row.credential), " is delivered only to ", only as String, ", not ", host as String], ""), + } + } + } +} + +fn custody_fetch_refusal(row: HostCredentialCustodyRow, detail: String) -> ProcessExit { + write_receipt_then_exit(body: join(["host_credential_custody_converge: ", host_custody_credential_wire(c: row.credential), " ", secret_ref_version_resource(ref: row.secret), " ", detail, "\n"], ""), ok: false) +} + +// ONE HOST AND ONE CREDENTIAL PER RUN, BOTH NAMED BY THE DISPATCH. The subject (the administrator +// target, refusing a host with no runner host spec or whose administrator is the job user) is +// custody_host above. Row admission -- the credential +// selection, the host scope and the placement, including the group-traversal refusal -- runs +// before any Secret Manager read. The receipt is written on the failing path too. +fn host_credential_custody_converge_ci_wet() -> ProcessExit + uses net: Network +{ + let stamp = concat("probed_at=", probed_at_word(reading: clock_now_probed_at()) as String) + match actions_variable_read(name: host_credential_custody_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + write_receipt_then_exit(body: join(["host_credential_custody_converge: ", v, " ", d, "; one rostered credential per run, named by the dispatch\n"], ""), ok: false) + ActionsVariablePresent { value: raw_credential } => + match select_host_custody_credential(supplied: raw_credential) { + HostCustodyCredentialUnrostered { supplied: s } => + write_receipt_then_exit(body: join(["host_credential_custody_converge: '", s, "' is not a rostered custody credential (", join(host_custody_credential_options, ", "), ")\n"], ""), ok: false) + HostCustodyCredentialSelected { credential: credential } => { + let row = host_credential_custody_row(c: credential) + match actions_variable_read(name: fleet_converge_expected_host_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + write_receipt_then_exit(body: join(["host_credential_custody_converge: ", v, " ", d, "; one host per run, named by the dispatch\n"], ""), ok: false) + ActionsVariablePresent { value: raw_host } => + match custody_host_admission(row: row, host: raw_host as HostIdentity) { + CustodyHostRefused { cause: c } => write_receipt_then_exit(body: concat(c, "\n"), ok: false) + CustodyHostAdmitted => + match custody_host(host: raw_host as HostIdentity) { + CustodyHostSubjectRefused { cause: c } => write_receipt_then_exit(body: concat(c, "\n"), ok: false) + CustodyHostSubjectAdmitted { subject: subject } => + match custody_row_placement(row: row) { + CustodyPlacementRefused { cause: c } => write_receipt_then_exit(body: concat(custody_placement_refusal_wire(r: c), "\n"), ok: false) + CustodyPlaced { row: placed, key_dir: kd, upper_ancestors: ua } => + match fetch_secret_ref_credential(secret_ref: placed.secret, token_source: WorkloadIdentityToken) { + SecretCredentialAccessUpsertRequired { plan: _ } => + custody_fetch_refusal(row: placed, detail: "needs an accessor grant (gunbc.auth.fleet_secret_accessor_roster), which a CI run cannot apply") + SecretCredentialFetchRefused { reason: r } => + custody_fetch_refusal(row: placed, detail: concat("token refused: ", r as String)) + SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => + custody_fetch_refusal(row: placed, detail: "AccessVersion response did not decode") + SecretCredentialResolvedVersionMismatch { requested: rq, resolved: rs } => + custody_fetch_refusal(row: placed, detail: join(["requested ", rq, " but Secret Manager answered about ", rs], "")) + SecretCredentialReady { credential: material, resolved_version: version } => + match typed_remote_file_write_seal( + path: placed.path as FilePath, + content: material as String, + allowed_scope: FileTreeScope { root_path: kd }, + ) { + TypedRemoteFileWriteAdmissionRefused { cause: c } => write_receipt_then_exit(body: concat(c as String, "\n"), ok: false) + TypedRemoteFileWriteAdmitted { write: w } => + match prepare_fleet_ssh_agent_context(attempt_raw: host_credential_custody_attempt_raw) { + FleetSshContextRefused { cause: c } => write_receipt_then_exit(body: concat(c, "\n"), ok: false) + FleetSshContextReady { context: context, receipt: context_receipt } => { + let run = converge_host_credential_custody( + subject: subject, context: context, placement_row: placed, key_dir: kd, + upper_ancestors: ua, write: w, sm_version: version, + ) + write_receipt_then_exit( + body: join([stamp, "\n", context_receipt, custody_receipt(run: run)], ""), + ok: custody_run_settled(run: run), + ) + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/dag/gunbc/host/host_effect_realize.dag b/dag/gunbc/host/host_effect_realize.dag index fa59252e899..5993a918f96 100644 --- a/dag/gunbc/host/host_effect_realize.dag +++ b/dag/gunbc/host/host_effect_realize.dag @@ -69,7 +69,7 @@ import extdeps.shell { posix_command_v_check_argv, shell_test_is_executable_argv import extdeps.shell.exec import extdeps.tools.id { id_uid_argv, id_gid_argv, id_binary_path } import extdeps.tools.mkdir { mkdir_parents_argv } -import extdeps.tools.chown { chown_argv } +import extdeps.tools.chown { chown_argv, ChownOwnerIds } import extdeps.tools.chmod { chmod_add_executable_argv } import extdeps.tools.stat { stat_owner_user_argv, stat_owner_group_argv, stat_mode_argv } import extdeps.posix.path_ownership { @@ -2023,7 +2023,7 @@ fn srv3_chown_then_read_back( let attempt = srv3_chown_attempt( outcome: srv3_elevated_witness_bin_success( transport: transport, - command: chown_argv(owner: desired, path: path), + command: chown_argv(owner: ChownOwnerIds { spec: desired }, path: path), ), ) srv3_readback_after_attempt( diff --git a/dag/gunbc/host_convergence_census.dag b/dag/gunbc/host_convergence_census.dag index fac732eb345..b2f710e67ee 100644 --- a/dag/gunbc/host_convergence_census.dag +++ b/dag/gunbc/host_convergence_census.dag @@ -60,7 +60,7 @@ type HostConvergenceSubject | SparkRuntimeImage | RunnerHostFiles | RunnerPasswordSessionTools - | MicrovmControllerAppKey + | HostCredentialCustody | MicrovmHost | GuestImage | Srv3InstallMedia @@ -683,10 +683,10 @@ data host_convergence_census_rows: List = [ first_consumer: cite(module_path: "gunbc.runner_password_session_tool_converge", decl_name: "runner_password_session_tool_converge_ci_wet"), ), census_row( - identity: cite(module_path: "gunbc.microvm_controller_app_key_converge", decl_name: "microvm_controller_app_key_converge_ci_wet"), - subject: MicrovmControllerAppKey, + identity: cite(module_path: "gunbc.host_credential_custody_converge", decl_name: "host_credential_custody_converge_ci_wet"), + subject: HostCredentialCustody, phases: protocol_phases(false, true, true, true, true, true, false), - current_authority: cite(module_path: "gunbc.microvm_controller_app_key_converge", decl_name: "microvm_controller_app_key_converge_ci_wet"), + current_authority: cite(module_path: "gunbc.host_credential_custody_converge", decl_name: "host_credential_custody_converge_ci_wet"), persistent_host_effect: ReachesPersistentHostMutation, disposition: BindToAuthority { target: fleet_plan() }, first_consumer: cite(module_path: "gunbc.fleet_converge_workflow", decl_name: "fleet_converge_workflow_modes"), diff --git a/dag/gunbc/runner/microvm_controller_app_key_converge.dag b/dag/gunbc/runner/microvm_controller_app_key_converge.dag deleted file mode 100644 index 8a2ede2c533..00000000000 --- a/dag/gunbc/runner/microvm_controller_app_key_converge.dag +++ /dev/null @@ -1,989 +0,0 @@ -module gunbc.microvm_controller_app_key_converge - -import std.types { String, NonEmptyStr, List, Bool, Int, FilePath, Secret } -import std.algebra { trim } -import std.resources { Network } -import std.process { ProcessExit, ExitSuccess, exit_failure } -import extdeps.filesystem.filesystem_io { Filesystem } -import extdeps.cloud.gcp.secret_ref { secret_ref_version_resource } -import extdeps.cloud.gcp.secret_manager { SmResolvedVersionIdentity } -import product.placement_supply { HostIdentity } -import gunbc.clock_read { clock_now_probed_at, probed_at_word } -import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name } -import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } -import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext, SshTarget } -import gunbc.file_access { FileTreeScope, path_within_file_tree_scope } -import gunbc.auth.github_apps { gunbai_ci_app_pem_secret } -import gunbc.auth.access_token_source { WorkloadIdentityToken } -import gunbc.auth.secret_ref_credential { - fetch_secret_ref_credential, - SecretCredentialReady, - SecretCredentialAccessUpsertRequired, - SecretCredentialWireDecodeRefused, - SecretCredentialFetchRefused, - SecretCredentialResolvedVersionMismatch, -} -import gunbc.github_effect_perform { lifecycle_controller_app_key } -import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile } -import gunbc.fabric_cell_converge { fabric_cell_base_dir } -import gunbc.typed_remote_file_write { - RemoteFileModeOwnerRead, - RemoteFileViaNonInteractiveSudo, - TypedRemoteFileWrite, - TypedRemoteFileWriteAdmitted, - TypedRemoteFileWriteAdmissionRefused, - TypedRemoteFileConverged, - TypedRemoteFileConvergenceRefused, - TypedRemoteFileStepRan, - TypedRemoteFileStepArgvRefused, - TypedRemoteFileStepLegRefused, - typed_remote_file_write_seal, - typed_remote_file_staging_path, - typed_remote_file_compare_argv, - typed_remote_file_step, - classify_typed_remote_file_comparison, - remote_file_privileged_argv, - converge_typed_remote_file, -} -import gunbc.runner_host_deploy { runner_host_spec_for, RunnerHostSpecFound, RunnerHostSpecAbsent } -import gunbc.runner_host_grants { bootstrap_principal_is_not_the_job_user } -import gunbc.fleet_bootstrap_principal { executor_bootstrap_principal } -import gunbc.live_deploy.member_observe { - path_parent, path_basename, parent_listing_argv, listing_contains_entry, - EntryPresence, EntryPresent, EntryAbsent, EntryPresenceIndeterminate, -} -import gunbc.runner_host_file_converge { - administrator_ssh_target, - RunnerHostFileStanding, - HostFileIdentical, - HostFileDiffers, - HostFileAbsent, - HostFileUnobservable, - RunnerHostFileMetadataReading, - HostFileMetadataRead, - HostFileMetadataUnread, - RunnerHostFilePathStanding, - HostFilePathPresent, - classify_host_file_metadata, - classify_host_file_path, - runner_host_file_settled_standing, - runner_host_file_metadata_probe_argv, - runner_host_file_standing_of_report, - RunnerHostPathMetadata, - PathsRootOnly, - PathsNotRootOnly, - PathsUnobservable, - PathsNotApplicable, - runner_host_path_ancestors, - runner_host_file_scope, - runner_host_root_only_find_argv, - classify_root_only_paths, - runner_host_path_metadata_wire, -} - -// THE MICROVM LIFECYCLE CONTROLLER'S COPY OF THE gunbai-ci APP KEY, DELIVERED AND READ BACK. -// -// gunbc.github_effect_perform perform_organization_jit_mint signs its App JWT with the file at -// lifecycle_controller_app_key and refuses at the signer when that file is absent; this module -// is the converge entry that note names. It moves the key from Secret Manager (gunbc.auth.github_apps -// gunbai_ci_app_pem_secret) to that path on ONE runner host per dispatch, as root:root 0400 in a -// root:root directory with no group or other bits at all, and it answers with a readback receipt that -// names the owner, the mode, the Secret Manager version the content was compared against, and the -// absence of the staging sibling -- never the bytes. -// -// THE CUSTODY THIS NARROWS. The shared-slot path still reads gunbc.runner_unit_file -// runner_host_app_pem_path (/etc/actions-runner/app.pem), which is owned by the RUNNER USER -- the -// principal whose processes run CI jobs. So today every job on a shared slot runs as an account that -// can read the key that mints registrations for the whole org. That file is deliberately NOT touched -// here: the shared-slot path is X in a replacement migration (DESIGN §3), still load-bearing, and -// changing its owner would break it before Y is selected. What this module establishes is the Y-side -// custody: a copy readable by root alone, outside every jail and attempt root, so that when the floor -// job moves to the microVM path the runner-owned copy has no remaining reader and can be deleted with -// X. Nothing in this change selects the microVM path. -// -// WHY ROOT-ONLY IS ENOUGH TO SAY "NOT READABLE BY THE RUNNER USER OR ATTEMPT UID 1000". Both are -// non-root principals. A regular file owned root:root with mode 0400, inside a directory owned -// root:root with no group/other bits, cannot be opened by any non-root uid: the directory denies the -// traversal and the file denies the read, independently. Both facts are READ BACK below rather than -// inferred from the commands that set them. The placement admission additionally refuses a key path -// inside the fabric-cell tree (gunbc.fabric_cell_converge fabric_cell_base_dir), which roots every -// attempt directory and therefore every jail (gunbc.runner_microvm_attempt runner_microvm_attempt_dir), -// so a future edit to the path cannot move the key into a guest-visible tree without refusing here. -// -// THE FETCH IS TYPED, NOT A PRELUDE. gunbc.ci_spec gunbc_ci_app_jwt_prelude fetches the same secret -// with curl and python in shell, because a GitHub Actions step has to produce a header file before any -// .dag process runs. This entry is a .dag process, so it reads through -// gunbc.auth.secret_ref_credential fetch_secret_ref_credential over the run's workload-identity token, -// which refuses a response about a different version than the one requested. The plaintext therefore -// never touches the near-side disk; it exists in this process and on the far side's stdin. -// -// DIGEST, STATED AS WHAT IS ACTUALLY CHECKED. The brief asked for a digest match against the Secret -// Manager version. .dag has no SHA-256 over a String (gunbc.typed_remote_file_write states the same -// limit), so a near-side digest of the intended bytes cannot be computed. The comparison made instead -// is STRONGER and is the one converge_typed_remote_file already owns: `cmp -s -` far-side with the -// fetched version's exact bytes on stdin. Byte equality implies digest equality; the converse would -// admit a collision. The receipt names the resolved version identity the bytes came from. -// -// PLAINTEXT STAGING. The only staging is the far-side sibling .gunbc-staging that -// converge_typed_remote_file writes, modes and renames. It lives inside the root-only directory, -// which is verified BEFORE the stage leg runs, so even the moment between `dd` and `chmod 400` is not -// observable by a non-root uid. It is removed after every attempt WHOSE ANCESTOR AND -// PARENT-DIRECTORY AUTHORITY HAS BEEN ESTABLISHED -- whether or not a write was attempted, but never -// beneath a path the route declined to trust, because `rm` is a root mutation through that same path -// (see StagingCleanupAuthorization). Its absence is READ BACK; a failed removal, a surviving sibling, -// or a withheld cleanup refuses the run (the cleanup-is-part-of-the-mint rule of -// gunbc.secret_provision_actuator). - -// ── Placement (pure) ───────────────────────────────────────────────────────────────────────────── - -// THE SUBJECT IS THE SIGNER'S OWN KEY REFERENCE, read through gunbc.github_effect_perform -// lifecycle_controller_app_key rather than re-spelling its path: the file this delivers is exactly -// the file perform_organization_jit_mint signs with, and HostKeyFile is the only arm that names one -// on this host -- a later arm (an agent, a KMS handle) would have no path for a converge to write, -// so it must refuse here rather than be given a fabricated one. -fn signing_key_file_path(key: JwsSigningKeyRef) -> NonEmptyStr { - match key { - HostKeyFile { path: p } => p - } -} - -data controller_app_key_forbidden_tree: FileTreeScope = FileTreeScope { root_path: fabric_cell_base_dir as NonEmptyStr } - -type ControllerAppKeyPlacement - = ControllerAppKeyPlaced { key_path: NonEmptyStr, key_dir: NonEmptyStr, upper_ancestors: List } - | ControllerAppKeyPlacementRefused { cause: NonEmptyStr } - -fn last_or_empty(xs: List) -> String { - fold(xs, init: "", f: fn(_, x) { x }) -} - -// The directory and its ancestors are derived from the one path authority, never re-spelled. -fn controller_app_key_placement(key_path: NonEmptyStr) -> ControllerAppKeyPlacement { - let ancestors = runner_host_path_ancestors(path: key_path as String) - let dir = last_or_empty(xs: ancestors) - if path_within_file_tree_scope(scope: controller_app_key_forbidden_tree, path: key_path) { - ControllerAppKeyPlacementRefused { - cause: join(["controller app key path ", key_path as String, " is inside the fabric-cell tree ", fabric_cell_base_dir, ", which roots every attempt and jail"], "") as NonEmptyStr, - } - } else if !path_within_file_tree_scope(scope: runner_host_file_scope, path: key_path) { - ControllerAppKeyPlacementRefused { - cause: join(["controller app key path ", key_path as String, " is outside ", runner_host_file_scope.root_path as String], "") as NonEmptyStr, - } - } else if dir == "" || dir == (runner_host_file_scope.root_path as String) { - ControllerAppKeyPlacementRefused { - cause: join(["controller app key path ", key_path as String, " has no dedicated directory below ", runner_host_file_scope.root_path as String, " to hold root-only"], "") as NonEmptyStr, - } - } else { - ControllerAppKeyPlaced { - key_path: key_path, - key_dir: dir as NonEmptyStr, - upper_ancestors: filter(ancestors, a => a != dir), - } - } -} - -// ── Readback vocabulary (pure) ─────────────────────────────────────────────────────────────────── - -// THREE FAR-SIDE QUESTIONS, ONE PER CLAUSE, so a refusal says WHICH clause failed. A single find with -// every clause conjoined answers only "not printed", which cannot tell a wrong owner from a -// group-readable mode, and those have different causes. `%` is outside the portable-word allowlist -// (gunbc.fleet_known_hosts_anchor portable_remote_word_char_allowed), so a stat format string cannot -// cross the SSH edge; find predicates can, and each prints the path exactly when its clause holds. -type KeyPathFindReading { - root_owned: Bool - group_or_other_bits: Bool - exactly_owner_read: Bool -} - -type KeyFileMetadata - = KeyFileRootOwnerRead - | KeyFileWrongOwner - | KeyFileGroupOrOtherAccess - | KeyFileModeNotOwnerReadOnly - | KeyFileAbsent - | KeyFileMetadataUnobservable { reason: String } - -type KeyDirMetadata - = KeyDirRootOnly - | KeyDirWrongOwner - | KeyDirGroupOrOtherAccess - | KeyDirAbsent - | KeyDirMetadataUnobservable { reason: String } - -// The order is owner first: a runner-owned file is the most dangerous state regardless of its mode, -// and reporting it as a mode defect would send the reader to chmod a file they do not own. -fn classify_key_file_metadata(reading: KeyPathFindReading) -> KeyFileMetadata { - if !reading.root_owned { - KeyFileWrongOwner - } else if reading.group_or_other_bits { - KeyFileGroupOrOtherAccess - } else if !reading.exactly_owner_read { - KeyFileModeNotOwnerReadOnly - } else { - KeyFileRootOwnerRead - } -} - -fn classify_key_dir_metadata(reading: KeyPathFindReading) -> KeyDirMetadata { - if !reading.root_owned { - KeyDirWrongOwner - } else if reading.group_or_other_bits { - KeyDirGroupOrOtherAccess - } else { - KeyDirRootOnly - } -} - -fn key_file_metadata_wire(m: KeyFileMetadata) -> String { - match m { - KeyFileRootOwnerRead => "owner=root:root mode=0400" - KeyFileWrongOwner => "WRONG-OWNER (not root:root)" - KeyFileGroupOrOtherAccess => "GROUP-OR-OTHER-ACCESS (mode has bits in 077)" - KeyFileModeNotOwnerReadOnly => "MODE-NOT-0400" - KeyFileAbsent => "ABSENT" - KeyFileMetadataUnobservable { reason: r } => concat("UNOBSERVABLE ", r) - } -} - -fn key_dir_metadata_wire(m: KeyDirMetadata) -> String { - match m { - KeyDirRootOnly => "owner=root:root no-group-other-bits" - KeyDirWrongOwner => "WRONG-OWNER (not root:root)" - KeyDirGroupOrOtherAccess => "GROUP-OR-OTHER-ACCESS (mode has bits in 077)" - KeyDirAbsent => "ABSENT" - KeyDirMetadataUnobservable { reason: r } => concat("UNOBSERVABLE ", r) - } -} - -// THE STAGING SIBLING'S ABSENCE IS ESTABLISHED BY A SUCCESSFUL LISTING OF ITS PARENT, never by a -// failed read of the child. This was `test -e`, whose exit 1 was read here as StagingAbsent -- and -// GNU `test -e` is stat(path) == 0, so ENOENT, EACCES on an ancestor, ELOOP, ENOTDIR and EIO all -// answer 1 with an empty stderr. "The staging file is gone" and "I could not look" arrived as one -// value, and this one feeds ControllerAppKeyHeld: a metadata failure would have been reported as -// custody held with no residue. A successful preceding `rm -f` is not proof of the later -// observation, because the two are different reads of a host that can change between them. -// -// THE RULE IS gunbc.live_deploy.member_observe's, APPLIED OVER AN ELEVATED LEG, and the departure -// is stated rather than silent (DESIGN §3b). entry_presence is the landed authority for -// establishing absence from a parent listing over a transport, but it reaches the far side as the -// bootstrap principal, and this parent is the key directory: root:root 0700. An unelevated listing -// of it can only ever refuse, so entry_presence would answer Indeterminate for every host whether -// or not the residue exists -- fail-closed and empty of information. So the listing runs through -// this module's own elevated leg and the membership question is the same one. -type StagingResidue - = StagingAbsent - | StagingResiduePresent { path: String } - | StagingUnobservable { reason: String } - -// CLEANUP IS A ROOT MUTATION, so it is entitled to the same path authority the WRITE is. The -// earlier revision ran `rm -f --` beneath the very ancestors controller_app_key_action had just -// declined to trust: the non-write arms of that match produced `none` and then fell through to an -// unconditional clear_staging. A group- or other-writable upper ancestor made this representable -- -// PathsNotRootOnly, directory only observed, action refused, no key content written, and an -// ELEVATED rm still executing through a path component a non-root principal can replace with a -// directory or a symlink between the observation and the leg. That is not residue; it is a root -// mutation through the exact containment the route declined to trust. -// -// The gate is PATH AUTHORITY and deliberately not the whole action: a content or file-metadata -// observation may be unobservable while the path is safely root-contained, and cleanup is still -// desirable there. So the seal joins three facts and nothing else -- upper ancestors PathsRootOnly, -// the key directory KeyDirRootOnly, and the staging path's parent being that admitted directory. -// sole_constructor makes the bad state UNWRITABLE rather than merely unreached (DESIGN §4b rung 4): -// clear_staging accepts the sealed authorization and DERIVES every value the leg uses from it -- -// subject, parent, path and the residue predicate's key -- so there is no way to spell a cleanup leg, -// or a residue reading over one, that no admission fold produced. -type StagingCleanupAuthorization sole_constructor { - subject: ControllerKeyHost - parent: String - staging: String -} - -type StagingCleanupAdmission - = StagingCleanupAuthorized { authorization: StagingCleanupAuthorization } - | StagingCleanupWithheld { reason: String } - -// THE ONLY MINT. PURE, over readings the caller already has: no leg runs here. -fn admit_staging_cleanup( - subject: ControllerKeyHost, - upper_ancestors: RunnerHostPathMetadata, - dir: KeyDirMetadata, - key_dir: String, - staging: String, -) -> StagingCleanupAdmission { - match upper_ancestors { - PathsRootOnly => - match dir { - KeyDirRootOnly => - if path_parent(path: staging) != key_dir { - StagingCleanupWithheld { reason: join(["staging parent ", path_parent(path: staging), " is not the admitted key directory ", key_dir], "") } - } else { - StagingCleanupAuthorized { - authorization: StagingCleanupAuthorization { subject: subject, parent: key_dir, staging: staging }, - } - } - _ => StagingCleanupWithheld { reason: concat("key directory is not root-only: ", key_dir_metadata_wire(m: dir)) } - } - _ => StagingCleanupWithheld { reason: concat("an ancestor directory is not root-only: ", runner_host_path_metadata_wire(m: upper_ancestors)) } - } -} - -// THE PREFIX, not the whole reason. admit_staging_cleanup has already computed WHICH of the three -// joined facts failed, and the withheld arm carries that cause through rather than flattening it: -// a receipt saying only "authority was not established" cannot tell an operator whether to fix an -// ancestor, the directory, or a staging path pointing outside it (DESIGN 5 -- a typed, LOCATED -// diagnostic). -data staging_cleanup_withheld_reason: String = "staging cleanup withheld because root-only path authority was not established" - -// PURE, over a listing that SUCCEEDED. The caller reaches this only on a successful leg, so every -// arm here is a reading of what the parent contains -- there is no exit code to misread. The argv -// and the membership read are member_observe's (parent_listing_argv, listing_contains_entry): only -// the TRANSPORT differs here, so only the transport is this module's. An earlier revision spelled -// both again and the copies had already drifted -- this one trimmed each line and the original did -// not, which is two sources answering one question differently (DESIGN §2). -fn classify_staging_residue(path: String, name: String, stdout: String) -> StagingResidue { - if listing_contains_entry(stdout: stdout, name: name) { - StagingResiduePresent { path: path } - } else { - StagingAbsent - } -} - -fn staging_residue_wire(s: StagingResidue) -> String { - match s { - StagingAbsent => "absent" - StagingResiduePresent { path: p } => concat("RESIDUE ", p) - StagingUnobservable { reason: r } => concat("UNOBSERVABLE ", r) - } -} - -fn content_standing_wire(s: RunnerHostFileStanding) -> String { - match s { - HostFileIdentical => "identical-to-sm-version" - HostFileDiffers => "DIFFERS-FROM-SM-VERSION" - HostFileAbsent => "ABSENT" - HostFileUnobservable { reason: r } => concat("UNOBSERVABLE ", r) - } -} - -// THE READBACK AND ITS VERDICT. Held requires every component, observed after the last mutation. The -// verdict carries the causes so the receipt and the exit agree on why. -type ControllerAppKeyReadback { - file: KeyFileMetadata - dir: KeyDirMetadata - upper_ancestors: RunnerHostPathMetadata - content: RunnerHostFileStanding - staging: StagingResidue -} - -type ControllerAppKeyCustody - = ControllerAppKeyHeld - | ControllerAppKeyNotHeld { causes: List } - -fn controller_app_key_custody(readback: ControllerAppKeyReadback) -> ControllerAppKeyCustody { - let causes = concat(concat(concat(concat( - match readback.file { - KeyFileRootOwnerRead => [] - _ => [concat("file ", key_file_metadata_wire(m: readback.file))] - }, - match readback.dir { - KeyDirRootOnly => [] - _ => [concat("dir ", key_dir_metadata_wire(m: readback.dir))] - }), - match readback.upper_ancestors { - PathsRootOnly => [] - _ => [concat("ancestors ", runner_host_path_metadata_wire(m: readback.upper_ancestors))] - }), - match readback.content { - HostFileIdentical => [] - _ => [concat("content ", content_standing_wire(s: readback.content))] - }), - match readback.staging { - StagingAbsent => [] - _ => [concat("staging ", staging_residue_wire(s: readback.staging))] - }) - if length(causes) == 0 { ControllerAppKeyHeld } else { ControllerAppKeyNotHeld { causes: causes } } -} - -// ── The decision before any write (pure) ───────────────────────────────────────────────────────── - -type ControllerAppKeyAction - = AppKeyLeaveInPlace - | AppKeyWrite - | AppKeyActionRefused { reason: String } - -// The directory and the ancestors are decided first because a write cannot repair them and writing -// into an unsafe directory would publish the key where a non-root uid can reach it. An unsafe FILE is -// repaired by the write: the staged sibling is created by root, moded 0400, and renamed over it. -fn controller_app_key_action( - dir: KeyDirMetadata, - upper_ancestors: RunnerHostPathMetadata, - content: RunnerHostFileStanding, - file: KeyFileMetadata, -) -> ControllerAppKeyAction { - match upper_ancestors { - PathsRootOnly => - match dir { - KeyDirRootOnly => - match content { - HostFileUnobservable { reason: r } => AppKeyActionRefused { reason: concat("content unobservable: ", r) } - HostFileAbsent => AppKeyWrite - HostFileDiffers => AppKeyWrite - HostFileIdentical => - match file { - KeyFileRootOwnerRead => AppKeyLeaveInPlace - KeyFileMetadataUnobservable { reason: r } => AppKeyActionRefused { reason: concat("file metadata unobservable: ", r) } - _ => AppKeyWrite - } - } - _ => AppKeyActionRefused { reason: concat("key directory is not root-only: ", key_dir_metadata_wire(m: dir)) } - } - _ => AppKeyActionRefused { reason: concat("an ancestor directory is not root-only: ", runner_host_path_metadata_wire(m: upper_ancestors)) } - } -} - -fn controller_app_key_action_wire(a: ControllerAppKeyAction) -> String { - match a { - AppKeyLeaveInPlace => "left-in-place" - AppKeyWrite => "written" - AppKeyActionRefused { reason: r } => concat("REFUSED ", r) - } -} - -// ── Remote legs (argv pure, execution effectful) ───────────────────────────────────────────────── - -fn key_path_find_argv(path: String, kind: String, clause: List) -> List { - concat(["find", path, "-maxdepth", "0", "-type", kind], clause) -} - -data key_path_root_owned_clause: List = ["-user", "root", "-group", "root"] -data key_path_group_or_other_clause: List = ["-perm", "/077"] -data key_path_exactly_owner_read_clause: List = ["-perm", "0400"] - -fn key_dir_ensure_argv(dir: String) -> List { - ["install", "-d", "-o", "root", "-g", "root", "-m", "0700", dir] -} - -fn staging_remove_argv(staging: String) -> List { - ["rm", "-f", "--", staging] -} - -fn elevated(argv: List) -> List { - remote_file_privileged_argv(privilege: RemoteFileViaNonInteractiveSudo, argv: argv) -} - -type ElevatedLeg - = ElevatedLegRan { exit_code: Int, stdout: String, stderr: String } - | ElevatedLegRefused { reason: String } - -// Every leg is elevated: the directory is root-only, so an unprivileged find or test inside it would -// report the permission boundary as an answer. -// THE SUBJECT OF A CUSTODY CONVERGE IS A HOST, NOT A SLOT POPULATION. This resolved through -// gunbc.runner_host_file_converge runner_host_file_subject, which requires an admitted -// RunnerHostDeploy -- a spec plus an admitted WIDTH -- and refuses a host whose width is -// unresolved. srv2 has no deploy row for that reason, so the mode terminated on srv2 while -// describing itself as covering srv1 and srv2. But the App key's location, its administrator -// target and its custody do not depend on how many runner slots a host admits: the key is root's, -// in root's directory, outside every slot and jail root. runner_host_spec_for names srv2's address -// and principals independently of width, so custody is resolved from the SPEC. -// -// THE PRIVILEGE LAW IS NOT DROPPED WITH THE DEPLOY ROW. The writer may not be the account CI jobs -// run as, and that comparison keeps its one authority in gunbc.runner_host_grants -- asked here -// over the spec's job_user rather than re-spelled (DESIGN §3). -// -// WHAT IS DELIBERATELY NOT CARRIED: desired and retired unit populations. This module verifies no -// teardown and names no unit, so a subject carrying empty unit lists would be a value whose fields -// lie about what was established. -type ControllerKeyHost { - host: HostIdentity - target: SshTarget -} - -type ControllerKeyHostAdmission - = ControllerKeyHostAdmitted { subject: ControllerKeyHost } - | ControllerKeyHostRefused { cause: String } - -fn controller_key_host(host: HostIdentity) -> ControllerKeyHostAdmission { - match runner_host_spec_for(host: host) { - RunnerHostSpecAbsent { host: h } => - ControllerKeyHostRefused { - cause: join(["microvm_controller_app_key_converge: ", h as String, " has no runner host spec, so it is not a runner host"], ""), - } - RunnerHostSpecFound { spec: spec } => - if !bootstrap_principal_is_not_the_job_user(bootstrap: executor_bootstrap_principal, job_user: spec.job_user) { - ControllerKeyHostRefused { - cause: join([ - "microvm_controller_app_key_converge: the administrator principal ", executor_bootstrap_principal.login as String, - " is the job user on ", host as String, - ", so the account that places a root-only key would be the one CI jobs run as", - ], ""), - } - } else { - ControllerKeyHostAdmitted { - subject: ControllerKeyHost { host: host, target: administrator_ssh_target(host: host) }, - } - } - } -} - -fn run_elevated_leg( - subject: ControllerKeyHost, - context: FleetSshExecutionContext, - argv: List, - stdin_payload: String, -) -> ElevatedLeg - uses net: Network -{ - match typed_remote_file_step(target: subject.target, context: context, raws: elevated(argv: argv), stdin_payload: stdin_payload) { - TypedRemoteFileStepArgvRefused => ElevatedLegRefused { reason: "argv refused as non-portable under the far-side shell re-parse allowlist" } - TypedRemoteFileStepLegRefused { cause: why } => ElevatedLegRefused { reason: why as String } - TypedRemoteFileStepRan { result: r } => ElevatedLegRan { exit_code: r.exit_code, stdout: r.stdout, stderr: r.stderr } - } -} - -type FindAnswer - = FindPrinted - | FindSilent - | FindUnobservable { reason: String } - -fn classify_find_answer(path: String, exit_code: Int, stdout: String, stderr: String) -> FindAnswer { - if exit_code != 0 { - FindUnobservable { reason: join(["find exit=", to_string(exit_code), " ", trim(s: stderr)], "") } - } else if any(split(s: stdout, delimiter: "\n"), l => trim(s: l) == path) { - FindPrinted - } else { - FindSilent - } -} - -fn find_answer( - subject: ControllerKeyHost, - context: FleetSshExecutionContext, - path: String, - kind: String, - clause: List, -) -> FindAnswer - uses net: Network -{ - match run_elevated_leg(subject: subject, context: context, argv: key_path_find_argv(path: path, kind: kind, clause: clause), stdin_payload: "") { - ElevatedLegRefused { reason: r } => FindUnobservable { reason: r } - ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => classify_find_answer(path: path, exit_code: c, stdout: o, stderr: e) - } -} - -fn find_printed(a: FindAnswer) -> Bool { - match a { - FindPrinted => true - FindSilent => false - FindUnobservable { reason: _ } => false - } -} - -type KeyPathObservation - = KeyPathObserved { reading: KeyPathFindReading } - | KeyPathMissing - | KeyPathUnobservable { reason: String } - -fn observe_key_path( - subject: ControllerKeyHost, - context: FleetSshExecutionContext, - path: String, - kind: String, -) -> KeyPathObservation - uses net: Network -{ - match find_answer(subject: subject, context: context, path: path, kind: kind, clause: []) { - FindUnobservable { reason: r } => - match run_elevated_leg(subject: subject, context: context, argv: ["test", "-e", path], stdin_payload: "") { - ElevatedLegRan { exit_code: c, stdout: _, stderr: _ } => - if c == 1 { KeyPathMissing } else { KeyPathUnobservable { reason: r } } - ElevatedLegRefused { reason: _ } => KeyPathUnobservable { reason: r } - } - FindSilent => KeyPathUnobservable { reason: concat(concat("exists but is not of type ", kind), ", or find printed nothing") } - FindPrinted => { - let owned = find_answer(subject: subject, context: context, path: path, kind: kind, clause: key_path_root_owned_clause) - let open = find_answer(subject: subject, context: context, path: path, kind: kind, clause: key_path_group_or_other_clause) - let exact = find_answer(subject: subject, context: context, path: path, kind: kind, clause: key_path_exactly_owner_read_clause) - match owned { - FindUnobservable { reason: r } => KeyPathUnobservable { reason: r } - _ => match open { - FindUnobservable { reason: r } => KeyPathUnobservable { reason: r } - _ => match exact { - FindUnobservable { reason: r } => KeyPathUnobservable { reason: r } - _ => KeyPathObserved { - reading: KeyPathFindReading { - root_owned: find_printed(a: owned), - group_or_other_bits: find_printed(a: open), - exactly_owner_read: find_printed(a: exact), - }, - } - } - } - } - } - } -} - -fn observe_key_file(subject: ControllerKeyHost, context: FleetSshExecutionContext, path: String) -> KeyFileMetadata - uses net: Network -{ - match observe_key_path(subject: subject, context: context, path: path, kind: "f") { - KeyPathMissing => KeyFileAbsent - KeyPathUnobservable { reason: r } => KeyFileMetadataUnobservable { reason: r } - KeyPathObserved { reading: rd } => classify_key_file_metadata(reading: rd) - } -} - -fn observe_key_dir(subject: ControllerKeyHost, context: FleetSshExecutionContext, path: String) -> KeyDirMetadata - uses net: Network -{ - match observe_key_path(subject: subject, context: context, path: path, kind: "d") { - KeyPathMissing => KeyDirAbsent - KeyPathUnobservable { reason: r } => KeyDirMetadataUnobservable { reason: r } - KeyPathObserved { reading: rd } => classify_key_dir_metadata(reading: rd) - } -} - -fn observe_upper_ancestors(subject: ControllerKeyHost, context: FleetSshExecutionContext, paths: List) -> RunnerHostPathMetadata - uses net: Network -{ - match run_elevated_leg(subject: subject, context: context, argv: runner_host_root_only_find_argv(paths: paths, kind: "d"), stdin_payload: "") { - ElevatedLegRefused { reason: r } => PathsUnobservable { reason: r } - ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => classify_root_only_paths(requested: paths, exit_code: c, stdout: o, stderr: e) - } -} - -// THE PROBE READS METADATA; IT DOES NOT ANSWER PRESENCE. This was `test -e` classified by -// gunbc.runner_host_file_converge classify_host_file_presence, whose exit-1 arm minted -// HostFileAbsent -- the false-absence arm #11845 deleted from that module (GNU `test -e` is -// stat(path) == 0, so EACCES, ELOOP, ENOTDIR and EIO all answer 1 with an empty stderr), leaving -// this consumer calling a name that no longer existed. Restoring the name would restore the -// defect: HostFileAbsent plans a write, so "I could not look" would walk into the write path for -// the one file this module exists to keep root:root 0400. The chain is the sibling's repaired one, -// consumed rather than re-minted (DESIGN §3): stat `%F` read through classify_host_file_metadata; -// when the read failed, the parent listing decides absence through classify_host_file_path; and -// runner_host_file_settled_standing answers `none` only for a path that was READ, which is the one -// case the byte comparison below is entitled to. -// -// THE PARENT LISTING RUNS OVER THIS MODULE'S ELEVATED LEG, and the departure from -// member_observe's entry_presence is the one clear_staging already states: the parent is the key -// directory at root:root 0700, so an unelevated listing can only refuse. The argv and the -// membership read stay member_observe's. -fn observe_key_parent_presence(subject: ControllerKeyHost, context: FleetSshExecutionContext, path: String) -> EntryPresence - uses net: Network -{ - match run_elevated_leg(subject: subject, context: context, argv: parent_listing_argv(parent: path_parent(path: path)), stdin_payload: "") { - ElevatedLegRefused { reason: r } => EntryPresenceIndeterminate { cause: concat("key parent not listed: ", r) } - ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => - if c != 0 { - EntryPresenceIndeterminate { cause: join(["key parent listing exit=", to_string(c), " ", trim(s: e)], "") } - } else if listing_contains_entry(stdout: o, name: path_basename(path: path)) { - EntryPresent - } else { - EntryAbsent - } - } -} - -// THE PARENT IS ENUMERATED ONLY WHEN THE READ FAILED, as in the sibling's observe_runner_host_file_path: -// a read that answered a file type has already decided presence, and a listing beside it buys a -// standing the classifier discards (DESIGN §2). -fn observe_key_path_standing( - subject: ControllerKeyHost, - context: FleetSshExecutionContext, - path: String, - reading: RunnerHostFileMetadataReading, -) -> RunnerHostFilePathStanding - uses net: Network -{ - match reading { - HostFileMetadataRead { file_type: t } => HostFilePathPresent { file_type: t } - HostFileMetadataUnread { cause: _ } => - classify_host_file_path(reading: reading, parent: observe_key_parent_presence(subject: subject, context: context, path: path)) - } -} - -// The same far-side `cmp -s -` judgment converge_typed_remote_file mints from, run elevated with the -// fetched version's bytes on stdin, so observation and apply cannot disagree about "identical". -fn observe_key_content(subject: ControllerKeyHost, context: FleetSshExecutionContext, write: TypedRemoteFileWrite) -> RunnerHostFileStanding - uses net: Network -{ - let path = write.path as String - match run_elevated_leg(subject: subject, context: context, argv: runner_host_file_metadata_probe_argv(path: path), stdin_payload: "") { - ElevatedLegRefused { reason: r } => HostFileUnobservable { reason: r } - ElevatedLegRan { exit_code: pc, stdout: po, stderr: pe } => - match runner_host_file_settled_standing(st: observe_key_path_standing( - subject: subject, - context: context, - path: path, - reading: classify_host_file_metadata(exit_code: pc, stdout: po, stderr: pe), - )) { - Present { value: settled } => settled - Absent => - match run_elevated_leg(subject: subject, context: context, argv: typed_remote_file_compare_argv(write: write), stdin_payload: write.content) { - ElevatedLegRefused { reason: r } => HostFileUnobservable { reason: r } - ElevatedLegRan { exit_code: cc, stdout: _, stderr: ce } => - runner_host_file_standing_of_report( - report: classify_typed_remote_file_comparison(write: write, compare_exit_code: cc, compare_stderr: ce), - ) - } - } - } -} - -// REMOVE, THEN READ THE ABSENCE BACK. `rm -f` exits 0 on an absent path, so its exit alone cannot -// distinguish "removed" from "never there" -- which is fine -- nor "there and still there", which is -// not; the presence test is what answers that. -// THE RESIDUE PREDICATE'S KEY IS DERIVED FROM THE SEAL, never supplied beside it. As a parameter -// it was the one value of the three a caller could still spell freely, and it is exactly what -// classify_staging_residue keys on: a name that does not match authorization.staging reads a -// listing CONTAINING the residue as StagingAbsent, and that absence feeds ControllerAppKeyHeld -- -// the same false-absence class the parent-listing repair above exists to close. Deriving it here -// is what makes the sole_constructor claim true of every input the leg uses rather than of two of -// the three (DESIGN 4b meta-obligation 1; 5, construction over validation). -fn clear_staging( - authorization: StagingCleanupAuthorization, - context: FleetSshExecutionContext, -) -> StagingResidue - uses net: Network -{ - let subject = authorization.subject - let staging = authorization.staging - let staging_parent = authorization.parent - let staging_name = path_basename(path: staging) - match run_elevated_leg(subject: subject, context: context, argv: staging_remove_argv(staging: staging), stdin_payload: "") { - ElevatedLegRefused { reason: r } => StagingUnobservable { reason: concat("staging removal not run: ", r) } - ElevatedLegRan { exit_code: rc, stdout: _, stderr: re } => - if rc != 0 { - StagingUnobservable { reason: join(["staging removal exit=", to_string(rc), " ", trim(s: re)], "") } - } else { - match run_elevated_leg(subject: subject, context: context, argv: parent_listing_argv(parent: staging_parent), stdin_payload: "") { - ElevatedLegRefused { reason: r } => StagingUnobservable { reason: concat("staging parent not listed: ", r) } - ElevatedLegRan { exit_code: c, stdout: o, stderr: e } => - if c != 0 { - StagingUnobservable { reason: join(["staging parent listing exit=", to_string(c), " ", trim(s: e)], "") } - } else { - classify_staging_residue(path: staging, name: staging_name, stdout: o) - } - } - } - } -} - -// ── The converge for one host ──────────────────────────────────────────────────────────────────── - -type ControllerAppKeyRun { - host: HostIdentity - key_path: NonEmptyStr - sm_version: SmResolvedVersionIdentity - action: ControllerAppKeyAction - write_refusal: String? - readback: ControllerAppKeyReadback -} - -fn ensure_key_dir(subject: ControllerKeyHost, context: FleetSshExecutionContext, dir: String) -> KeyDirMetadata - uses net: Network -{ - match observe_key_dir(subject: subject, context: context, path: dir) { - KeyDirRootOnly => KeyDirRootOnly - KeyDirMetadataUnobservable { reason: r } => KeyDirMetadataUnobservable { reason: r } - _ => - match run_elevated_leg(subject: subject, context: context, argv: key_dir_ensure_argv(dir: dir), stdin_payload: "") { - ElevatedLegRefused { reason: r } => KeyDirMetadataUnobservable { reason: concat("directory ensure not run: ", r) } - ElevatedLegRan { exit_code: c, stdout: _, stderr: e } => - if c != 0 { - KeyDirMetadataUnobservable { reason: join(["directory ensure exit=", to_string(c), " ", trim(s: e)], "") } - } else { - observe_key_dir(subject: subject, context: context, path: dir) - } - } - } -} - -// THE ANCESTORS ARE OBSERVED BEFORE ANY MUTATION, and that ordering is the point rather than a -// style. ensure_key_dir CREATES (install -d) and then observes, so calling it first ran a host -// mutation ahead of the observation entitled to refuse the whole converge -- on a host whose /etc -// or /etc/gunbc is group-writable the directory was created and only then was the ancestor read. -// No key was ever written, because controller_app_key_action decides after every observation, so -// the residue was an empty root-owned directory rather than a custody hole. But the module claimed -// observe -> decide -> mutate and did not implement it, and the witnesses over the pure decision -// could not see the difference: that is a comment and an evidence population asserting a property -// the production route did not have. -// -// So the create is now gated on the ancestor reading. On the refusing arm the directory is only -// OBSERVED, which is a read, and the same pure controller_app_key_action still decides -- the -// decision keeps its single authority and only the order of the effects changed. -fn converge_controller_app_key( - subject: ControllerKeyHost, - context: FleetSshExecutionContext, - placed_path: NonEmptyStr, - key_dir: NonEmptyStr, - upper_ancestors: List, - write: TypedRemoteFileWrite, - sm_version: SmResolvedVersionIdentity, -) -> ControllerAppKeyRun - uses net: Network -{ - let staging = typed_remote_file_staging_path(write: write) - let ancestors_before = observe_upper_ancestors(subject: subject, context: context, paths: upper_ancestors) - let dir_before = match ancestors_before { - PathsRootOnly => ensure_key_dir(subject: subject, context: context, dir: key_dir as String) - _ => observe_key_dir(subject: subject, context: context, path: key_dir as String) - } - let content_before = observe_key_content(subject: subject, context: context, write: write) - let file_before = observe_key_file(subject: subject, context: context, path: placed_path as String) - let action = controller_app_key_action(dir: dir_before, upper_ancestors: ancestors_before, content: content_before, file: file_before) - let write_refusal = match action { - AppKeyWrite => - match converge_typed_remote_file( - target: subject.target, - context: context, - admission: TypedRemoteFileWriteAdmitted { write: write }, - mode: RemoteFileModeOwnerRead, - privilege: RemoteFileViaNonInteractiveSudo, - ) { - TypedRemoteFileConverged { converged: _ } => none - TypedRemoteFileConvergenceRefused { step: st, reason: why } => Present { value: join(["step=", st, " ", why], "") } - } - _ => none - } - let cleanup_admission = admit_staging_cleanup( - subject: subject, - upper_ancestors: ancestors_before, - dir: dir_before, - key_dir: key_dir as String, - staging: staging, - ) - let staging_after = match cleanup_admission { - StagingCleanupAuthorized { authorization: z } => - clear_staging(authorization: z, context: context) - StagingCleanupWithheld { reason: r } => StagingUnobservable { reason: join([staging_cleanup_withheld_reason, ": ", r], "") } - } - ControllerAppKeyRun { - host: subject.host, - key_path: placed_path, - sm_version: sm_version, - action: action, - write_refusal: write_refusal, - readback: ControllerAppKeyReadback { - file: observe_key_file(subject: subject, context: context, path: placed_path as String), - dir: observe_key_dir(subject: subject, context: context, path: key_dir as String), - upper_ancestors: observe_upper_ancestors(subject: subject, context: context, paths: upper_ancestors), - content: observe_key_content(subject: subject, context: context, write: write), - staging: staging_after, - }, - } -} - -fn controller_app_key_run_settled(run: ControllerAppKeyRun) -> Bool { - let refused_before = match run.action { - AppKeyActionRefused { reason: _ } => true - AppKeyLeaveInPlace => false - AppKeyWrite => false - } - let write_failed = match run.write_refusal { - Present { value: _ } => true - Absent => false - } - let held = match controller_app_key_custody(readback: run.readback) { - ControllerAppKeyHeld => true - ControllerAppKeyNotHeld { causes: _ } => false - } - !refused_before && !write_failed && held -} - -// NEVER THE BYTES. Every field below is metadata or a judgment; the content is named only by the -// Secret Manager version it was compared against. -fn controller_app_key_receipt(run: ControllerAppKeyRun) -> String { - join([ - "ControllerAppKeyCustodyReceipt host=", run.host as String, - " path=", run.key_path as String, - " sm_version=", run.sm_version as String, - "\n action=", controller_app_key_action_wire(a: run.action), - match run.write_refusal { - Present { value: w } => concat(" write=REFUSED ", w) - Absent => "" - }, - "\n file=", key_file_metadata_wire(m: run.readback.file), - "\n dir=", key_dir_metadata_wire(m: run.readback.dir), - "\n ancestors=", runner_host_path_metadata_wire(m: run.readback.upper_ancestors), - "\n content=", content_standing_wire(s: run.readback.content), - "\n staging=", staging_residue_wire(s: run.readback.staging), - "\n custody=", match controller_app_key_custody(readback: run.readback) { - ControllerAppKeyHeld => "HELD" - ControllerAppKeyNotHeld { causes: cs } => concat("NOT-HELD ", join(cs, "; ")) - }, - "\n", - ], "") -} - -// ── CI entry ───────────────────────────────────────────────────────────────────────────────────── - -data microvm_controller_app_key_converge_attempt_raw: String = "microvm-controller-app-key-converge-ci" - -data microvm_controller_app_key_converge_receipt_path: String = "target/microvm-controller-app-key-converge-receipt.txt" - -fn write_receipt_then_exit(body: String, ok: Bool) -> ProcessExit { - let w = Filesystem.Write(path: microvm_controller_app_key_converge_receipt_path, content: body) - if w.success == false { - exit_failure(reason: join(["microvm_controller_app_key_converge: receipt write failed: ", w.error, "\n", body], "")) - } else if ok { - ExitSuccess - } else { - exit_failure(reason: body) - } -} - -// ONE HOST PER RUN, NAMED BY THE DISPATCH, exactly as gunbc.runner_password_session_tool_converge: -// the fleet-converge job is pinned to a runner on the named host, and the subject -- the -// administrator target, refusing a host with no runner host spec or whose administrator is the job -// user -- is controller_key_host above. The receipt is written on the failing path too. -fn microvm_controller_app_key_converge_ci_wet() -> ProcessExit - uses net: Network -{ - let stamp = concat("probed_at=", probed_at_word(reading: clock_now_probed_at()) as String) - match actions_variable_read(name: fleet_converge_expected_host_env_name) { - ActionsVariableAbsent { variable: v, detail: d } => - write_receipt_then_exit(body: join(["microvm_controller_app_key_converge: ", v, " ", d, "; one host per run, named by the dispatch\n"], ""), ok: false) - ActionsVariablePresent { value: raw_host } => - match controller_key_host(host: raw_host as HostIdentity) { - ControllerKeyHostRefused { cause: c } => write_receipt_then_exit(body: concat(c, "\n"), ok: false) - ControllerKeyHostAdmitted { subject: subject } => - match controller_app_key_placement(key_path: signing_key_file_path(key: lifecycle_controller_app_key)) { - ControllerAppKeyPlacementRefused { cause: c } => write_receipt_then_exit(body: concat(c as String, "\n"), ok: false) - ControllerAppKeyPlaced { key_path: kp, key_dir: kd, upper_ancestors: ua } => - match fetch_secret_ref_credential(secret_ref: gunbai_ci_app_pem_secret, token_source: WorkloadIdentityToken) { - SecretCredentialAccessUpsertRequired { plan: _ } => - write_receipt_then_exit(body: join(["microvm_controller_app_key_converge: ", secret_ref_version_resource(ref: gunbai_ci_app_pem_secret), " needs an interactive access ensure, which a CI run cannot perform\n"], ""), ok: false) - SecretCredentialFetchRefused { reason: r } => - write_receipt_then_exit(body: join(["microvm_controller_app_key_converge: token for ", secret_ref_version_resource(ref: gunbai_ci_app_pem_secret), " refused: ", r as String, "\n"], ""), ok: false) - SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => - write_receipt_then_exit(body: join(["microvm_controller_app_key_converge: the AccessVersion response for ", secret_ref_version_resource(ref: gunbai_ci_app_pem_secret), " did not decode\n"], ""), ok: false) - SecretCredentialResolvedVersionMismatch { requested: rq, resolved: rs } => - write_receipt_then_exit(body: join(["microvm_controller_app_key_converge: requested ", rq, " but Secret Manager answered about ", rs, "\n"], ""), ok: false) - SecretCredentialReady { credential: key, resolved_version: version } => - match typed_remote_file_write_seal( - path: kp as FilePath, - content: key as String, - allowed_scope: FileTreeScope { root_path: kd }, - ) { - TypedRemoteFileWriteAdmissionRefused { cause: c } => write_receipt_then_exit(body: concat(c as String, "\n"), ok: false) - TypedRemoteFileWriteAdmitted { write: w } => - match prepare_fleet_ssh_agent_context(attempt_raw: microvm_controller_app_key_converge_attempt_raw) { - FleetSshContextRefused { cause: c } => write_receipt_then_exit(body: concat(c, "\n"), ok: false) - FleetSshContextReady { context: context, receipt: context_receipt } => { - let run = converge_controller_app_key( - subject: subject, context: context, placed_path: kp, key_dir: kd, - upper_ancestors: ua, write: w, sm_version: version, - ) - write_receipt_then_exit( - body: join([stamp, "\n", context_receipt, controller_app_key_receipt(run: run)], ""), - ok: controller_app_key_run_settled(run: run), - ) - } - } - } - } - } - } - } -} diff --git a/dag/gunbc/runner/runner_host_file_converge.dag b/dag/gunbc/runner/runner_host_file_converge.dag index 93a3e85ea78..4f562c9f280 100644 --- a/dag/gunbc/runner/runner_host_file_converge.dag +++ b/dag/gunbc/runner/runner_host_file_converge.dag @@ -347,7 +347,7 @@ fn runner_host_file_subject(host: HostIdentity) -> RunnerHostFileSubjectAdmissio // constructor refuses on. // WHO WE REACH A RUNNER HOST AS, in one place. The endpoint comes from the fleet reach authority and // the principal is the declared administrator; neither depends on the deploy row, so a converge that -// resolves its subject some other way -- gunbc.microvm_controller_app_key_converge resolves custody +// resolves its subject some other way -- gunbc.host_credential_custody_converge resolves custody // from the host SPEC, because the App key does not depend on slot width -- consumes this rather than // spelling the pair again (DESIGN §3). fn administrator_ssh_target(host: HostIdentity) -> SshTarget { diff --git a/dag/gunbc/typed_remote_file_write.dag b/dag/gunbc/typed_remote_file_write.dag index b80bc24b3de..c8a3271b8e1 100644 --- a/dag/gunbc/typed_remote_file_write.dag +++ b/dag/gunbc/typed_remote_file_write.dag @@ -93,7 +93,7 @@ type TypedRemoteFileWriteOutcome // cmp's exit vocabulary plus the whole route over a hermetic fixture, not a near-side byte compare. // For a unit file the transfer is a few hundred bytes. // OWNER-READ-ONLY IS ITS OWN ARM, not a caller-supplied octal: a signing key read by root alone -// (gunbc.microvm_controller_app_key_converge) has no reason to be writable even by its owner, and an +// (gunbc.host_credential_custody_converge ControllerAppKey) has no reason to be writable even by its owner, and an // open octal parameter would let any consumer spell a group- or world-readable key. type RemoteFileMode = RemoteFileModeOwnerReadWrite diff --git a/dag/test/claim/gcp_secret_access_witness_test.dag b/dag/test/claim/gcp_secret_access_witness_test.dag index 4296c855e69..465accb1dfa 100644 --- a/dag/test/claim/gcp_secret_access_witness_test.dag +++ b/dag/test/claim/gcp_secret_access_witness_test.dag @@ -472,7 +472,7 @@ test fn the_roster_is_the_single_authority_for_the_spark_and_mtcollins1_grants() xs: bmc_cells ) && spark_secret_access_grant().target.secret == spark_accessor_row.target.secret - && count(fleet_accessor_grant_roster) == 4 + && count(fleet_accessor_grant_roster) == 5 } // ── Post-write readback: the verdict is the INDEPENDENT re-read, never the write response ────── diff --git a/dag/test/claim/host_credential_custody_converge_witness_test.dag b/dag/test/claim/host_credential_custody_converge_witness_test.dag new file mode 100644 index 00000000000..dcde5b9bb4d --- /dev/null +++ b/dag/test/claim/host_credential_custody_converge_witness_test.dag @@ -0,0 +1,474 @@ +module test.claim.host_credential_custody_converge_witness + +import std.logic { Bool } +import std.types { String, NonEmptyStr, List, Int } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import product.placement_supply { HostIdentity } +import gunbc.runner_host_file_converge { + HostFileIdentical, + HostFileDiffers, + PathsRootOnly, + PathsNotRootOnly, + runner_host_file_subject, + RunnerHostFileSubjectAdmitted, + RunnerHostFileSubjectRefused, + RunnerHostPathMetadata, +} +import gunbc.fleet_intent_network { operator_host_srv2 } +import extdeps.posix.identity { PosixOwnerNames } +import gunbc.executor_privileged_operation { EnsureOwnedDirectory } +import gunbc.auth.approval_ntfy_deployment { approval_ntfy_config_dir } +import gunbc.auth.approval_ntfy_converge { ntfy_member_operations, NtfyConfigDirectory } +import gunbc.host_credential_custody_converge { + HostCustodyCredential, + ControllerAppKey, + ApprovalNtfyPublisherToken, + host_custody_credential_roster, + host_custody_credential_options, + host_custody_credential_wire, + HostCustodyCredentialSelected, + HostCustodyCredentialUnrostered, + select_host_custody_credential, + HostCredentialCustodyRow, + host_credential_custody_row, + CustodyFileOwnerRead, + CustodyFileOwnerReadWriteGroupRead, + CustodyDirOwnerOnly, + CustodyDirOwnerAllGroupTraverse, + CustodyPlaced, + CustodyPlacementRefused, + CustodyPathInForbiddenTree, + CustodyGroupCannotTraverse, + CustodyDirectoryGroupDiffers, + custody_row_placement, + custody_placement_refusal_wire, + custody_file_legs, + custody_dir_legs, + custody_owner_clause, + custody_perm_clause, + custody_find_argv, + custody_dir_ensure_argv, + custody_file_chown_argv, + CustodyFindReading, + CustodyPathAsDeclared, + CustodyPathWrongOwner, + CustodyPathForbiddenBits, + CustodyPathModeNotDeclared, + CustodyPathAbsent, + classify_custody_path, + StagingResidue, + StagingAbsent, + StagingResiduePresent, + StagingUnobservable, + classify_staging_residue, + CustodyReadback, + CustodyHeld, + CustodyNotHeld, + host_credential_custody, + CustodyLeaveInPlace, + CustodyWrite, + CustodyActionRefused, + custody_action, + CustodyHostAdmitted, + CustodyHostRefused, + custody_host_admission, + CustodyPathUnobservable, + custody_host, + CustodyHostSubjectAdmitted, + CustodyHostSubjectRefused, + admit_staging_cleanup, + StagingCleanupAuthorized, + StagingCleanupWithheld, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE SUBJECT OF EVERY CLAIM HERE IS ONE PURE INTERFACE, with its inputs SUPPLIED (DESIGN §3, a +// witness discriminates at one interface): the far-side find answers and the cmp/test exits are +// constructed rather than produced by a host. The ROWS, however, are the real roster's rows wherever +// a claim is about admission, so a change to a rostered row's fields reddens here. The execution +// route (fleet SSH, sudo, Secret Manager) has no hermetic seam that returns distinct answers per +// leg; its inhabitance is the dispatched fleet-converge mode's receipt, not a claim in this file. + +data controller_row: HostCredentialCustodyRow = host_credential_custody_row(c: ControllerAppKey) + +data ntfy_row: HostCredentialCustodyRow = host_credential_custody_row(c: ApprovalNtfyPublisherToken) + +fn placed(row: HostCredentialCustodyRow) -> Bool { + match custody_row_placement(row: row) { + CustodyPlaced { row: _, key_dir: _, upper_ancestors: _ } => true + CustodyPlacementRefused { cause: _ } => false + } +} + +// ── the closed roster ─────────────────────────────────────────────────────────────────────────── + +// POSITIVE CONTROL for admission: every rostered row places. A row that stops placing would be a +// dispatch option that refuses on every run. +test fn every_rostered_row_places() -> Bool { + all(host_custody_credential_roster, c => placed(row: host_credential_custody_row(c: c))) +} + +// The dispatch choice and the selection are one roster read two ways: every option selects the +// credential whose wire it is, and nothing else selects. +test fn every_dispatch_option_selects_its_own_row() -> Bool { + all(host_custody_credential_roster, c => + match select_host_custody_credential(supplied: host_custody_credential_wire(c: c)) { + HostCustodyCredentialSelected { credential: s } => host_custody_credential_wire(c: s) == host_custody_credential_wire(c: c) + HostCustodyCredentialUnrostered { supplied: _ } => false + }) + && length(host_custody_credential_options) == length(host_custody_credential_roster) +} + +test fn an_unrostered_credential_word_refuses_rather_than_naming_a_secret() -> Bool { + match select_host_custody_credential(supplied: "ci-github-app-private-key") { + HostCustodyCredentialUnrostered { supplied: s } => s == "ci-github-app-private-key" + HostCustodyCredentialSelected { credential: _ } => false + } +} + +// ── the controller key: its former constants are its row's fields ─────────────────────────────── + +test fn the_controller_row_is_root_only_0400_in_a_0700_directory() -> Bool { + let f = custody_file_legs(row: controller_row) + let d = custody_dir_legs(row: controller_row) + f.exact_perm == "0400" && f.forbidden_perm == "/077" + && d.exact_perm == "0700" && d.forbidden_perm == "/077" + && (f.ownership.user as String) == "root" && (f.ownership.group as String) == "root" + && (d.ownership.user as String) == "root" && (d.ownership.group as String) == "root" +} + +test fn the_controller_key_places_in_its_own_directory_under_etc() -> Bool { + match custody_row_placement(row: controller_row) { + CustodyPlaced { row: _, key_dir: d, upper_ancestors: ua } => + (d as String) == "/etc/gunbc/microvm-controller" && ua == ["/etc", "/etc/gunbc"] + CustodyPlacementRefused { cause: _ } => false + } +} + +test fn a_custody_path_under_the_attempt_tree_is_refused() -> Bool { + let row = HostCredentialCustodyRow { + credential: controller_row.credential, secret: controller_row.secret, + path: "/opt/fabric-cells/slot-1/attempts/a1/app.pem" as NonEmptyStr, + host_scope: controller_row.host_scope, + file_ownership: controller_row.file_ownership, file_mode: controller_row.file_mode, + dir_ownership: controller_row.dir_ownership, dir_mode: controller_row.dir_mode, + } + match custody_row_placement(row: row) { + CustodyPlacementRefused { cause: CustodyPathInForbiddenTree { path: _ } } => true + _ => false + } +} + +// ── the ntfy publisher token: a group row, and the directory field that makes it readable ─────── + +test fn the_ntfy_row_is_0640_root_ntfy_in_a_0750_root_ntfy_directory() -> Bool { + let f = custody_file_legs(row: ntfy_row) + let d = custody_dir_legs(row: ntfy_row) + f.exact_perm == "0640" && f.forbidden_perm == "/037" + && d.exact_perm == "0750" && d.forbidden_perm == "/027" + && (f.ownership.user as String) == "root" && (f.ownership.group as String) == "gunbc-ntfy" + && (d.ownership.group as String) == "gunbc-ntfy" +} + +// THE PATH IS THE READ SIDE'S, AND ITS DIRECTORY IS THE SERVER'S CONFIG DIRECTORY. +test fn the_ntfy_token_places_in_the_ntfy_config_directory() -> Bool { + match custody_row_placement(row: ntfy_row) { + CustodyPlaced { row: _, key_dir: d, upper_ancestors: ua } => + (d as String) == (approval_ntfy_config_dir as String) && ua == ["/etc"] + CustodyPlacementRefused { cause: _ } => false + } +} + +// ONE DIRECTORY, TWO CONVERGES: the custody row and gunbc.auth.approval_ntfy_converge +// NtfyConfigDirectory both ensure approval_ntfy_config_dir. If they disagreed on owner, group or +// mode, each run would undo the other's; this holds them equal. +test fn the_ntfy_row_directory_agrees_with_the_ntfy_config_directory_member() -> Bool { + let d = custody_dir_legs(row: ntfy_row) + all(ntfy_member_operations(m: NtfyConfigDirectory { path: approval_ntfy_config_dir }), op => + match op { + EnsureOwnedDirectory { path: p, owner: o, group: g, mode: m } => + (p as String) == (approval_ntfy_config_dir as String) + && (o as String) == (d.ownership.user as String) + && (g as String) == (d.ownership.group as String) + && (m as String) == d.exact_perm + _ => false + }) +} + +// ── THE RED CONTROL: a group-readable file under a directory that denies the group traversal ──── +// +// The real ntfy row with ONE field changed -- its directory arm narrowed to owner-only -- must +// REFUSE at admission, typed and located, rather than place (the predecessor's constant) or widen +// the directory (an absorbing fallback). The positive control is the_ntfy_token_places_... above: +// the same row, unchanged, places. +test fn a_group_readable_token_under_an_untraversable_directory_refuses() -> Bool { + let row = HostCredentialCustodyRow { + credential: ntfy_row.credential, secret: ntfy_row.secret, path: ntfy_row.path, + host_scope: ntfy_row.host_scope, + file_ownership: ntfy_row.file_ownership, file_mode: ntfy_row.file_mode, + dir_ownership: ntfy_row.dir_ownership, dir_mode: CustodyDirOwnerOnly, + } + match custody_row_placement(row: row) { + CustodyPlacementRefused { cause: CustodyGroupCannotTraverse { path: p, dir: d, group: g, file_perm: fp, dir_perm: dp } } => + (p as String) == "/etc/gunbc-ntfy/publisher-token" + && (d as String) == "/etc/gunbc-ntfy" + && (g as String) == "gunbc-ntfy" + && fp == "0640" && dp == "0700" + && string_contains(s: custody_placement_refusal_wire(r: CustodyGroupCannotTraverse { path: p, dir: d, group: g, file_perm: fp, dir_perm: dp }), pattern: "denies that group traversal") + _ => false + } +} + +test fn a_group_readable_token_whose_directory_is_another_group_refuses() -> Bool { + let row = HostCredentialCustodyRow { + credential: ntfy_row.credential, secret: ntfy_row.secret, path: ntfy_row.path, + host_scope: ntfy_row.host_scope, + file_ownership: ntfy_row.file_ownership, file_mode: ntfy_row.file_mode, + dir_ownership: PosixOwnerNames { user: "root" as NonEmptyStr, group: "root" as NonEmptyStr }, + dir_mode: ntfy_row.dir_mode, + } + match custody_row_placement(row: row) { + CustodyPlacementRefused { cause: CustodyDirectoryGroupDiffers { path: _, file_group: fg, dir_group: dg } } => + (fg as String) == "gunbc-ntfy" && (dg as String) == "root" + _ => false + } +} + +// ── far-side legs derive from the row ─────────────────────────────────────────────────────────── + +test fn the_owner_leg_takes_user_and_group_from_the_row() -> Bool { + custody_find_argv(path: "/p", kind: "f", clause: custody_owner_clause(o: custody_file_legs(row: ntfy_row).ownership)) + == ["find", "/p", "-maxdepth", "0", "-type", "f", "-user", "root", "-group", "gunbc-ntfy"] +} + +test fn the_mode_legs_ask_for_the_arm_exactly_and_forbid_its_complement() -> Bool { + custody_find_argv(path: "/p", kind: "f", clause: custody_perm_clause(perm: custody_file_legs(row: controller_row).exact_perm)) + == ["find", "/p", "-maxdepth", "0", "-type", "f", "-perm", "0400"] + && custody_perm_clause(perm: custody_file_legs(row: ntfy_row).forbidden_perm) == ["-perm", "/037"] +} + +test fn the_directory_ensure_and_the_file_owner_come_from_the_row() -> Bool { + custody_dir_ensure_argv(row: ntfy_row, dir: "/etc/gunbc-ntfy") + == ["install", "-d", "-o", "root", "-g", "gunbc-ntfy", "-m", "0750", "/etc/gunbc-ntfy"] + && custody_file_chown_argv(row: ntfy_row) == ["/usr/bin/chown", "root:gunbc-ntfy", "/etc/gunbc-ntfy/publisher-token"] +} + +// ── host scope ────────────────────────────────────────────────────────────────────────────────── + +test fn the_ntfy_token_is_delivered_to_srv1_only() -> Bool { + let on_srv1 = match custody_host_admission(row: ntfy_row, host: "srv1" as HostIdentity) { + CustodyHostAdmitted => true + CustodyHostRefused { cause: _ } => false + } + let on_srv2 = match custody_host_admission(row: ntfy_row, host: "srv2" as HostIdentity) { + CustodyHostAdmitted => false + CustodyHostRefused { cause: c } => string_contains(s: c, pattern: "only to srv1") + } + let controller_anywhere = match custody_host_admission(row: controller_row, host: "srv2" as HostIdentity) { + CustodyHostAdmitted => true + CustodyHostRefused { cause: _ } => false + } + on_srv1 && on_srv2 && controller_anywhere +} + +// ── readback verdict ──────────────────────────────────────────────────────────────────────────── + +data declared_reading: CustodyFindReading = CustodyFindReading { owned_as_declared: true, forbidden_bits: false, exact_mode: true } + +data staging_path: String = "/etc/gunbc-ntfy/publisher-token.gunbc-staging" +data staging_name: String = "publisher-token.gunbc-staging" +data cleanup_dir: String = "/etc/gunbc-ntfy" + +// THE STAGING READING IS SUPPLIED AS THE VALUE IT IS: classify_staging_residue is pure over a +// listing that SUCCEEDED, so StagingUnobservable belongs to the leg and is supplied directly. +fn staging_from_listing(listing: String) -> StagingResidue { + classify_staging_residue(path: staging_path, name: staging_name, stdout: listing) +} + +data clean_listing: String = "server.yml\npublisher-token" + +fn readback_with(file: CustodyFindReading, staging: StagingResidue, content_identical: Bool) -> CustodyReadback { + CustodyReadback { + file: classify_custody_path(reading: file), + dir: classify_custody_path(reading: declared_reading), + upper_ancestors: PathsRootOnly, + content: if content_identical { HostFileIdentical } else { HostFileDiffers }, + staging: staging, + } +} + +fn is_held(r: CustodyReadback) -> Bool { + match host_credential_custody(row: ntfy_row, readback: r) { + CustodyHeld => true + CustodyNotHeld { causes: _ } => false + } +} + +test fn a_declared_identical_token_with_no_staging_is_held() -> Bool { + is_held(r: readback_with(file: declared_reading, staging: staging_from_listing(listing: clean_listing), content_identical: true)) +} + +test fn a_wrong_owner_is_not_held_and_names_the_declared_owner() -> Bool { + let r = readback_with(file: CustodyFindReading { owned_as_declared: false, forbidden_bits: false, exact_mode: true }, staging: staging_from_listing(listing: clean_listing), content_identical: true) + match host_credential_custody(row: ntfy_row, readback: r) { + CustodyNotHeld { causes: cs } => any(cs, c => string_contains(s: c, pattern: "WRONG-OWNER (declared owner=root:gunbc-ntfy mode=0640)")) + CustodyHeld => false + } +} + +test fn forbidden_bits_are_not_held() -> Bool { + let r = readback_with(file: CustodyFindReading { owned_as_declared: true, forbidden_bits: true, exact_mode: false }, staging: staging_from_listing(listing: clean_listing), content_identical: true) + !is_held(r: r) && match r.file { + CustodyPathForbiddenBits => true + _ => false + } +} + +test fn a_mode_other_than_the_arm_is_not_held() -> Bool { + let r = readback_with(file: CustodyFindReading { owned_as_declared: true, forbidden_bits: false, exact_mode: false }, staging: staging_from_listing(listing: clean_listing), content_identical: true) + !is_held(r: r) && match r.file { + CustodyPathModeNotDeclared => true + _ => false + } +} + +test fn differing_bytes_are_not_held() -> Bool { + !is_held(r: readback_with(file: declared_reading, staging: staging_from_listing(listing: clean_listing), content_identical: false)) +} + +test fn a_surviving_staging_sibling_is_not_held() -> Bool { + let r = readback_with(file: declared_reading, staging: staging_from_listing(listing: "publisher-token\npublisher-token.gunbc-staging"), content_identical: true) + !is_held(r: r) && match r.staging { + StagingResiduePresent { path: _ } => true + _ => false + } +} + +test fn an_unrunnable_staging_listing_is_unobservable_not_absent() -> Bool { + let r = readback_with(file: declared_reading, staging: StagingUnobservable { reason: "staging parent listing exit=2 ls: cannot open directory" }, content_identical: true) + !is_held(r: r) && match r.staging { + StagingUnobservable { reason: _ } => true + _ => false + } +} + +// ── the decision before a write ───────────────────────────────────────────────────────────────── + +test fn an_identical_file_with_wrong_owner_is_rewritten_not_left() -> Bool { + match custody_action(dir: CustodyPathAsDeclared, upper_ancestors: PathsRootOnly, content: HostFileIdentical, file: CustodyPathWrongOwner) { + CustodyWrite => true + _ => false + } +} + +test fn a_declared_file_is_left_in_place() -> Bool { + match custody_action(dir: CustodyPathAsDeclared, upper_ancestors: PathsRootOnly, content: HostFileIdentical, file: CustodyPathAsDeclared) { + CustodyLeaveInPlace => true + _ => false + } +} + +test fn a_directory_not_as_declared_refuses_before_any_content_write() -> Bool { + match custody_action(dir: CustodyPathForbiddenBits, upper_ancestors: PathsRootOnly, content: HostFileDiffers, file: CustodyPathAbsent) { + CustodyActionRefused { reason: _ } => true + _ => false + } +} + +test fn a_writable_ancestor_refuses_before_any_content_write() -> Bool { + match custody_action(dir: CustodyPathAsDeclared, upper_ancestors: PathsNotRootOnly { paths: ["/etc"], detail: "" }, content: HostFileDiffers, file: CustodyPathAbsent) { + CustodyActionRefused { reason: _ } => true + _ => false + } +} + +// ── the custody subject is a host, independent of slot width (gunbc#11902) ───────────────────── + +// THE DISCRIMINATING PAIR: srv2's runner width is unresolved, so the deploy route refuses it while +// custody_host admits it from the spec. If custody is re-coupled to the deploy row, the first reddens. +test fn srv2_resolves_a_custody_subject_although_its_slot_width_is_unresolved() -> Bool { + match custody_host(host: operator_host_srv2) { + CustodyHostSubjectAdmitted { subject: s } => (s.host as String) == (operator_host_srv2 as String) + CustodyHostSubjectRefused { cause: _ } => false + } +} + +test fn the_deploy_route_still_refuses_srv2() -> Bool { + match runner_host_file_subject(host: operator_host_srv2) { + RunnerHostFileSubjectRefused { cause: _ } => true + RunnerHostFileSubjectAdmitted { subject: _ } => false + } +} + +// The ntfy row is scoped to srv1, so srv1 must resolve as a custody subject or the row can never run. +test fn srv1_resolves_a_custody_subject() -> Bool { + match custody_host(host: "srv1" as HostIdentity) { + CustodyHostSubjectAdmitted { subject: _ } => true + CustodyHostSubjectRefused { cause: _ } => false + } +} + +test fn a_host_with_no_runner_spec_is_refused_as_a_custody_subject() -> Bool { + match custody_host(host: "not-a-fleet-host" as HostIdentity) { + CustodyHostSubjectRefused { cause: c } => string_contains(s: c, pattern: "no runner host spec") + CustodyHostSubjectAdmitted { subject: _ } => false + } +} + +// ── staging absence comes from the parent listing ─────────────────────────────────────────────── + +test fn a_parent_listing_without_the_staging_name_establishes_absence() -> Bool { + match staging_from_listing(listing: clean_listing) { + StagingAbsent => true + _ => false + } +} + +test fn an_empty_parent_listing_is_absence_because_the_parent_was_read() -> Bool { + match staging_from_listing(listing: "") { + StagingAbsent => true + _ => false + } +} + +// ── cleanup is a root mutation, so it carries the same path authority the write does ──────────── + +fn cleanup_withheld_reason(upper: RunnerHostPathMetadata, dir: CustodyPathMetadata, staging: String) -> String { + match custody_host(host: operator_host_srv2) { + CustodyHostSubjectRefused { cause: _ } => "" + CustodyHostSubjectAdmitted { subject: s } => + match admit_staging_cleanup(subject: s, upper_ancestors: upper, dir: dir, key_dir: cleanup_dir, staging: staging) { + StagingCleanupWithheld { reason: r } => r + StagingCleanupAuthorized { authorization: _ } => "" + } + } +} + +test fn a_writable_upper_ancestor_withholds_the_staging_cleanup() -> Bool { + string_contains(s: cleanup_withheld_reason(upper: PathsNotRootOnly { paths: ["/etc"], detail: "" }, dir: CustodyPathAsDeclared, staging: staging_path), pattern: "ancestor") +} + +test fn a_directory_not_as_declared_withholds_the_staging_cleanup() -> Bool { + string_contains(s: cleanup_withheld_reason(upper: PathsRootOnly, dir: CustodyPathForbiddenBits, staging: staging_path), pattern: "not as the row declares") +} + +test fn an_unreadable_directory_withholds_the_staging_cleanup() -> Bool { + string_contains(s: cleanup_withheld_reason(upper: PathsRootOnly, dir: CustodyPathUnobservable { reason: "find leg exit=1" }, staging: staging_path), pattern: "unobservable") +} + +test fn a_staging_path_outside_the_admitted_directory_is_withheld() -> Bool { + string_contains(s: cleanup_withheld_reason(upper: PathsRootOnly, dir: CustodyPathAsDeclared, staging: "/tmp/publisher-token.gunbc-staging"), pattern: "admitted directory") +} + +// THE POSITIVE CONTROL: the seal names the exact parent and staging path the leg will run over. +test fn declared_ancestors_and_directory_authorize_the_exact_staging_path() -> Bool { + match custody_host(host: operator_host_srv2) { + CustodyHostSubjectRefused { cause: _ } => false + CustodyHostSubjectAdmitted { subject: s } => + match admit_staging_cleanup(subject: s, upper_ancestors: PathsRootOnly, dir: CustodyPathAsDeclared, key_dir: cleanup_dir, staging: staging_path) { + StagingCleanupAuthorized { authorization: z } => z.staging == staging_path && z.parent == cleanup_dir + StagingCleanupWithheld { reason: _ } => false + } + } +} diff --git a/dag/test/claim/runner/microvm_controller_app_key_converge_witness_test.dag b/dag/test/claim/runner/microvm_controller_app_key_converge_witness_test.dag deleted file mode 100644 index c69b9773ba6..00000000000 --- a/dag/test/claim/runner/microvm_controller_app_key_converge_witness_test.dag +++ /dev/null @@ -1,418 +0,0 @@ -module test.claim.microvm_controller_app_key_converge_witness - -import std.logic { Bool } -import std.types { String, NonEmptyStr, List, Int } -import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } -import gunbc.github_effect_perform { lifecycle_controller_app_key } -import gunbc.runner_host_file_converge { - HostFileIdentical, - HostFileDiffers, - PathsRootOnly, - PathsNotRootOnly, - runner_host_file_subject, - RunnerHostFileSubjectAdmitted, - RunnerHostFileSubjectRefused, -} -import product.placement_supply { HostIdentity } -import gunbc.fleet_intent_network { operator_host_srv2 } -import gunbc.microvm_controller_app_key_converge { - controller_key_host, - ControllerKeyHostAdmitted, - ControllerKeyHostRefused, - StagingResidue, - ControllerAppKeyPlaced, - ControllerAppKeyPlacementRefused, - controller_app_key_placement, - signing_key_file_path, - KeyPathFindReading, - KeyFileRootOwnerRead, - KeyFileWrongOwner, - KeyFileGroupOrOtherAccess, - KeyFileModeNotOwnerReadOnly, - KeyFileAbsent, - KeyDirRootOnly, - KeyDirGroupOrOtherAccess, - classify_key_file_metadata, - classify_key_dir_metadata, - StagingAbsent, - StagingResiduePresent, - StagingUnobservable, - classify_staging_residue, - ControllerAppKeyReadback, - ControllerAppKeyHeld, - ControllerAppKeyNotHeld, - controller_app_key_custody, - AppKeyLeaveInPlace, - AppKeyWrite, - AppKeyActionRefused, - controller_app_key_action, - key_path_find_argv, - key_path_exactly_owner_read_clause, - KeyDirMetadataUnobservable, - admit_staging_cleanup, - StagingCleanupAuthorized, - StagingCleanupWithheld, -} - -data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly - -// THE SUBJECT OF EVERY CLAIM HERE IS ONE PURE INTERFACE, with its inputs SUPPLIED (DESIGN §3, a -// witness discriminates at one interface): the far-side find answers and the cmp/test exits are -// constructed rather than produced by a host. The execution route (fleet SSH, sudo, Secret Manager) -// has no hermetic seam that returns distinct answers per leg, so its inhabitance is the dispatched -// fleet-converge mode's receipt, not a claim in this file. - -data held_file: KeyPathFindReading = KeyPathFindReading { root_owned: true, group_or_other_bits: false, exactly_owner_read: true } - -data held_dir: KeyPathFindReading = KeyPathFindReading { root_owned: true, group_or_other_bits: false, exactly_owner_read: false } - -data staging_path: String = "/etc/gunbc/microvm-controller/app.pem.gunbc-staging" - -data cleanup_key_dir: String = "/etc/gunbc/microvm-controller" -data staging_name: String = "app.pem.gunbc-staging" - -// THE STAGING READING IS SUPPLIED AS THE VALUE IT IS, not as an exit code. classify_staging_residue -// is now pure over a listing that SUCCEEDED, so StagingUnobservable has no constructor inside it -- -// that arm belongs to the leg, and the claim that needs it supplies it directly. -fn staging_from_listing(listing: String) -> StagingResidue { - classify_staging_residue(path: staging_path, name: staging_name, stdout: listing) -} - -fn readback_with(file: KeyPathFindReading, staging: StagingResidue, content_identical: Bool) -> ControllerAppKeyReadback { - ControllerAppKeyReadback { - file: classify_key_file_metadata(reading: file), - dir: classify_key_dir_metadata(reading: held_dir), - upper_ancestors: PathsRootOnly, - content: if content_identical { HostFileIdentical } else { HostFileDiffers }, - staging: staging, - } -} - -fn is_held(r: ControllerAppKeyReadback) -> Bool { - match controller_app_key_custody(readback: r) { - ControllerAppKeyHeld => true - ControllerAppKeyNotHeld { causes: _ } => false - } -} - -// ── positive control ──────────────────────────────────────────────────────────────────────────── - -test fn a_root_owned_0400_identical_key_with_no_staging_is_held() -> Bool { - is_held(r: readback_with(file: held_file, staging: staging_from_listing(listing: "other-file\napp.pem"), content_identical: true)) -} - -// ── discriminating reds, each changing ONE input from the control and asserting the REASON ────── - -test fn a_runner_owned_key_is_not_held_and_is_named_wrong_owner() -> Bool { - let r = readback_with(file: KeyPathFindReading { root_owned: false, group_or_other_bits: false, exactly_owner_read: true }, staging: staging_from_listing(listing: "other-file\napp.pem"), content_identical: true) - !is_held(r: r) && match r.file { - KeyFileWrongOwner => true - _ => false - } -} - -test fn a_group_readable_key_is_not_held_and_is_named_group_or_other_access() -> Bool { - let r = readback_with(file: KeyPathFindReading { root_owned: true, group_or_other_bits: true, exactly_owner_read: false }, staging: staging_from_listing(listing: "other-file\napp.pem"), content_identical: true) - !is_held(r: r) && match r.file { - KeyFileGroupOrOtherAccess => true - _ => false - } -} - -test fn an_owner_writable_key_is_not_held_and_is_named_mode_not_0400() -> Bool { - let r = readback_with(file: KeyPathFindReading { root_owned: true, group_or_other_bits: false, exactly_owner_read: false }, staging: staging_from_listing(listing: "other-file\napp.pem"), content_identical: true) - !is_held(r: r) && match r.file { - KeyFileModeNotOwnerReadOnly => true - _ => false - } -} - -test fn a_key_whose_bytes_differ_from_the_sm_version_is_not_held() -> Bool { - !is_held(r: readback_with(file: held_file, staging: staging_from_listing(listing: "other-file\napp.pem"), content_identical: false)) -} - -test fn a_surviving_staging_sibling_is_not_held_and_is_named_residue() -> Bool { - let r = readback_with(file: held_file, staging: staging_from_listing(listing: "app.pem\napp.pem.gunbc-staging"), content_identical: true) - !is_held(r: r) && match r.staging { - StagingResiduePresent { path: _ } => true - _ => false - } -} - -// THE NAME SAYS WHAT IT ESTABLISHES: a leg that did not run is not absence. The probe this -// replaced was `test -e`, whose exit 1 collapsed "gone" with "could not look" -- and the absent -// arm is the one that lets ControllerAppKeyHeld be minted. -test fn an_unrunnable_staging_listing_is_unobservable_not_absent() -> Bool { - let r = readback_with(file: held_file, staging: StagingUnobservable { reason: "staging parent listing exit=2 ls: cannot open directory" }, content_identical: true) - !is_held(r: r) && match r.staging { - StagingUnobservable { reason: _ } => true - _ => false - } -} - -test fn a_group_traversable_directory_is_not_held() -> Bool { - let r = ControllerAppKeyReadback { - file: classify_key_file_metadata(reading: held_file), - dir: classify_key_dir_metadata(reading: KeyPathFindReading { root_owned: true, group_or_other_bits: true, exactly_owner_read: false }), - upper_ancestors: PathsRootOnly, - content: HostFileIdentical, - staging: StagingAbsent, - } - !is_held(r: r) && match r.dir { - KeyDirGroupOrOtherAccess => true - _ => false - } -} - -// ── the decision before a write ───────────────────────────────────────────────────────────────── - -test fn an_identical_key_with_wrong_owner_is_rewritten_not_left() -> Bool { - match controller_app_key_action(dir: KeyDirRootOnly, upper_ancestors: PathsRootOnly, content: HostFileIdentical, file: KeyFileWrongOwner) { - AppKeyWrite => true - _ => false - } -} - -test fn a_held_key_is_left_in_place() -> Bool { - match controller_app_key_action(dir: KeyDirRootOnly, upper_ancestors: PathsRootOnly, content: HostFileIdentical, file: KeyFileRootOwnerRead) { - AppKeyLeaveInPlace => true - _ => false - } -} - -// WHAT THESE TWO ESTABLISH, STATED IN THEIR NAMES. They call the pure controller_app_key_action -// with SUPPLIED observations; they do not execute the converge, so they cannot establish that no -// host mutation preceded the decision. What they do establish is that neither an unsafe key -// directory nor a writable ancestor reaches the arm that writes KEY CONTENT. Their previous names -// said "before any write", which advertised a property of the production route that the route did -// not have: converge_controller_app_key ran ensure_key_dir (install -d) before the ancestors were -// observed. That ordering is now repaired -- the create is gated on the ancestor reading -- but the -// names stay scoped to what a claim over a pure function can reach. -test fn an_unsafe_directory_refuses_before_any_key_content_write() -> Bool { - match controller_app_key_action(dir: KeyDirGroupOrOtherAccess, upper_ancestors: PathsRootOnly, content: HostFileDiffers, file: KeyFileAbsent) { - AppKeyActionRefused { reason: _ } => true - _ => false - } -} - -test fn a_writable_ancestor_refuses_before_any_key_content_write() -> Bool { - match controller_app_key_action(dir: KeyDirRootOnly, upper_ancestors: PathsNotRootOnly { paths: ["/etc/gunbc"], detail: "" }, content: HostFileDiffers, file: KeyFileAbsent) { - AppKeyActionRefused { reason: _ } => true - _ => false - } -} - -// ── placement ─────────────────────────────────────────────────────────────────────────────────── - -// THE REAL PATH, not a fixture: this reddens if lifecycle_controller_app_key moves somewhere -// with no dedicated root-only directory or into the fabric-cell tree. -test fn the_modeled_key_path_places_in_its_own_directory_under_etc() -> Bool { - match controller_app_key_placement(key_path: signing_key_file_path(key: lifecycle_controller_app_key)) { - ControllerAppKeyPlaced { key_path: _, key_dir: d, upper_ancestors: ua } => - (d as String) == "/etc/gunbc/microvm-controller" && ua == ["/etc", "/etc/gunbc"] - ControllerAppKeyPlacementRefused { cause: _ } => false - } -} - -test fn a_key_path_under_the_attempt_tree_is_refused() -> Bool { - match controller_app_key_placement(key_path: "/opt/fabric-cells/slot-1/attempts/a1/app.pem" as NonEmptyStr) { - ControllerAppKeyPlacementRefused { cause: c } => string_contains(s: c as String, pattern: "fabric-cell tree") - ControllerAppKeyPlaced { key_path: _, key_dir: _, upper_ancestors: _ } => false - } -} - -test fn the_mode_leg_asks_for_exactly_0400() -> Bool { - key_path_find_argv(path: "/p", kind: "f", clause: key_path_exactly_owner_read_clause) - == ["find", "/p", "-maxdepth", "0", "-type", "f", "-perm", "0400"] -} - - -// ── the custody subject is independent of slot width ──────────────────────────────────────────── - -// THE DISCRIMINATING PAIR, and the reason this repair exists. srv2's runner width is unresolved, so -// it has no admitted RunnerHostDeploy and runner_host_file_subject refuses it -- while the mode -// described itself as covering srv1 and srv2. The App key's location, administrator target and -// custody do not depend on how many slots a host admits, so controller_key_host resolves the same -// host from its SPEC and admits it. If a future edit re-couples custody to the deploy row, the -// first of these goes red. -test fn srv2_resolves_a_custody_subject_although_its_slot_width_is_unresolved() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostAdmitted { subject: s } => (s.host as String) == (operator_host_srv2 as String) - ControllerKeyHostRefused { cause: _ } => false - } -} - -test fn the_deploy_route_still_refuses_srv2_which_is_what_made_the_mode_terminate() -> Bool { - match runner_host_file_subject(host: operator_host_srv2) { - RunnerHostFileSubjectRefused { cause: _ } => true - RunnerHostFileSubjectAdmitted { subject: _ } => false - } -} - -test fn a_host_with_no_runner_spec_is_refused_as_a_custody_subject() -> Bool { - match controller_key_host(host: "not-a-fleet-host" as HostIdentity) { - ControllerKeyHostRefused { cause: c } => string_contains(s: c, pattern: "no runner host spec") - ControllerKeyHostAdmitted { subject: _ } => false - } -} - -// ── staging absence comes from the parent listing ─────────────────────────────────────────────── - -test fn a_parent_listing_without_the_staging_name_establishes_absence() -> Bool { - match staging_from_listing(listing: "app.pem\nother") { - StagingAbsent => true - _ => false - } -} - -test fn a_parent_listing_carrying_the_staging_name_is_residue() -> Bool { - match staging_from_listing(listing: "app.pem\napp.pem.gunbc-staging") { - StagingResiduePresent { path: p } => p == staging_path - _ => false - } -} - -// AN EMPTY LISTING IS ABSENCE, and that is the one case worth stating apart: the parent was read and -// held nothing, which is a reading. It is NOT the same as a listing that never ran, which cannot -// reach this function at all. -test fn an_empty_parent_listing_is_absence_because_the_parent_was_read() -> Bool { - match staging_from_listing(listing: "") { - StagingAbsent => true - _ => false - } -} - - -// ── cleanup is a root mutation, so it carries the same path authority the write does ──────────── - -// THE FOURTH FINDING IN THIS FAMILY, one step further down the same route. The ordering repair -// stopped `install -d` beneath an untrusted ancestor, but clear_staging still ran UNCONDITIONALLY -// after the action refused, and it BEGINS with `sudo rm -f -- `. On a host whose /etc or -// /etc/gunbc is group- or other-writable the route refused the write and then performed a root -// mutation through the very path component it had declined to trust -- a component a non-root -// principal can replace with a directory or a symlink between the observation and the leg. -// -// These controls run the PURE admission, which is the whole gate: sole_constructor means -// StagingCleanupAuthorization exists nowhere else, so an unauthorized arm has no value to pass to -// clear_staging and the rm has no way to be spelled. The previous 21 could not see the defect -// because they invoked only controller_app_key_action and never reached the cleanup leg. - -test fn a_writable_upper_ancestor_withholds_the_staging_cleanup() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => - match admit_staging_cleanup( - subject: s, - upper_ancestors: PathsNotRootOnly { paths: ["/etc/gunbc"], detail: "" }, - dir: KeyDirRootOnly, - key_dir: cleanup_key_dir, - staging: staging_path, - ) { - StagingCleanupWithheld { reason: r } => string_contains(s: r, pattern: "ancestor") - StagingCleanupAuthorized { authorization: _ } => false - } - } -} - -test fn an_unsafe_key_directory_withholds_the_staging_cleanup() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => - match admit_staging_cleanup( - subject: s, - upper_ancestors: PathsRootOnly, - dir: KeyDirGroupOrOtherAccess, - key_dir: cleanup_key_dir, - staging: staging_path, - ) { - StagingCleanupWithheld { reason: r } => string_contains(s: r, pattern: "not root-only") - StagingCleanupAuthorized { authorization: _ } => false - } - } -} - -// UNREADABLE IS NOT SAFE. A directory whose metadata could not be read is exactly the state where a -// cleanup leg would be running blind, so it withholds for the same reason an unsafe one does. -test fn an_unreadable_key_directory_withholds_the_staging_cleanup() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => - match admit_staging_cleanup( - subject: s, - upper_ancestors: PathsRootOnly, - dir: KeyDirMetadataUnobservable { reason: "find leg exit=1" }, - key_dir: cleanup_key_dir, - staging: staging_path, - ) { - StagingCleanupWithheld { reason: _ } => true - StagingCleanupAuthorized { authorization: _ } => false - } - } -} - -// THE JOIN, not merely the two readings: a staging path whose parent is NOT the directory that was -// admitted is unauthorized even when both readings are root-only, because the authority established -// is over that directory and nothing else. -test fn a_staging_path_outside_the_admitted_directory_is_withheld() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => - match admit_staging_cleanup( - subject: s, - upper_ancestors: PathsRootOnly, - dir: KeyDirRootOnly, - key_dir: cleanup_key_dir, - staging: "/tmp/app.pem.gunbc-staging", - ) { - StagingCleanupWithheld { reason: r } => string_contains(s: r, pattern: "admitted key directory") - StagingCleanupAuthorized { authorization: _ } => false - } - } -} - -// THE POSITIVE CONTROL. Root-only ancestors and a root-only directory containing the staging path -// authorize exactly one cleanup, and the seal names the exact parent and staging path the leg will -// run over -- not the subject's idea of them. -test fn root_only_ancestors_and_directory_authorize_the_exact_staging_path() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => - match admit_staging_cleanup( - subject: s, - upper_ancestors: PathsRootOnly, - dir: KeyDirRootOnly, - key_dir: cleanup_key_dir, - staging: staging_path, - ) { - StagingCleanupAuthorized { authorization: z } => - z.staging == staging_path && z.parent == cleanup_key_dir && (z.subject.host as String) == (operator_host_srv2 as String) - StagingCleanupWithheld { reason: _ } => false - } - } -} - -// THE THREE WITHHELD CAUSES ARE DISTINCT, which is what makes the diagnostic LOCATED rather than -// merely typed. An earlier revision discarded admit_staging_cleanup's computed cause at the call -// site and substituted one constant, so a receipt said "root-only path authority was not -// established" without saying whether an operator should fix an ancestor, the directory, or a -// staging path pointing outside it. This reddens if the three are ever flattened back together. -test fn the_three_withheld_causes_name_which_fact_failed() -> Bool { - match controller_key_host(host: operator_host_srv2) { - ControllerKeyHostRefused { cause: _ } => false - ControllerKeyHostAdmitted { subject: s } => { - let ancestor_cause = match admit_staging_cleanup(subject: s, upper_ancestors: PathsNotRootOnly { paths: ["/etc/gunbc"], detail: "" }, dir: KeyDirRootOnly, key_dir: cleanup_key_dir, staging: staging_path) { - StagingCleanupWithheld { reason: r } => r - StagingCleanupAuthorized { authorization: _ } => "" - } - let dir_cause = match admit_staging_cleanup(subject: s, upper_ancestors: PathsRootOnly, dir: KeyDirGroupOrOtherAccess, key_dir: cleanup_key_dir, staging: staging_path) { - StagingCleanupWithheld { reason: r } => r - StagingCleanupAuthorized { authorization: _ } => "" - } - let path_cause = match admit_staging_cleanup(subject: s, upper_ancestors: PathsRootOnly, dir: KeyDirRootOnly, key_dir: cleanup_key_dir, staging: "/tmp/app.pem.gunbc-staging") { - StagingCleanupWithheld { reason: r } => r - StagingCleanupAuthorized { authorization: _ } => "" - } - ancestor_cause != "" && ancestor_cause != dir_cause && dir_cause != path_cause && ancestor_cause != path_cause - } - } -} diff --git a/dag/test/claim/srv3/srv3_principal_privilege_witness_test.dag b/dag/test/claim/srv3/srv3_principal_privilege_witness_test.dag index 308e47522f8..7792a486e35 100644 --- a/dag/test/claim/srv3/srv3_principal_privilege_witness_test.dag +++ b/dag/test/claim/srv3/srv3_principal_privilege_witness_test.dag @@ -12,7 +12,7 @@ import extdeps.posix.identity { posix_group_id_from_text, posix_owner_spec_text, } -import extdeps.tools.chown { chown_argv, chown_binary_path } +import extdeps.tools.chown { chown_argv, chown_binary_path, ChownOwnerIds } import extdeps.tools.mkdir { mkdir_parents_argv } import extdeps.tools.chmod { chmod_add_executable_argv, chmod_binary_path, chmod_path_resolved_program } import extdeps.sudo.elevation { sudo_elevate, sudo_binary_path } @@ -104,10 +104,10 @@ test fn owner_spec_renders_as_uid_colon_gid() -> Bool { test fn chown_argv_names_the_binary_then_the_owner_then_the_path() -> Bool { chown_argv( - owner: PosixOwnerSpec { + owner: ChownOwnerIds { spec: PosixOwnerSpec { user: PosixUserId { value: 1000 }, group: PosixGroupId { value: 1000 }, - }, + } }, path: "/opt/gunbc", ) == ["/usr/bin/chown", "1000:1000", "/opt/gunbc"] } diff --git a/docs/plans/review-sheet-and-census-lane-handoff.md b/docs/plans/review-sheet-and-census-lane-handoff.md index 3390df6dc45..ce8458276f4 100644 --- a/docs/plans/review-sheet-and-census-lane-handoff.md +++ b/docs/plans/review-sheet-and-census-lane-handoff.md @@ -133,6 +133,15 @@ necessary-but-not-sufficient for that trigger. Not blocked. #11679 is merged, so the custody authority this consolidates into exists today. +**Landed as a consolidation** into `gunbc.host_credential_custody_converge` (the #11679 module, +renamed because it no longer holds one key): fleet-converge mode `host_credential_custody_converge` +with a `credential` choice over `host_custody_credential_roster` — `ControllerAppKey` first, then +`ApprovalNtfyPublisherToken`. The delivery HumanIntervention is deleted; what stays human is the +**mint** (`approval_ntfy_publisher_token_mint_intervention`): creating the ntfy user and adding its +token to Secret Manager. Two operator acts remain before the first live receipt for the ntfy row: +the mint, and ensuring `gunbc.auth.fleet_secret_accessor_roster` +`approval_ntfy_publisher_token_accessor_row`. + ## Small, unblocked Seven unused imports across `gunbc.ci_workflow_scan_producer` (`CodeSearchPage`,