Skip to content
Closed
108 changes: 79 additions & 29 deletions dag/gunbc/spark/grant_privileged_operation.dag
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import gunbc.spark.bootstrap_credential {
CredentialMaterializationPending,
}
import std.algebra { trim }
import std.effect_grant { NamespacePosition, FilesystemPathTree, Write }
import std.content_hash { content_hash_of_value }
import std.scoped_authorization {
AttemptIdentity,
AuthorizedAction,
AuthorizationScope,
AuthorizationRequest,
}
import std.resources { Network }
import extdeps.shell
import extdeps.filesystem.filesystem_io { Filesystem }
Expand Down Expand Up @@ -54,39 +62,81 @@ import gunbc.spark.managed_access_bootstrap {
// materialized with a carried ref or explicitly pending. So the lease takes the standing, and a
// pending administrator credential is an honest refusal rather than a reference to a secret nobody
// has authored.
type OperatorBootstrapCredentialLease sole_constructor {
//
// THE CARRIER ITSELF IS NOW std.scoped_authorization, AND IT IS A REQUEST RATHER THAN A GRANT.
// AuthorizationRequest<SparkBootstrapSubject> used to be declared here as its own type carrying host,
// bootstrap_principal, administrator, authorized_purpose and attempt_identity -- the third
// independent spelling of "a scoped operator authorization consumed by exactly one attempt" in this
// repository, beside gunbc.workflow_escalation's WorkflowApprovalState and std.temporal_effect's
// DurableApprovalGrant. It had independently reached attempt identity and purpose, and was missing
// expiry, any consumption state, and any ENFORCING scope: authorized_purpose was a sentence built
// by join(), and a rendered sentence cannot decide anything.
//
// It folds to AuthorizationRequest, not OperatorGrant, and the distinction is the honest part. This
// value is constructed by the RUN, not by an operator -- nothing here has been approved yet, so
// there is no granted_by to name and no expiry to carry. Calling it a grant would have manufactured
// consent that nobody gave. What the run states is what it NEEDS authorized; the grant is what comes
// back.
//
// The subject is owned here rather than in std, so the generic module never grows a row per lane.

type SparkBootstrapSubject {
host: HostIdentity
bootstrap_principal: NonEmptyStr
administrator: SparkCredentialStanding
authorized_purpose: NonEmptyStr
attempt_identity: NonEmptyStr
}

// THE ENFORCING HALF, which the lease never had. The privileged act being authorized is the
// installation of one sudoers drop-in on one host, so the scope names that position in the cited
// filesystem tree with verb Write -- not the secret, which is the MEANS, and not a sentence. A
// request against any other drop-in path fails the namespace prefix relation rather than passing
// because the prose happened to read plausibly.
fn spark_bootstrap_sudoers_scope(dropin_path: NonEmptyStr) -> AuthorizationScope {
AuthorizationScope {
verb: Write,
resource: NamespacePosition {
tree: FilesystemPathTree,
path: ["etc", "sudoers.d", dropin_path as String],
},
action: "posix.sudoers.install" as AuthorizedAction,
}
}

fn spark_bootstrap_credential_lease(
host: HostIdentity,
administrator: SparkCredentialStanding,
grant: SparkManagedGrant,
attempt_identity: NonEmptyStr,
) -> OperatorBootstrapCredentialLease {
OperatorBootstrapCredentialLease {
host: host,
bootstrap_principal: spark_operator_bootstrap_login as NonEmptyStr,
administrator: administrator,
authorized_purpose: join([
) -> AuthorizationRequest<SparkBootstrapSubject> {
AuthorizationRequest {
escalation_id: "spark-bootstrap-sudoers-install" as NonEmptyStr,
scopes: [
spark_bootstrap_sudoers_scope(dropin_path: spark_managed_grant_wire(grant: grant)),
],
subject: SparkBootstrapSubject {
host: host,
bootstrap_principal: spark_operator_bootstrap_login as NonEmptyStr,
administrator: administrator,
},
attempt: attempt_identity as AttemptIdentity,
intent_hash: content_hash_of_value(
value: spark_managed_grant_sudoers_command(grant: grant),
),
destructive: true,
purpose: join([
"install the narrow sudoers grant '", spark_managed_grant_wire(grant: grant) as String,
"' (", spark_managed_grant_sudoers_command(grant: grant) as String,
") on ", host as String,
" -- one-time, through the modeled secret boundary, not retained",
], "") as NonEmptyStr,
attempt_identity: attempt_identity,
}
}

// WHERE THE CREDENTIAL LIVES, if anywhere. A pending standing has no resource to name, and saying so
// is the useful answer: the remedy for "no secret is enrolled" is to enrol one, which is different
// from the remedy for "the enrolled one was rejected".
fn lease_secret_resource(lease: OperatorBootstrapCredentialLease) -> String? {
match lease.administrator {
fn lease_secret_resource(lease: AuthorizationRequest<SparkBootstrapSubject>) -> String? {
match lease.subject.administrator {
CredentialMaterializationPending => none
CredentialMaterialized { ref: r } => Present { value: secret_ref_secret_resource(ref: r) }
}
Expand Down Expand Up @@ -158,9 +208,9 @@ fn admitted_root_argv_label(command: AdmittedRootArgv) -> String {
// enrolment problem; a credential sudo rejected is a wrong or stale version; a command that failed
// after sudo admitted it is a host problem. Collapsing these sends a reader to the wrong place.
type PrivilegedStepOutcome
= CredentialNotEnrolled { lease: OperatorBootstrapCredentialLease }
| CredentialNotMaterialized { lease: OperatorBootstrapCredentialLease, cause: String }
| CredentialRejected { lease: OperatorBootstrapCredentialLease }
= CredentialNotEnrolled { lease: AuthorizationRequest<SparkBootstrapSubject> }
| CredentialNotMaterialized { lease: AuthorizationRequest<SparkBootstrapSubject>, cause: String }
| CredentialRejected { lease: AuthorizationRequest<SparkBootstrapSubject> }
| PrivilegedCommandFailed { command: AdmittedRootArgv, exit_code: Int, stderr: String }
| PrivilegedCommandSucceeded { command: AdmittedRootArgv }

Expand Down Expand Up @@ -191,16 +241,16 @@ fn privileged_step_outcome_wire(outcome: PrivilegedStepOutcome) -> String {
match outcome {
CredentialNotEnrolled { lease: l } =>
join([
" privileged-step CREDENTIAL-NOT-ENROLLED host=", l.host as String,
" principal=", l.bootstrap_principal as String,
" purpose=", l.authorized_purpose as String,
" privileged-step CREDENTIAL-NOT-ENROLLED host=", l.subject.host as String,
" principal=", l.subject.bootstrap_principal as String,
" purpose=", l.purpose as String,
" -- no administrator credential is materialized for this host, so the privileged step was",
" never attempted; enrol one and the process applies the mutation itself\n",
], "")
CredentialNotMaterialized { lease: l, cause: why } =>
join([
" privileged-step CREDENTIAL-NOT-MATERIALIZED host=", l.host as String,
" principal=", l.bootstrap_principal as String,
" privileged-step CREDENTIAL-NOT-MATERIALIZED host=", l.subject.host as String,
" principal=", l.subject.bootstrap_principal as String,
match lease_secret_resource(lease: l) {
Absent => ""
Present { value: r } => join([" secret=", r], "")
Expand All @@ -211,8 +261,8 @@ fn privileged_step_outcome_wire(outcome: PrivilegedStepOutcome) -> String {
], "")
CredentialRejected { lease: l } =>
join([
" privileged-step CREDENTIAL-REJECTED host=", l.host as String,
" principal=", l.bootstrap_principal as String,
" privileged-step CREDENTIAL-REJECTED host=", l.subject.host as String,
" principal=", l.subject.bootstrap_principal as String,
match lease_secret_resource(lease: l) {
Absent => ""
Present { value: r } => join([" secret=", r], "")
Expand Down Expand Up @@ -245,17 +295,17 @@ data spark_grant_credential_path_env: NonEmptyStr = "GUNBC_SPARK_BOOTSTRAP_CREDE
// WHICH SECRET THE STEP MUST MATERIALIZE, read off the lease rather than spelled a second time. The
// CI step that fetches the version and the code that consumes it are then one authority: a step
// fetching a different secret than the lease authorized is unwritable rather than merely unlikely.
fn lease_secret_version_resource(lease: OperatorBootstrapCredentialLease) -> String? {
match lease.administrator {
fn lease_secret_version_resource(lease: AuthorizationRequest<SparkBootstrapSubject>) -> String? {
match lease.subject.administrator {
CredentialMaterializationPending => none
CredentialMaterialized { ref: r } => Present { value: secret_ref_version_resource(ref: r) }
}
}

// The URL the materializing step must fetch, derived rather than spelled, so the step and the lease
// cannot name different versions.
fn lease_secret_access_url(lease: OperatorBootstrapCredentialLease) -> String? {
match lease.administrator {
fn lease_secret_access_url(lease: AuthorizationRequest<SparkBootstrapSubject>) -> String? {
match lease.subject.administrator {
CredentialMaterializationPending => none
CredentialMaterialized { ref: r } => Present { value: secret_ref_access_url(ref: r) }
}
Expand Down Expand Up @@ -365,12 +415,12 @@ func read_bootstrap_credential() -> BootstrapCredentialMaterialization {
func run_privileged_step(
context: FleetSshExecutionContext,
host: HostIdentity,
lease: OperatorBootstrapCredentialLease,
lease: AuthorizationRequest<SparkBootstrapSubject>,
command: AdmittedRootArgv,
) -> PrivilegedStepOutcome
uses net: Network
{
match lease.administrator {
match lease.subject.administrator {
CredentialMaterializationPending => CredentialNotEnrolled { lease: lease }
CredentialMaterialized { ref: _ } =>
match read_bootstrap_credential() {
Expand All @@ -390,7 +440,7 @@ func run_privileged_step(
context: context,
target: SshTarget {
endpoint: fleet_probe_endpoint_for(host: host as String) as NonEmptyStr,
principal: lease.bootstrap_principal,
principal: lease.subject.bootstrap_principal,
},
words: words,
),
Expand Down
153 changes: 137 additions & 16 deletions dag/gunbc/srv3/srv3_boot_once_cd.dag
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ module gunbc.srv3_boot_once_cd

import std.measure { Second, second }
import extdeps.bmc.http
import std.scoped_authorization { ScopedAuthorization }
import std.types { Timestamp }
import gunbc.clock_read { clock_now_probed_at }
import gunbc.workflow_escalation {
WorkflowApprovalState,
workflow_destructive_step_refusal_reason
}
import gunbc.srv3_os_install_actuate_workflow {
srv3_boot_once_cd_default_approval,
srv3_boot_once_cd_operator_granted_approval,
srv3_boot_once_cd_workflow_disposition
srv3_boot_once_cd_workflow_disposition,
srv3_boot_cd_target,
srv3_boot_cd_enabled,
srv3_boot_cd_mode,
srv3_boot_cd_reset_type,
Srv3BootOverrideSubject,
}
import gunbc.srv3_bmc_credential_resolve {
BmcCredentialAccessUpsertRequired,
Expand Down Expand Up @@ -42,10 +49,6 @@ data srv3_boot_once_cd_secret_debt: Disposition = Scaffold {
data srv3_boot_cd_body_path: String = "/tmp/srv3_boot_cd_body.json"
data srv3_reset_body_path: String = "/tmp/srv3_reset_body.json"

data srv3_boot_cd_target: RedfishBootSourceOverrideTarget = BootTargetCd
data srv3_boot_cd_enabled: RedfishBootSourceOverrideEnabled = BootOverrideOnce
data srv3_boot_cd_mode: RedfishBootSourceOverrideMode = BootOverrideModeUefi
data srv3_boot_cd_reset_type: RedfishResetType = ResetForceRestart

fn srv3_boot_cd_override_mode_valid_for_arm64(mode: RedfishBootSourceOverrideMode) -> Bool {
match mode {
Expand All @@ -65,31 +68,149 @@ fn srv3_boot_cd_override_mode_refusal(mode: RedfishBootSourceOverrideMode) -> St
}

func srv3_boot_once_cd() -> ProcessExit
uses net: std.resources.Network
uses net: std.resources.Network, clock: std.resources.Clock
{
srv3_boot_once_cd_gated(approval: srv3_boot_once_cd_default_approval)
}

func srv3_boot_once_cd_operator_approved() -> ProcessExit
uses net: std.resources.Network
uses net: std.resources.Network, clock: std.resources.Clock
{
srv3_boot_once_cd_gated(approval: srv3_boot_once_cd_operator_granted_approval)
}

func srv3_boot_once_cd_gated(approval: WorkflowApprovalState) -> ProcessExit
uses net: std.resources.Network
// The expiry check needs an instant, and the only honest source of one is the clock -- a
// caller-supplied observed_at would let the grant be revalidated against whatever time made it
// pass. An unreadable clock REFUSES: "we could not tell whether the grant had expired" is not
// evidence that it had not (§5 -- a failure arm refuses, never widens).
// THE CLAIM IS TAKEN BEFORE ANY MUTATION AND IT IS THE STORE THAT DECIDES. Evaluating the grant
// and then executing is a check, and the check races: two runs, or one run restarted after a
// crash, each read the same valid grant and each proceed. The durable compare-and-set on the
// escalation's slot is what excludes the second one, so it runs BEFORE the credential is resolved
// and long before the first Redfish write. Losing refuses; an unreadable store refuses -- "we
// could not tell whether another attempt holds this grant" is not evidence that none does, and
// widening there would fail open on the exact operation whose purpose is exclusion.
//
// The claim is then TERMINATED either way, and the two terminals are different facts: a completed
// grant is spent and must never re-run, an aborted one may warrant the operator re-issuing.
// Leaving the slot at ClaimedBy on a failure would make a crashed attempt indistinguishable from
// a running one and block the retry forever.
func srv3_boot_once_cd_gated(approval: ScopedAuthorization<Srv3BootOverrideSubject>) -> ProcessExit
uses net: std.resources.Network, clock: std.resources.Clock
{
let disposition = srv3_boot_once_cd_workflow_disposition(approval: approval)
let observed_at = match clock_now_probed_at() {
Present { value: ts } => ts as Timestamp
Absent =>
return exit_failure(
reason: "boot-once-cd: clock unreadable, cannot decide whether the operator grant has expired",
)
}
let disposition = srv3_boot_once_cd_workflow_disposition(approval: approval, observed_at: observed_at)
let refusal = workflow_destructive_step_refusal_reason(disposition: disposition)
if refusal != "" {
return exit_failure(reason: concat("boot-once-cd: ", refusal))
}

let resolution = observe_and_resolve_srv3_bmc_credential()
return match resolution {
CredentialResolutionRefused { reason: r } =>
exit_failure(reason: concat("boot-once-cd: credential resolution refused: ", r))
_ => srv3_boot_once_cd_resolved(resolution: resolution)
match srv3_claim_boot_once_cd_authorization(at: observed_at) {
ClaimLost { holder: who } =>
return exit_failure(
reason: concat("boot-once-cd: operator grant already claimed by another attempt: ", who as String),
)
ClaimUndecided { reason: why } =>
return exit_failure(reason: concat("boot-once-cd: ", why as String))
ClaimHeld { state: _, generation: held_at } =>
let resolution = observe_and_resolve_srv3_bmc_credential()
let outcome = match resolution {
CredentialResolutionRefused { reason: r } =>
exit_failure(reason: concat("boot-once-cd: credential resolution refused: ", r))
_ => srv3_boot_once_cd_resolved(resolution: resolution)
}
return srv3_settle_boot_once_cd_claim(outcome: outcome, held_at: held_at, at: observed_at)
}
}

func srv3_claim_boot_once_cd_authorization(at: Timestamp) -> AuthorizationClaimOutcome
uses fs: std.resources.Filesystem
{
let proposed = claim_transition_claimed(attempt: srv3_boot_once_cd_attempt, at: at)
match admit_cas_attempt(
attempt: claim_authorization(
escalation_id: srv3_boot_once_cd_authorization_request.escalation_id,
expected: ExpectSlotAbsent,
proposed: proposed,
),
) {
CasAttemptAdmitted { verified: v } =>
authorization_claim_outcome(
proposed: proposed,
outcome: file_compare_and_set(root: srv3_authorization_claim_root, verified: v),
)
CasAttemptDigestMismatch { claimed: _, actual: _ } =>
ClaimUndecided { reason: "claim payload and its digest disagree" as NonEmptyStr }
CasAttemptDigestIncomparable { claimed: _, actual: _ } =>
ClaimUndecided { reason: "claim digest families are incomparable" as NonEmptyStr }
CasAttemptKeyNotSlotAddressable { key: _ } =>
ClaimUndecided { reason: "authorization claim key is not slot-addressable" as NonEmptyStr }
}
}

// The terminal transition rides the SAME compare-and-set, expecting the generation we hold. If it
// does not land, the step's own result still stands -- the mutation already happened, and lying
// about it to keep the ledger tidy would be the worse failure -- but the exit carries the fact
// that the claim was left unterminated, because an operator reading a bare success would not know
// a manual slot inspection is owed.
func srv3_settle_boot_once_cd_claim(
outcome: ProcessExit,
held_at: CasGeneration,
at: Timestamp,
) -> ProcessExit
uses fs: std.resources.Filesystem
{
let terminal = match outcome {
ExitSuccess => claim_transition_completed(attempt: srv3_boot_once_cd_attempt, at: at)
ExitFailure { code: _, reason: r } =>
claim_transition_aborted(attempt: srv3_boot_once_cd_attempt, at: at, cause: r as NonEmptyStr)
}
match admit_cas_attempt(
attempt: claim_authorization(
escalation_id: srv3_boot_once_cd_authorization_request.escalation_id,
expected: ExpectSlotGeneration { generation: held_at },
proposed: terminal,
),
) {
CasAttemptAdmitted { verified: v } =>
match file_compare_and_set(root: srv3_authorization_claim_root, verified: v) {
CasCommitted { committed: _ } => outcome
CasPreconditionFailed { expected: _, observed: _ } =>
srv3_unterminated_claim_exit(outcome: outcome, detail: "claim slot moved under us")
CasStoreRefused { cause: _ } =>
srv3_unterminated_claim_exit(outcome: outcome, detail: "claim store refused the terminal write")
}
CasAttemptDigestMismatch { claimed: _, actual: _ } =>
srv3_unterminated_claim_exit(outcome: outcome, detail: "terminal payload and its digest disagree")
CasAttemptDigestIncomparable { claimed: _, actual: _ } =>
srv3_unterminated_claim_exit(outcome: outcome, detail: "terminal digest families are incomparable")
CasAttemptKeyNotSlotAddressable { key: _ } =>
srv3_unterminated_claim_exit(outcome: outcome, detail: "authorization claim key is not slot-addressable")
}
}

fn srv3_unterminated_claim_exit(outcome: ProcessExit, detail: String) -> ProcessExit {
exit_failure(
reason: concat(
"boot-once-cd: the step finished but its authorization claim was left unterminated (",
concat(
detail,
concat("); step outcome was: ", srv3_process_exit_render(exit: outcome)),
),
),
)
}

fn srv3_process_exit_render(exit: ProcessExit) -> String {
match exit {
ExitSuccess => "success"
ExitFailure { code: _, reason: r } => concat("failure: ", r)
}
}

Expand Down
Loading
Loading