diff --git a/core/crates/ployz-core/src/authority/deploy.rs b/core/crates/ployz-core/src/authority/deploy.rs index 0d0a6c9315..bbaf02053b 100644 --- a/core/crates/ployz-core/src/authority/deploy.rs +++ b/core/crates/ployz-core/src/authority/deploy.rs @@ -51,13 +51,26 @@ impl PayloadDigest { .mount_graph .scope_to_namespace(namespace) .map_err(|error| CanonicalError::Parse(error.to_string()))?; - let mut value = serde_json::to_value(&spec) - .map_err(|error| CanonicalError::Parse(error.to_string()))?; + Self::of_scoped(&spec, |_| {}) + } + + /// The digest of `spec` with its Volumes already scoped, after `edit` changed its JSON + /// form, such as a preview template's placeholders. + /// + /// # Errors + /// Returns a [`CanonicalError`] when the spec has no canonical form. + pub(super) fn of_scoped( + spec: &RequestedServiceSpec, + edit: impl FnOnce(&mut Value), + ) -> Result { + let mut value = + serde_json::to_value(spec).map_err(|error| CanonicalError::Parse(error.to_string()))?; if let Value::Object(fields) = &mut value { fields.remove("mode"); fields.remove("update"); } drop_nulls(&mut value); + edit(&mut value); let body = canonical::to_canonical(&value)?; Ok(Self(Sha256::digest(body.as_bytes()).into())) } @@ -544,6 +557,9 @@ pub fn verify( })?; } super::validity::verify(body.issued_at, body.expires_at, facts.now)?; + if let Some(creation) = facts.creation { + refuse_preview_parts(facts.namespace, creation.spec)?; + } let digest = facts .creation .map(|creation| creation_digest(&body, creation)) @@ -551,6 +567,41 @@ pub fn verify( Ok(DeployApproved { body, by, digest }) } +/// Refuse a creation, outside a preview rule, in a reserved preview Namespace or carrying a +/// parent's value a preview inherits: only a preview rule may admit either, so preview code +/// shares a Namespace with nothing else and an inherited value opens nowhere else. +/// +/// # Errors +/// Returns [`AuthorityRefusal::Mismatch`] naming the Namespace or the Sealed value. +pub fn refuse_preview_parts( + namespace: &Namespace, + spec: &ResolvedServiceSpec, +) -> Result<(), AuthorityRefusal> { + if super::preview::is_preview_namespace(namespace) { + return Err(AuthorityRefusal::Mismatch { + field: "a preview Namespace", + }); + } + if sealed_values(spec).any(|value| value.environment.is_some()) { + return Err(AuthorityRefusal::Mismatch { + field: super::preview::SEALED_VALUE, + }); + } + Ok(()) +} + +fn sealed_values(spec: &ResolvedServiceSpec) -> impl Iterator { + let hook = spec + .pre_deploy + .iter() + .flat_map(|hook| hook.environment.values()); + spec.container + .environment + .values() + .chain(hook) + .flat_map(EnvValue::sealed_parts) +} + /// Whether `by` may approve a Container that opens the Sealed values `spec` carries, in its /// environment or its hook's. A Device opens any. A browser key under a session grant /// opens only values sealed for its grant's Environment, so a dashboard holding a session diff --git a/core/crates/ployz-core/src/authority/mod.rs b/core/crates/ployz-core/src/authority/mod.rs index 52d52a90cb..d64a8724a2 100644 --- a/core/crates/ployz-core/src/authority/mod.rs +++ b/core/crates/ployz-core/src/authority/mod.rs @@ -12,6 +12,7 @@ pub mod canonical; pub mod deploy; pub mod join; pub mod keys; +pub mod preview; pub mod proposal; pub mod refusal; pub mod restart; @@ -29,13 +30,17 @@ pub use deploy::{ }; pub use join::JoinApproval; pub use keys::{Approver, KeySignature, Purpose, Signed, Signer, SigningPublicKey, Statement}; +pub use preview::{ + CheckedReview, PreviewCreation, PreviewFacts, PreviewKey, PreviewProof, PreviewReview, + PreviewRule, PreviewService, Review, ReviewSigner, ReviewedService, +}; pub use proposal::{ProposalApproval, ProposalRef, ProposalStep, StepDigest, StepKind}; pub use refusal::AuthorityRefusal; pub use restart::{Approved, RestartApproval, RestartFacts, RestartStep, SignerKey, WorkflowKind}; pub use rule::{ - AttestKey, AutoDeployRule, BuildClaim, BuildPolicy, BuildStage, CommitAuthority, CommitSha, - GithubClaims, ImageDigest, PolicyDigest, RecipientKey, RuleFacts, RuleId, RuleProof, - RuleService, RuleSource, SealedDigest, + AttestKey, AutoDeployRule, BuildClaim, BuildPolicy, BuildStage, ClaimRule, CommitAuthority, + CommitSha, GithubClaims, ImageDigest, PolicyDigest, RecipientKey, ReviewDigest, RuleFacts, + RuleId, RuleIdentity, RuleProof, RuleService, RuleSource, SealedDigest, }; pub use session::{ActionFamily, GrantBody, SessionGrant}; pub use starting_set::{Member, StartingSet}; diff --git a/core/crates/ployz-core/src/authority/preview.rs b/core/crates/ployz-core/src/authority/preview.rs new file mode 100644 index 0000000000..0a438d4554 --- /dev/null +++ b/core/crates/ployz-core/src/authority/preview.rs @@ -0,0 +1,873 @@ +//! Previews under a preview rule: the rule a person signs once for an Environment's pull +//! request previews, the review a person signs for one untrusted commit, and the checks a +//! Machine runs on a preview's build and Containers. +//! +//! A Machine derives each preview's Namespace itself, from the rule's ID and a preview key +//! it verified: the branch GitHub names in a trusted push's token, or the key a signed +//! review names. Only then does it hash the spec, with that Namespace and the Generated +//! Values it checked replaced by placeholders, against what the person signed. A +//! Cloud-chosen production Namespace or hostname anywhere in the spec stays literal and +//! fails the template. Nothing here verifies a JWT or touches a key. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use ts_rs::TS; +use uuid::Uuid; + +use super::{ + canonical::{self, Timestamp}, + deploy::{Creation, PayloadDigest, content_addressed}, + keys::{Approver, Purpose, Signed, SigningPublicKey, Statement, signing_input}, + refusal::AuthorityRefusal, + restart::SignerKey, + rule::{ + AttestKey, BuildClaim, BuildPolicy, BuildStage, ClaimRule, CommitAuthority, CommitSha, + GithubClaims, ImageDigest, PolicyDigest, ReviewDigest, RuleId, RuleIdentity, RuleSource, + RuleStatement, SealedDigest, + }, + session::{ActionFamily, GrantScope}, + trusted_key::{PasskeyAssertion, TrustedKeys}, +}; +use crate::{ + ContainerKind, DeviceCode, EnvValue, MachineId, Namespace, RequestedServiceSpec, + SealingPublicKey, ServiceName, config::Generator, +}; + +/// What every occurrence of a preview's derived Namespace becomes before its spec is +/// hashed. Braces never appear in a Namespace or a hostname. +pub const NAMESPACE_PLACEHOLDER: &str = "{preview-namespace}"; + +/// Which preview of a rule this is: a branch of the repository, or a fork's pull request. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize, TS)] +#[serde(try_from = "String", into = "String")] +#[ts(type = "string")] +pub enum PreviewKey { + Branch(String), + Fork(u64), +} + +impl PreviewKey { + /// # Errors + /// Returns a [`crate::ValueError`] unless `value` is `branch:` or + /// `fork:`. + pub fn parse(value: &str) -> Result { + let invalid = || { + crate::ValueError::new( + "preview key", + value, + "branch: or fork:", + ) + }; + if let Some(branch) = value.strip_prefix("branch:") { + return (!branch.is_empty()) + .then(|| Self::Branch(branch.to_owned())) + .ok_or_else(invalid); + } + value + .strip_prefix("fork:") + .filter(|number| !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit())) + .and_then(|number| number.parse().ok()) + .map(Self::Fork) + .ok_or_else(invalid) + } +} + +impl fmt::Display for PreviewKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Branch(branch) => write!(formatter, "branch:{branch}"), + Self::Fork(number) => write!(formatter, "fork:{number}"), + } + } +} + +impl TryFrom for PreviewKey { + type Error = crate::ValueError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: PreviewKey) -> Self { + value.to_string() + } +} + +/// The Namespace a preview of `rule` runs in: `pv-`, the first 16 hex digits of the rule +/// ID, `-`, and the first 12 hex digits of SHA-256 of the preview key. The rule ID is +/// random from the approver's client, so Cloud cannot pick one that collides with another +/// rule's previews. +#[must_use] +pub fn preview_namespace(rule: RuleId, key: &PreviewKey) -> Namespace { + let rule = rule.to_string(); + let key: String = Sha256::digest(key.to_string().as_bytes()) + .iter() + .take(6) + .map(|byte| format!("{byte:02x}")) + .collect(); + Namespace::parse(format!("pv-{}-{key}", rule.get(..16).unwrap_or_default())) + .expect("a preview Namespace is a DNS label") +} + +/// Whether `namespace` has the reserved preview form, `pv-<16 hex>-<12 hex>`. Only a +/// creation under the preview rule it derives from may land in one. +#[must_use] +pub fn is_preview_namespace(namespace: &Namespace) -> bool { + let hex = |part: &str, len| { + part.len() == len + && part + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }; + namespace + .as_str() + .strip_prefix("pv-") + .and_then(|rest| rest.split_once('-')) + .is_some_and(|(rule, key)| hex(rule, 16) && hex(key, 12)) +} + +/// A preview's generated domain prefix: `