feat(stateful-templates): materialize group email templates (M5.5)
Close the last M5 gap: a [group] may now declare an email trigger template, materialized into a per-descendant-app row like cron/queue. Uses the shared-group-secret model: the template resolves its inbound_secret_ref against the GROUP's own secret store once at apply and seals it (resolve_and_seal + insert_email_trigger_tx generalized to a SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed blob is portable across rows — materialize copies the bytes verbatim into each descendant (no master key, no reseal, no new schema column, no threading into the CRUD hooks). Each copy has its own trigger_id / inbound webhook URL. - apply_service/manifest: drop the two group-email rejections; a group email template with an unset group secret still fails apply hard. - materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm. - trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a group TEMPLATE row is never directly invocable via its own webhook URL (mirrors the cron/queue app_id guards). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -758,17 +758,12 @@ impl ApplyService {
|
|||||||
// a group-owned endpoint by `validate_bundle` via the inherited
|
// a group-owned endpoint by `validate_bundle` via the inherited
|
||||||
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
|
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
|
||||||
// (kv/docs/files/pubsub) resolve live via the chain union at
|
// (kv/docs/files/pubsub) resolve live via the chain union at
|
||||||
// dispatch; §4.5 M5 the stateful CRON + QUEUE kinds are allowed and
|
// dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are
|
||||||
// MATERIALIZED into a per-descendant-app row. Only `email` (a per-app
|
// allowed and MATERIALIZED into a per-descendant-app row. An email
|
||||||
// sealed inbound secret) is still rejected on a group.
|
// 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 {
|
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.
|
// §11.6: a `shared` trigger watches the group's SHARED collection.
|
||||||
// Only the collection-scoped kinds can be shared (pubsub has no
|
// Only the collection-scoped kinds can be shared (pubsub has no
|
||||||
// shared topic store yet), and the watched collection must be one
|
// shared topic store yet), and the watched collection must be one
|
||||||
@@ -1048,13 +1043,16 @@ impl ApplyService {
|
|||||||
inbound_secret_ref, ..
|
inbound_secret_ref, ..
|
||||||
} = bt
|
} = bt
|
||||||
{
|
{
|
||||||
// Email is app-only (a group template rejects it in
|
// Resolve the referenced secret against the OWNER's store,
|
||||||
// `validate_bundle_for`). Resolve the referenced secret,
|
|
||||||
// decrypt it, and re-seal it into the email trigger's detail
|
// decrypt it, and re-seal it into the email trigger's detail
|
||||||
// row — the value never travels in the manifest.
|
// row — the value never travels in the manifest. §4.5 M5: an
|
||||||
let app_id = owner.app_id().expect("email triggers are app-only");
|
// app trigger resolves the app's secret; a group EMAIL TEMPLATE
|
||||||
let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?;
|
// resolves the group's own secret once here, and materialization
|
||||||
insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce)
|
// 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
|
.await
|
||||||
.map_err(map_trig)?;
|
.map_err(map_trig)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -1873,15 +1871,18 @@ impl ApplyService {
|
|||||||
|
|
||||||
/// Resolve a referenced secret to plaintext, then re-seal it for an
|
/// Resolve a referenced secret to plaintext, then re-seal it for an
|
||||||
/// email trigger's inbound-secret column. The value never appears in
|
/// 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(
|
async fn resolve_and_seal(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: crate::secrets_service::SecretOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
|
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
|
||||||
let stored = self
|
let stored = self
|
||||||
.secrets
|
.secrets
|
||||||
.get(crate::secrets_service::SecretOwner::App(app_id), "*", name)
|
.get(owner, "*", name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -1890,13 +1891,8 @@ impl ApplyService {
|
|||||||
(push it with `pic secret set {name}`)"
|
(push it with `pic secret set {name}`)"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let plaintext = crate::secrets_service::open(
|
let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored)
|
||||||
&self.master_key,
|
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
|
||||||
crate::secrets_service::SecretOwner::App(app_id),
|
|
||||||
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
|
// 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).
|
// key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
|
||||||
match plaintext.as_str() {
|
match plaintext.as_str() {
|
||||||
@@ -2897,13 +2893,12 @@ impl ApplyOwner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app id, when this is an app node. Routes/triggers/email-secrets only
|
/// As a [`SecretOwner`] — for resolving an email trigger's inbound secret
|
||||||
/// exist on app nodes, so their reconcile paths call this with an
|
/// against the owner's store (§4.5 M5: an app trigger vs a group template).
|
||||||
/// `expect` — guarded by validation that rejects them on a group bundle.
|
fn secret_owner(self) -> crate::secrets_service::SecretOwner {
|
||||||
fn app_id(self) -> Option<AppId> {
|
|
||||||
match self {
|
match self {
|
||||||
Self::App(a) => Some(a),
|
Self::App(a) => crate::secrets_service::SecretOwner::App(a),
|
||||||
Self::Group(_) => None,
|
Self::Group(g) => crate::secrets_service::SecretOwner::Group(g),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,19 @@
|
|||||||
//! into an app-owned copy — `materialized_from = template.id` — for every
|
//! into an app-owned copy — `materialized_from = template.id` — for every
|
||||||
//! descendant app, and removes copies whose (app, template) pair no longer
|
//! 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
|
//! 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
|
//! advisory lock), so the unchanged dispatch paths work against it as if it were
|
||||||
//! against it as if it were hand-authored.
|
//! 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,
|
//! Full-live like `rebuild_route_table`: called at the same chokepoints (apply,
|
||||||
//! app create/delete, group reparent). A precise create/delete diff (not
|
//! app create/delete, group reparent). A precise create/delete diff (not
|
||||||
//! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies.
|
//! 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,
|
//! 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
|
//! — the caller surfaces them; materialization of the other pairs still
|
||||||
//! surfaces them; materialization of the other pairs still proceeds.
|
//! proceeds.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ use sqlx::PgPool;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email).
|
/// 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
|
/// Fixed advisory-lock key serializing all materialization reconcilers (§4.5
|
||||||
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
|
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
|
||||||
@@ -207,6 +210,24 @@ async fn materialize_one(
|
|||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.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)
|
Ok(None)
|
||||||
|
|||||||
@@ -792,25 +792,35 @@ pub(crate) async fn insert_trigger_tx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
/// 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
|
/// this writes the ciphertext. Parent retry settings match the
|
||||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
/// 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(
|
pub(crate) async fn insert_email_trigger_tx(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
app_id: AppId,
|
owner: ScriptOwner,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
registered_by: AdminUserId,
|
registered_by: AdminUserId,
|
||||||
inbound_secret_encrypted: &[u8],
|
inbound_secret_encrypted: &[u8],
|
||||||
inbound_secret_nonce: &[u8],
|
inbound_secret_nonce: &[u8],
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
) -> Result<TriggerId, TriggerRepoError> {
|
||||||
|
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(
|
let row: (Uuid,) = sqlx::query_as(
|
||||||
"INSERT INTO triggers ( \
|
"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, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal \
|
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(script_id.into_inner())
|
||||||
.bind(registered_by.into_inner())
|
.bind(registered_by.into_inner())
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
@@ -1315,12 +1325,16 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
trigger_id: TriggerId,
|
trigger_id: TriggerId,
|
||||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
||||||
let row: Option<EmailInboundRow> = sqlx::query_as(
|
let row: Option<EmailInboundRow> = 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, \
|
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
|
||||||
t.registered_by_principal, \
|
t.registered_by_principal, \
|
||||||
d.inbound_secret_encrypted, d.inbound_secret_nonce \
|
d.inbound_secret_encrypted, d.inbound_secret_nonce \
|
||||||
FROM triggers t \
|
FROM triggers t \
|
||||||
JOIN email_trigger_details d ON d.trigger_id = t.id \
|
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())
|
.bind(trigger_id.into_inner())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
|
|||||||
@@ -75,19 +75,12 @@ impl Manifest {
|
|||||||
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
|
// 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
|
// 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
|
// declare `[suppress]` to decline a template it inherits from a higher
|
||||||
// ancestor, for its whole subtree. The stateful trigger kinds
|
// ancestor, for its whole subtree.
|
||||||
// (cron/queue/email) stay app-only — they need per-app state/secrets →
|
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron,
|
||||||
// materialization.
|
// queue, and email materialize a per-descendant-app row. An email
|
||||||
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron and
|
// template resolves its `inbound_secret_ref` against the GROUP's own
|
||||||
// queue materialize a per-descendant-app row (email lands with its
|
// secret store once at apply (shared-group-secret model); the server
|
||||||
// per-app inbound-secret handling). Only the app-specific `email` kind
|
// rejects it there if the secret is unset.
|
||||||
// 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user