diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index aeccf5c..476def7 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -758,17 +758,12 @@ impl ApplyService { // a group-owned endpoint by `validate_bundle` via the inherited // targets) and TRIGGER TEMPLATES. The stateless EVENT kinds // (kv/docs/files/pubsub) resolve live via the chain union at - // dispatch; §4.5 M5 the stateful CRON + QUEUE kinds are allowed and - // MATERIALIZED into a per-descendant-app row. Only `email` (a per-app - // sealed inbound secret) is still rejected on a group. + // dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are + // allowed and MATERIALIZED into a per-descendant-app row. An email + // template resolves its inbound secret against the GROUP's own secret + // store once at apply (shared-group-secret model); materialization + // copies the sealed bytes verbatim. for t in &bundle.triggers { - if matches!(t, BundleTrigger::Email { .. }) { - return Err(ApplyError::Invalid( - "a group email trigger template is not yet supported \ - (per-app inbound secret); cron/queue/event kinds are allowed" - .into(), - )); - } // §11.6: a `shared` trigger watches the group's SHARED collection. // Only the collection-scoped kinds can be shared (pubsub has no // shared topic store yet), and the watched collection must be one @@ -1048,13 +1043,16 @@ impl ApplyService { inbound_secret_ref, .. } = bt { - // Email is app-only (a group template rejects it in - // `validate_bundle_for`). Resolve the referenced secret, + // Resolve the referenced secret against the OWNER's store, // decrypt it, and re-seal it into the email trigger's detail - // row — the value never travels in the manifest. - let app_id = owner.app_id().expect("email triggers are app-only"); - let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?; - insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce) + // row — the value never travels in the manifest. §4.5 M5: an + // app trigger resolves the app's secret; a group EMAIL TEMPLATE + // resolves the group's own secret once here, and materialization + // copies the sealed bytes verbatim to each descendant. + let (ct, nonce) = self + .resolve_and_seal(owner.secret_owner(), inbound_secret_ref) + .await?; + insert_email_trigger_tx(&mut *tx, owner.as_script_owner(), sid, actor, &ct, &nonce) .await .map_err(map_trig)?; } else { @@ -1873,15 +1871,18 @@ impl ApplyService { /// Resolve a referenced secret to plaintext, then re-seal it for an /// email trigger's inbound-secret column. The value never appears in - /// the manifest — only the secret's name does. + /// the manifest — only the secret's name does. §4.5 M5: `owner` is the + /// app for an app-owned trigger, or the GROUP for a group email template + /// (which resolves against the group's own secret store once at apply; + /// materialization then copies the sealed bytes to each descendant). async fn resolve_and_seal( &self, - app_id: AppId, + owner: crate::secrets_service::SecretOwner, name: &str, ) -> Result<(Vec, Vec), ApplyError> { let stored = self .secrets - .get(crate::secrets_service::SecretOwner::App(app_id), "*", name) + .get(owner, "*", name) .await .map_err(|e| ApplyError::Backend(e.to_string()))? .ok_or_else(|| { @@ -1890,13 +1891,8 @@ impl ApplyService { (push it with `pic secret set {name}`)" )) })?; - let plaintext = crate::secrets_service::open( - &self.master_key, - crate::secrets_service::SecretOwner::App(app_id), - name, - &stored, - ) - .map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?; + let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored) + .map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?; // The inbound HMAC must be a non-empty string — an empty/whitespace // key is forgeable (preserves the spirit of audit 2026-06-11 H-B2). match plaintext.as_str() { @@ -2897,13 +2893,12 @@ impl ApplyOwner { } } - /// The app id, when this is an app node. Routes/triggers/email-secrets only - /// exist on app nodes, so their reconcile paths call this with an - /// `expect` — guarded by validation that rejects them on a group bundle. - fn app_id(self) -> Option { + /// As a [`SecretOwner`] — for resolving an email trigger's inbound secret + /// against the owner's store (§4.5 M5: an app trigger vs a group template). + fn secret_owner(self) -> crate::secrets_service::SecretOwner { match self { - Self::App(a) => Some(a), - Self::Group(_) => None, + Self::App(a) => crate::secrets_service::SecretOwner::App(a), + Self::Group(g) => crate::secrets_service::SecretOwner::Group(g), } } diff --git a/crates/manager-core/src/materialize.rs b/crates/manager-core/src/materialize.rs index c49128d..eba00bf 100644 --- a/crates/manager-core/src/materialize.rs +++ b/crates/manager-core/src/materialize.rs @@ -6,16 +6,19 @@ //! into an app-owned copy — `materialized_from = template.id` — for every //! descendant app, and removes copies whose (app, template) pair no longer //! inherits. The copy owns its per-app state (cron `last_fired_at`, the queue -//! advisory lock, the email sealed secret), so the unchanged dispatch paths work -//! against it as if it were hand-authored. +//! advisory lock), so the unchanged dispatch paths work against it as if it were +//! hand-authored. An EMAIL copy is a verbatim byte-copy of the group template's +//! sealed inbound secret — the group resolved + sealed it against its own store +//! once at apply (shared-group-secret model), and email secrets are v0/no-AAD so +//! the ciphertext is portable across rows; no master key is needed here. //! //! Full-live like `rebuild_route_table`: called at the same chokepoints (apply, //! app create/delete, group reparent). A precise create/delete diff (not //! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies. //! -//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken, -//! or an email whose descendant lacks the referenced secret) — the caller -//! surfaces them; materialization of the other pairs still proceeds. +//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken) +//! — the caller surfaces them; materialization of the other pairs still +//! proceeds. use std::collections::HashSet; @@ -23,7 +26,7 @@ use sqlx::PgPool; use uuid::Uuid; /// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email). -const MATERIALIZED_KINDS: &[&str] = &["cron", "queue"]; +const MATERIALIZED_KINDS: &[&str] = &["cron", "queue", "email"]; /// Fixed advisory-lock key serializing all materialization reconcilers (§4.5 /// M5). Two reconcilers running concurrently (e.g. two parallel app-creates) @@ -207,6 +210,24 @@ async fn materialize_one( .execute(&mut **tx) .await?; } + "email" => { + // §M5.5: the group template sealed its inbound HMAC secret against + // the GROUP's own store once at apply (shared-group-secret model). + // The secret is v0 (no AAD) — bound only to the master key, not the + // owning row — so a verbatim byte-copy is a valid per-app secret; no + // master key is needed here. Each copy has its own trigger_id (its + // own inbound webhook URL). + sqlx::query( + "INSERT INTO email_trigger_details \ + (trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \ + SELECT $1, inbound_secret_encrypted, inbound_secret_nonce \ + FROM email_trigger_details WHERE trigger_id = $2", + ) + .bind(new_id.0) + .bind(template_id) + .execute(&mut **tx) + .await?; + } _ => {} } Ok(None) diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index eb896aa..a51216c 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -792,25 +792,35 @@ pub(crate) async fn insert_trigger_tx( } /// Insert an email trigger within a transaction. The inbound HMAC secret -/// is sealed by the apply engine (resolved from the app's secret store); +/// is sealed by the apply engine (resolved from the owner's secret store); /// this writes the ciphertext. Parent retry settings match the /// interactive `create_email_trigger` path (async, 3, exponential, 1000). +/// +/// §4.5 M5: `owner` is app-XOR-group. A `ScriptOwner::Group` writes an email +/// trigger TEMPLATE (group_id set, app_id NULL) that is never dispatched +/// directly (`email_inbound_target` filters `app_id IS NOT NULL`) — the +/// materializer copies it into a per-descendant-app row. pub(crate) async fn insert_email_trigger_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - app_id: AppId, + owner: ScriptOwner, script_id: ScriptId, registered_by: AdminUserId, inbound_secret_encrypted: &[u8], inbound_secret_nonce: &[u8], ) -> Result { + let (owner_app_id, owner_group_id) = match owner { + ScriptOwner::App(a) => (Some(a.into_inner()), None), + ScriptOwner::Group(g) => (None, Some(g.into_inner())), + }; let row: (Uuid,) = sqlx::query_as( "INSERT INTO triggers ( \ - app_id, script_id, kind, enabled, dispatch_mode, \ + app_id, group_id, script_id, kind, enabled, dispatch_mode, \ retry_max_attempts, retry_backoff, retry_base_ms, \ registered_by_principal \ - ) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id", + ) VALUES ($1, $2, $3, 'email', TRUE, 'async', 3, 'exponential', 1000, $4) RETURNING id", ) - .bind(app_id.into_inner()) + .bind(owner_app_id) + .bind(owner_group_id) .bind(script_id.into_inner()) .bind(registered_by.into_inner()) .fetch_one(&mut **tx) @@ -1315,12 +1325,16 @@ impl TriggerRepo for PostgresTriggerRepo { trigger_id: TriggerId, ) -> Result, TriggerRepoError> { let row: Option = sqlx::query_as( + // §4.5 M5: `t.app_id IS NOT NULL` excludes a group email TEMPLATE + // row — only its materialized per-app copies are directly invocable + // (mirrors the cron-scheduler / queue-consumer guards). A template + // has no app to run under, so hitting its webhook URL must 404. "SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \ t.registered_by_principal, \ d.inbound_secret_encrypted, d.inbound_secret_nonce \ FROM triggers t \ JOIN email_trigger_details d ON d.trigger_id = t.id \ - WHERE t.id = $1 AND t.kind = 'email'", + WHERE t.id = $1 AND t.kind = 'email' AND t.app_id IS NOT NULL", ) .bind(trigger_id.into_inner()) .fetch_optional(&self.pool) diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 48d3803..5a25106 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -75,19 +75,12 @@ impl Manifest { // ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that // fan out live to descendant apps. As of §11 tail M1 a group may also // declare `[suppress]` to decline a template it inherits from a higher - // ancestor, for its whole subtree. The stateful trigger kinds - // (cron/queue/email) stay app-only — they need per-app state/secrets → - // materialization. - // §4.5 M5: a group may declare STATEFUL trigger templates too — cron and - // queue materialize a per-descendant-app row (email lands with its - // per-app inbound-secret handling). Only the app-specific `email` kind - // is still rejected on a group here. - if m.group.is_some() && !m.triggers.email.is_empty() { - anyhow::bail!( - "a [group] email trigger template is not yet supported \ - (per-app inbound secret); cron/queue/event kinds are allowed" - ); - } + // ancestor, for its whole subtree. + // §4.5 M5: a group may declare STATEFUL trigger templates too — cron, + // queue, and email materialize a per-descendant-app row. An email + // template resolves its `inbound_secret_ref` against the GROUP's own + // secret store once at apply (shared-group-secret model); the server + // rejects it there if the secret is unset. Ok(m) }