Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 188 additions & 49 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions crates/schema-forge-acton/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "schema-forge-acton"
version = "0.34.0"
version = "0.35.0"
edition = "2021"

[dependencies]
Expand All @@ -11,7 +11,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["sync"] }
acton-service = { version = "0.34.1", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs"] }
acton-service = { version = "0.35.0", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "grpc", "tls"] }
schema-forge-dsl = { path = "../schema-forge-dsl" }
schema-forge-surrealdb = { path = "../schema-forge-surrealdb", optional = true }
schema-forge-postgres = { path = "../schema-forge-postgres", optional = true }
Expand All @@ -26,7 +26,7 @@ hex = "0.4.3"
uuid = { version = "1.23.0", features = ["v4", "v7"] }
humantime = "2.3.0"
async-trait = "0.1.89"
tonic = "0.14"
tonic = { version = "0.14", features = ["tls-webpki-roots"] }
prost = "0.14.3"
prost-reflect = "0.16.3"
bytes = "1.11.1"
Expand Down
67 changes: 67 additions & 0 deletions crates/schema-forge-acton/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ impl ActorExtension for ForgeActor {
configure_registry_mutations(actor);
configure_backend_operations(actor);
}

/// Do not restart this actor.
///
/// `configure` registers handlers but sets no state: the registry,
/// backend, tenant config, policy store, storage registry and hook
/// dispatcher all arrive in the single [`InitForge`] message `serve`
/// sends at boot. A restart rebuilds from `configure`, so the
/// replacement would come back with `Default` state — an empty registry
/// and no backend — and nothing would ever send it a second `InitForge`.
///
/// The result would be a process that stays up and answers `404 schema
/// not found` on every entity route. That is fail-closed, but it points
/// an operator at a data problem when the actual fault is an actor that
/// died. Refusing the restart keeps the failure legible: the handle
/// resolves to `None` and routes answer `500` naming the missing actor.
///
/// This also preserves the behaviour of acton-service <= 0.34.1, where
/// the declared policy was never read and no extension could restart at
/// all. Making `ForgeActor` genuinely restartable means giving it a way
/// to re-initialise itself from the backend; until then, `Permanent`
/// would be a promise the actor cannot keep.
fn restart_policy() -> RestartPolicy {
RestartPolicy::Temporary
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -544,3 +568,46 @@ fn configure_backend_operations(actor: &mut ManagedActor<Idle, ForgeActor>) {
})
});
}

#[cfg(test)]
mod tests {
use super::*;

/// Pins the reasoning behind [`ForgeActor::restart_policy`].
///
/// acton-service 0.35.0 made the declared restart policy effective for
/// the first time, so this stopped being a dormant declaration and became
/// live supervision behaviour. `Temporary` is only the right answer for
/// as long as a rebuilt-from-`Default` `ForgeActor` is unusable — the
/// assertions below are that condition, not the policy value. If someone
/// later teaches the actor to re-initialise itself from the backend, this
/// test fails and the policy should be revisited rather than the test
/// relaxed.
#[test]
fn a_restarted_forge_actor_would_be_unusable() {
let rebuilt = ForgeActor::default();
assert!(
rebuilt.backend.is_none(),
"a restart rebuilds from Default, so the backend would be lost"
);
assert!(
rebuilt.registry.is_empty(),
"a restart rebuilds from Default, so every schema would be lost"
);
assert!(
rebuilt.policy_store.is_none(),
"a restart rebuilds from Default, so authz would have no store"
);
assert_eq!(ForgeActor::restart_policy(), RestartPolicy::Temporary);
}

/// The contrasting case: `HookDispatchActor` carries no state, so the
/// default `Permanent` policy is genuinely correct for it.
#[test]
fn hook_dispatch_actor_is_restartable() {
assert_eq!(
crate::hooks::HookDispatchActor::restart_policy(),
RestartPolicy::Permanent
);
}
}
7 changes: 7 additions & 0 deletions crates/schema-forge-acton/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ impl From<crate::hooks::HookError> for ForgeError {
HookError::Protocol { message } => Self::HookUnavailable {
message: format!("protocol error: {message}"),
},
// A misconfigured endpoint is the operator's mistake, not the
// client's, and it is not something a retry can clear. Mapping it
// to `Internal` keeps the endpoint URL out of the API response
// while leaving the full explanation in the logged cause.
e @ HookError::InsecureEndpoint { .. } => Self::Internal {
message: e.to_string(),
},
HookError::Internal { message } => Self::Internal { message },
}
}
Expand Down
143 changes: 143 additions & 0 deletions crates/schema-forge-acton/src/hooks/credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! The bearer credential SchemaForge presents when it calls a hook service.
//!
//! A hook service is a peer that runs under the operator's own supervision,
//! not a user. What it needs to know about an inbound RPC is that the call
//! really came from this forge — not which end user triggered it, which the
//! invocation payload already carries in its `user_id` field. So the
//! credential names the forge itself and is minted fresh per call with a short
//! lifetime, rather than being a long-lived shared secret sitting in config.
//!
//! Minting reuses the same [`PasetoGenerator`] the forge already builds for its
//! login endpoint, which means a hook service authenticates hook calls with the
//! exact `[token]` section it would use for any other acton-service surface.
//! Nothing new has to be distributed: the key material is already shared with
//! anything that validates forge-issued tokens.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use acton_service::auth::tokens::TokenGenerator;
use acton_service::middleware::token::Claims;

use super::HookError;

/// Subject claim on a minted hook credential.
///
/// A hook call is made by the forge process, so the subject names the process
/// rather than the end user whose request triggered it. Keeping the two
/// distinct matters: a hook service that authorized on `sub` would otherwise
/// see every hook call as if the end user had made it directly.
///
/// The `client:` prefix is acton-service's convention for a machine principal
/// (see [`Claims::is_client`]), so a hook service can tell a forge call from a
/// user call without knowing this constant.
pub const HOOK_CREDENTIAL_SUBJECT: &str = "client:schema-forge";

/// Role granted to a minted hook credential, so a hook service can write a
/// Cedar policy or a role check that admits the forge and nothing else.
pub const HOOK_CREDENTIAL_ROLE: &str = "schema-forge-hook-caller";

/// How long a minted hook credential is valid.
///
/// Long enough to cover the whole dispatch including a slow hook (the default
/// hook timeout is 30s), short enough that a token captured from a stalled
/// connection is useless by the time it could be replayed. It is not a session:
/// a fresh one is minted per call, so nothing depends on it outliving the RPC.
pub const HOOK_CREDENTIAL_TTL: Duration = Duration::from_secs(60);

/// Supplies the value of the `authorization` metadata key on a hook call,
/// without the `Bearer ` prefix.
///
/// Separated from the dispatcher so the transport can be tested without token
/// machinery, and so a deployment that authenticates hook calls some other way
/// (an mTLS-only mesh, say) can supply its own.
pub trait HookCredentialSource: Send + Sync + std::fmt::Debug {
/// Produce a credential for one outbound hook call.
///
/// Called once per dispatch rather than cached, so an implementation that
/// mints short-lived tokens never hands out an expired one.
fn bearer(&self) -> Result<String, HookError>;
}

/// A [`HookCredentialSource`] that mints a short-lived PASETO naming the forge.
#[derive(Clone)]
pub struct PasetoHookCredential<G> {
generator: Arc<G>,
}

impl<G> std::fmt::Debug for PasetoHookCredential<G> {
/// Deliberately opaque: the generator holds signing key material, and this
/// type is reachable from `TonicDispatcherConfig`, which is logged.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PasetoHookCredential")
}
}

impl<G: TokenGenerator> PasetoHookCredential<G> {
/// Wrap a token generator. Share the generator the forge already uses for
/// its login endpoint so hook credentials validate against the same key.
pub fn new(generator: Arc<G>) -> Self {
Self { generator }
}
}

/// The claims carried by a hook credential.
///
/// Pure, so the shape of what the forge asserts about itself is testable
/// without a signing key. `exp` is filled in by the generator from the
/// requested lifetime; the zero here is a placeholder it overwrites.
pub fn hook_claims() -> Claims {
Claims {
sub: HOOK_CREDENTIAL_SUBJECT.to_string(),
roles: vec![HOOK_CREDENTIAL_ROLE.to_string()],
perms: vec![],
exp: 0,
iat: None,
jti: None,
iss: None,
aud: None,
email: None,
username: None,
custom: HashMap::new(),
}
}

impl<G: TokenGenerator> HookCredentialSource for PasetoHookCredential<G> {
fn bearer(&self) -> Result<String, HookError> {
self.generator
.generate_token_with_expiry(&hook_claims(), HOOK_CREDENTIAL_TTL)
.map_err(|e| HookError::Internal {
message: format!("failed to mint hook credential: {e}"),
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn claims_name_the_forge_not_the_end_user() {
let claims = hook_claims();
assert_eq!(claims.sub, HOOK_CREDENTIAL_SUBJECT);
assert_eq!(claims.roles, vec![HOOK_CREDENTIAL_ROLE.to_string()]);
// An end user's identity travels in the invocation payload, never in
// the credential — a hook service must not be able to mistake a forge
// call for a direct call by the triggering user.
assert!(claims.email.is_none());
assert!(claims.username.is_none());
// acton-service's machine-principal convention, so a hook service can
// branch on `is_client()` rather than string-matching the subject.
assert!(claims.is_client());
assert!(!claims.is_user());
}

#[test]
fn credential_lifetime_outlives_the_default_hook_timeout() {
// A credential that expired mid-dispatch would fail the slowest hooks
// and only those, which is the hardest kind of failure to diagnose.
let default_timeout = Duration::from_millis(u64::from(super::super::DEFAULT_HOOK_TIMEOUT_MS));
assert!(HOOK_CREDENTIAL_TTL > default_timeout);
}
}
6 changes: 6 additions & 0 deletions crates/schema-forge-acton/src/hooks/dispatch_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ use super::{run_after_hook, HookDispatcher, HookInvocation, HooksConfig};
#[derive(Default, Debug)]
pub struct HookDispatchActor;

// Restart policy is left at the default (`Permanent`), which acton-service
// 0.35.0 made effective for the first time. That is the right policy here
// precisely because this actor is stateless: every input travels with the
// message, so a rebuilt-from-`Default` replacement is indistinguishable from
// the original. Contrast `ForgeActor`, whose whole state arrives in one
// `InitForge` at boot and which therefore opts out.
impl ActorExtension for HookDispatchActor {
fn configure(actor: &mut ManagedActor<Idle, Self>) {
actor.act_on::<DispatchHook>(|_actor, ctx| {
Expand Down
49 changes: 49 additions & 0 deletions crates/schema-forge-acton/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
//!
//! See `docs/hooks-reference.md` for full semantics.

pub mod credential;
pub mod dispatch_actor;
pub mod tonic_dispatcher;
pub use credential::{HookCredentialSource, PasetoHookCredential};
pub use dispatch_actor::{DispatchHook, HookDispatchActor};
pub use tonic_dispatcher::{TonicDispatcherConfig, TonicHookDispatcher};

Expand Down Expand Up @@ -79,6 +81,34 @@ pub struct HooksConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent_async: usize,

/// Permit plaintext (`http://`) hook endpoints. Default: `false`.
///
/// A hook invocation carries the entity's field snapshot and the
/// authenticated user's subject claim, and it carries the bearer
/// credential SchemaForge presents to the hook service. Over cleartext
/// all three are readable, and the credential is replayable, by anything
/// on the path. Endpoints are therefore required to be `https://` unless
/// an operator opts out here, which is reasonable only when the transport
/// is already confidential — loopback, or a sidecar mesh that terminates
/// TLS for the process.
///
/// This defaults to refusing rather than warning because a warning is
/// indistinguishable from working: dispatch would keep succeeding, and
/// nothing would ever force the deployment to be fixed.
#[serde(default)]
pub allow_plaintext: bool,

/// Client certificate this service presents to mutual-TLS hook services,
/// and the trust anchors it verifies them against.
///
/// When absent, `https://` endpoints are verified against the built-in web
/// PKI roots and SchemaForge presents no certificate. Set this when hook
/// services are issued from a private CA, which is the normal case for an
/// internal mesh, and pair it with `[caller_auth]` on the hook service so
/// the certificate is authorized and not merely authenticated.
#[serde(default)]
pub client_identity: Option<acton_service::config::ClientIdentityConfig>,

/// Per-hook bindings. Each entry is a `(schema, event)` pair bound to
/// an endpoint and policy.
#[serde(default)]
Expand All @@ -104,6 +134,8 @@ impl Default for HooksConfig {
enabled: false,
default_timeout_ms: default_timeout_ms(),
max_concurrent_async: default_max_concurrent(),
allow_plaintext: false,
client_identity: None,
bindings: Vec::new(),
}
}
Expand Down Expand Up @@ -205,6 +237,10 @@ pub enum HookError {
Timeout { endpoint: String, timeout_ms: u32 },
/// The endpoint is unreachable or returned a transport error.
Unavailable { endpoint: String, message: String },
/// The endpoint would carry entity data and a bearer credential over a
/// transport that does not protect them. Refused before any connection is
/// opened, so nothing is ever sent in the clear.
InsecureEndpoint { endpoint: String },
/// The hook response could not be decoded or violated the contract.
Protocol { message: String },
/// Internal dispatcher error (configuration mismatch, descriptor drift,
Expand All @@ -223,6 +259,13 @@ impl std::fmt::Display for HookError {
Self::Unavailable { endpoint, message } => {
write!(f, "hook at {endpoint} unavailable: {message}")
}
Self::InsecureEndpoint { endpoint } => write!(
f,
"hook endpoint {endpoint} is not https; refusing to send entity data and a \
bearer credential in the clear. Use an https:// endpoint, or set \
`allow_plaintext = true` under [schema_forge.hooks] if the transport is \
already confidential (loopback or a TLS-terminating sidecar)."
),
Self::Protocol { message } => write!(f, "hook protocol error: {message}"),
Self::Internal { message } => write!(f, "hook dispatcher error: {message}"),
}
Expand Down Expand Up @@ -301,6 +344,12 @@ pub async fn run_before_hook(
}
Ok(Some(outcome))
}
// A refused endpoint is an operator misconfiguration, not the
// transport flakiness `required = false` exists to tolerate. Letting
// it through as a warning would leave the hook silently never running
// — the failure mode that made this check necessary in the first
// place.
Err(e @ HookError::InsecureEndpoint { .. }) => Err(e),
Err(e) if binding.required => Err(e),
Err(e) => {
warn!(
Expand Down
Loading
Loading