From d422bb1ced485fb1ef8bb994b151e36b3bf74c2d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Mon, 3 Aug 2026 16:31:28 -0600 Subject: [PATCH 1/3] fix(hooks): authenticate and encrypt the hook transport The `hooks generate` scaffold emitted a bare `tonic::transport::Server` bound to 0.0.0.0:9090 with no interceptor and no acton-service dependency at all, so every RPC answered whoever could reach the port. The dispatcher matched it: no credential on the request, no TLS on the channel, and `tonic` resolved without any TLS feature, so an `https://` endpoint could not have connected even if one had been configured. A hook invocation carries the entity's full field snapshot and the subject claim of the user whose request triggered it, so this exposed entity data with none of the Cedar policy that guards the equivalent REST surface. `docs/hooks-reference.md` asserted the opposite -- that a hook service "ships with the same observability, resilience, and auth primitives as any other acton-service" -- which left no reason for an operator to go looking. The webhook subsystem already refused plaintext URLs (`validate_url_rejects_http`). Hooks were the outlier. Server: the scaffold now depends on acton-service and serves through `ServiceBuilder` + `GrpcServicesBuilder`, so a `[token]` section applies `GrpcTokenAuthLayer` to every registered service automatically. A starter `config.toml` ships with `[token]` live rather than commented out, plus commented `[tls]`/`[caller_auth]` for mutual-TLS SAN pinning. Reflection stays off: it is auth-exempt, so enabling it would publish the hook message definitions, and therefore the entity field names, to unauthenticated callers. Client: `TonicDispatcherConfig` gains a `HookCredentialSource`. `serve` supplies a `PasetoHookCredential` over the generator it already builds for login, minting a 60s token with subject `client:schema-forge` and role `schema-forge-hook-caller` per call -- so a hook service validates with the same `[token]` section it would use for any other surface, and there is no second key to distribute and no long-lived shared secret in config. Endpoints must now be `https://` unless `allow_plaintext` is set, and `client_identity` supplies a client certificate for mutual-TLS meshes, resolved at startup so a bad key fails the boot rather than the first hooked write. The plaintext check runs at construction as well as at dispatch. At dispatch alone, `required = false` would have downgraded a misconfigured endpoint to a logged warning and let the hook silently never run, which is the failure mode this is meant to close. Verified against a generated scaffold: no token and a garbage token both answer `grpc-status: 16`, `/health` stays `200`. acton-service gains `grpc` + `tls`; `crypto-aws-lc-rs` then supplies `tonic/tls-aws-lc`, and `tls-webpki-roots` supplies the default anchors. --- Cargo.lock | 184 ++++++++++++++--- crates/schema-forge-acton/Cargo.toml | 4 +- crates/schema-forge-acton/src/error.rs | 7 + .../src/hooks/credential.rs | 143 ++++++++++++++ crates/schema-forge-acton/src/hooks/mod.rs | 49 +++++ .../src/hooks/tonic_dispatcher.rs | 185 +++++++++++++++++- .../tests/tonic_dispatcher_integration.rs | 18 ++ crates/schema-forge-cli/src/commands/hooks.rs | 119 ++++++++++- crates/schema-forge-cli/src/commands/serve.rs | 18 +- .../schema-forge-cli/tests/hooks_generate.rs | 65 ++++++ docs/hooks-reference.md | 141 +++++++++++-- skills/schemaforge/hooks-reference.md | 140 +++++++++++-- 12 files changed, 1002 insertions(+), 71 deletions(-) create mode 100644 crates/schema-forge-acton/src/hooks/credential.rs diff --git a/Cargo.lock b/Cargo.lock index ead5c75..cfb2943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,7 @@ checksum = "90f16b067374834c7c2879acc85ff4977b71fa19f5980ff09a9fb9d1c890d0a2" dependencies = [ "acton-reactive", "anyhow", + "arc-swap", "argon2", "async-trait", "axum", @@ -75,17 +76,20 @@ dependencies = [ "futures", "governor", "http 1.4.0", + "hyper-util", "mti", "once_cell", "opentelemetry", "opentelemetry-instrumentation-tower", "opentelemetry-otlp", "opentelemetry_sdk", + "prost", "prost-build", "rand 0.10.0", "regex", "reqwest 0.12.28", "rustls 0.23.40", + "rustls-pki-types", "rusty_paseto", "serde", "serde_json", @@ -94,10 +98,14 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls 0.26.4", + "tokio-stream", "tokio-util", "tonic", "tonic-build", + "tonic-health", + "tonic-prost", "tonic-prost-build", + "tonic-reflection", "tower", "tower-http", "tower-resilience-bulkhead", @@ -109,7 +117,10 @@ dependencies = [ "utoipa", "utoipa-swagger-ui", "uuid", + "webpki-roots 1.0.9", + "x509-parser", "xdg 3.0.0", + "zeroize", ] [[package]] @@ -315,9 +326,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -388,6 +399,45 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "assert_cmd" version = "2.1.2" @@ -1331,7 +1381,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -2413,6 +2463,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der_derive" version = "0.7.3" @@ -2596,7 +2660,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2888,7 +2952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3917,7 +3981,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] @@ -3950,7 +4014,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4485,7 +4549,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "url", - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] @@ -5094,7 +5158,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5277,6 +5341,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "olpc-cjson" version = "0.1.4" @@ -6220,7 +6293,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6258,9 +6331,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6588,7 +6661,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] @@ -7037,6 +7110,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -7047,7 +7129,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7092,9 +7174,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7118,7 +7200,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7560,7 +7642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9053,7 +9135,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9084,7 +9166,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9305,6 +9387,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -9502,6 +9585,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 1.0.9", ] [[package]] @@ -9516,6 +9600,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tonic-health" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ff0636fef47afb3ec02818f5bceb4377b8abb9d6a386aeade18bd6212f8eb7" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-prost" version = "0.14.5" @@ -9543,6 +9640,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf0685a51e6d02b502ba0764002e766b7f3042aed13d9234925b6ffbfa3fca7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tough" version = "0.22.0" @@ -10402,14 +10513,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -10446,7 +10557,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11102,6 +11213,23 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "x509-tsp" version = "0.1.0" @@ -11203,18 +11331,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 65818e6..1a56674 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -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.34.1", 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 } @@ -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" diff --git a/crates/schema-forge-acton/src/error.rs b/crates/schema-forge-acton/src/error.rs index 3cfa6db..99f137f 100644 --- a/crates/schema-forge-acton/src/error.rs +++ b/crates/schema-forge-acton/src/error.rs @@ -199,6 +199,13 @@ impl From 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 }, } } diff --git a/crates/schema-forge-acton/src/hooks/credential.rs b/crates/schema-forge-acton/src/hooks/credential.rs new file mode 100644 index 0000000..d9efa09 --- /dev/null +++ b/crates/schema-forge-acton/src/hooks/credential.rs @@ -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; +} + +/// A [`HookCredentialSource`] that mints a short-lived PASETO naming the forge. +#[derive(Clone)] +pub struct PasetoHookCredential { + generator: Arc, +} + +impl std::fmt::Debug for PasetoHookCredential { + /// 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 PasetoHookCredential { + /// 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) -> 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 HookCredentialSource for PasetoHookCredential { + fn bearer(&self) -> Result { + 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); + } +} diff --git a/crates/schema-forge-acton/src/hooks/mod.rs b/crates/schema-forge-acton/src/hooks/mod.rs index 463ef44..a687540 100644 --- a/crates/schema-forge-acton/src/hooks/mod.rs +++ b/crates/schema-forge-acton/src/hooks/mod.rs @@ -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}; @@ -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, + /// Per-hook bindings. Each entry is a `(schema, event)` pair bound to /// an endpoint and policy. #[serde(default)] @@ -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(), } } @@ -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, @@ -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}"), } @@ -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!( diff --git a/crates/schema-forge-acton/src/hooks/tonic_dispatcher.rs b/crates/schema-forge-acton/src/hooks/tonic_dispatcher.rs index 97b0a57..0593cfe 100644 --- a/crates/schema-forge-acton/src/hooks/tonic_dispatcher.rs +++ b/crates/schema-forge-acton/src/hooks/tonic_dispatcher.rs @@ -25,6 +25,7 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; @@ -38,15 +39,22 @@ use schema_forge_core::types::{DynamicValue, HookEvent}; use tokio::sync::Mutex; use tonic::client::Grpc; use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; -use tonic::transport::{Channel, Endpoint}; +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; use tonic::{Request, Status}; use tracing::{debug, warn}; +use super::credential::HookCredentialSource; use super::{ HookBinding, HookDispatcher, HookError, HookInvocation, HookOutcome, HooksConfig, DEFAULT_HOOK_TIMEOUT_MS, }; +/// gRPC metadata key carrying the bearer credential. +/// +/// acton-service's `GrpcTokenAuthLayer` reads the HTTP `authorization` header, +/// which is where tonic puts this metadata entry. +const AUTHORIZATION_METADATA: &str = "authorization"; + /// Configuration knobs that influence dispatcher construction (timeouts /// for the channel, descriptor loader, etc.). Distinct from /// [`HooksConfig`] which describes per-binding policy. @@ -55,16 +63,46 @@ pub struct TonicDispatcherConfig { /// Connect timeout applied when opening a tonic [`Channel`] to a hook /// endpoint. Defaults to 2 seconds. pub connect_timeout: Duration, + + /// Supplies the bearer credential presented on every hook call. + /// + /// `None` sends hook calls unauthenticated, which a hook service built + /// from the scaffold will reject. It exists for tests and for embedders + /// whose hook services authenticate the forge some other way; the `serve` + /// command always supplies one. + pub credential: Option>, } +impl TonicDispatcherConfig { + /// Default connect timeout: 2 seconds. + const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +} + +// Hand-written rather than derived so `connect_timeout` gets its documented +// default instead of `Duration::ZERO`, which would fail every connection. impl Default for TonicDispatcherConfig { fn default() -> Self { Self { - connect_timeout: Duration::from_secs(2), + connect_timeout: Self::DEFAULT_CONNECT_TIMEOUT, + credential: None, } } } +/// Whether an endpoint may be dialed under the configured plaintext policy. +/// +/// A hook call carries the entity's field snapshot, the triggering user's +/// subject, and the bearer credential the forge presents. All three are exposed +/// on a cleartext hop, and the credential is replayable from it, so plaintext +/// is refused unless an operator has explicitly accepted that. +/// +/// Pure, so the policy is testable without opening a socket — and it runs +/// *before* the connection is opened, so a refusal means nothing left the +/// process. +fn endpoint_is_permitted(endpoint: &str, allow_plaintext: bool) -> bool { + allow_plaintext || endpoint.starts_with("https://") +} + /// Resolved per-binding state cached at construction time. #[derive(Debug)] struct ResolvedBinding { @@ -81,6 +119,13 @@ pub struct TonicHookDispatcher { bindings: HashMap<(String, HookEvent), ResolvedBinding>, /// endpoint URL -> tonic Channel (lazily connected, then cached). channels: Mutex>, + /// TLS settings applied to every `https://` endpoint. Resolved once at + /// construction so a bad certificate or key fails at startup rather than + /// on the first entity write that happens to fire a hook. + tls: ClientTlsConfig, + /// Whether `http://` endpoints may be dialed. See + /// [`HooksConfig::allow_plaintext`]. + allow_plaintext: bool, } impl TonicHookDispatcher { @@ -95,6 +140,18 @@ impl TonicHookDispatcher { let mut bindings: HashMap<(String, HookEvent), ResolvedBinding> = HashMap::new(); for binding in &hooks.bindings { + // Checked here as well as at dispatch. The per-call check is what + // guarantees nothing is sent in the clear; this one is what makes + // the operator find out at boot instead of on the first hooked + // write — and it is not subject to `required = false`, which would + // otherwise downgrade a misconfigured endpoint to a warning and + // let the hook silently never run. + if !endpoint_is_permitted(&binding.endpoint, hooks.allow_plaintext) { + return Err(HookError::InsecureEndpoint { + endpoint: binding.endpoint.clone(), + }); + } + let path = binding .descriptor_path .as_deref() @@ -124,10 +181,38 @@ impl TonicHookDispatcher { bindings.insert((binding.schema.clone(), binding.event), resolved); } + // Resolving the client identity here, rather than at first connect, + // means an unreadable certificate or a key that does not match its + // certificate is a startup failure. Deferred, it would surface as an + // intermittent hook outage on whichever write first triggered a hook. + let tls = match &hooks.client_identity { + Some(identity) if identity.enabled => { + acton_service::client_tls::tonic_client_tls_config(identity).map_err(|e| { + HookError::Internal { + message: format!("invalid hook client identity: {e}"), + } + })? + } + // No client certificate: verify the peer against the built-in web + // PKI roots and present nothing. Appropriate when the hook service + // authenticates the forge by bearer token alone. + _ => ClientTlsConfig::new().with_enabled_roots(), + }; + + if dispatcher.credential.is_none() && !hooks.bindings.is_empty() { + warn!( + "hook dispatch configured with no credential source: calls will carry no \ + `authorization` metadata and any hook service with `[token]` configured will \ + reject them" + ); + } + Ok(Self { config: dispatcher, bindings, channels: Mutex::new(HashMap::new()), + tls, + allow_plaintext: hooks.allow_plaintext, }) } @@ -140,10 +225,28 @@ impl TonicHookDispatcher { if let Some(c) = self.channels.lock().await.get(endpoint) { return Ok(c.clone()); } + // Checked before the cache is populated and before any socket is + // opened, so a refused endpoint never sends a byte. + if !endpoint_is_permitted(endpoint, self.allow_plaintext) { + return Err(HookError::InsecureEndpoint { + endpoint: endpoint.to_string(), + }); + } let ep = Endpoint::from_str(endpoint).map_err(|e| HookError::Internal { message: format!("invalid endpoint {endpoint}: {e}"), })?; let ep = ep.connect_timeout(self.config.connect_timeout); + // `tls_config` on an `http://` endpoint is an error in tonic, so it is + // applied only where it applies. Under `allow_plaintext` the operator + // has already accepted that this hop is unprotected. + let ep = if endpoint.starts_with("https://") { + ep.tls_config(self.tls.clone()) + .map_err(|e| HookError::Internal { + message: format!("failed to apply TLS config for {endpoint}: {e}"), + })? + } else { + ep + }; let channel = ep.connect().await.map_err(|e| HookError::Unavailable { endpoint: endpoint.to_string(), message: e.to_string(), @@ -185,6 +288,16 @@ impl TonicHookDispatcher { let timeout = Duration::from_millis(config_timeout_ms as u64); request.set_timeout(timeout); + // Minted per call rather than cached, so a short-lived credential is + // never presented after it has expired. + if let Some(ref source) = self.config.credential { + let value = format!("Bearer {}", source.bearer()?); + let value = value.parse().map_err(|e| HookError::Internal { + message: format!("minted hook credential is not valid metadata: {e}"), + })?; + request.metadata_mut().insert(AUTHORIZATION_METADATA, value); + } + let call = grpc.unary(request, resolved.path.clone(), codec); let response = match tokio::time::timeout(timeout, call).await { Ok(Ok(resp)) => resp, @@ -656,7 +769,7 @@ mod tests { bindings: vec![HookBinding { schema: "X".into(), event: HookEvent::BeforeChange, - endpoint: "http://x".into(), + endpoint: "https://x".into(), timeout_ms: None, required: false, descriptor_path: None, @@ -666,4 +779,70 @@ mod tests { let err = TonicHookDispatcher::new(&cfg, TonicDispatcherConfig::default()).unwrap_err(); assert!(matches!(err, HookError::Internal { .. })); } + + /// A plaintext binding must fail the boot, not the first hooked write. + /// Under `required = false` a dispatch-time refusal would be logged and + /// swallowed, leaving a hook that silently never runs. + #[test] + fn plaintext_binding_refused_at_construction() { + let cfg = HooksConfig { + enabled: true, + bindings: vec![HookBinding { + schema: "X".into(), + event: HookEvent::BeforeChange, + endpoint: "http://hook:9090".into(), + timeout_ms: None, + required: false, + descriptor_path: Some("/nonexistent".into()), + }], + ..HooksConfig::default() + }; + let err = TonicHookDispatcher::new(&cfg, TonicDispatcherConfig::default()).unwrap_err(); + assert!( + matches!(err, HookError::InsecureEndpoint { .. }), + "expected InsecureEndpoint, got {err:?}" + ); + } + + #[test] + fn plaintext_endpoint_refused_by_default() { + assert!(!endpoint_is_permitted("http://hook:9090", false)); + } + + #[test] + fn tls_endpoint_permitted_without_opt_in() { + assert!(endpoint_is_permitted("https://hook:9090", false)); + } + + #[test] + fn plaintext_endpoint_permitted_once_opted_in() { + assert!(endpoint_is_permitted("http://127.0.0.1:9090", true)); + } + + /// An `https://` prefix must be matched as a scheme, not found anywhere in + /// the string — `http://evil/?next=https://ok` would otherwise pass. + #[test] + fn tls_scheme_is_matched_at_the_start_only() { + assert!(!endpoint_is_permitted( + "http://evil.example/?next=https://hook", + false + )); + } + + /// The refusal has to happen before a socket is opened. This endpoint + /// resolves to nothing, so a `Unavailable` verdict would prove the + /// dispatcher tried to connect before checking. + #[tokio::test] + async fn dispatch_to_plaintext_endpoint_fails_without_connecting() { + let cfg = HooksConfig::default(); + let d = TonicHookDispatcher::new(&cfg, TonicDispatcherConfig::default()).unwrap(); + let err = d + .channel_for("http://198.51.100.1:9090") + .await + .expect_err("plaintext endpoint must be refused"); + assert!( + matches!(err, HookError::InsecureEndpoint { .. }), + "expected InsecureEndpoint, got {err:?}" + ); + } } diff --git a/crates/schema-forge-acton/tests/tonic_dispatcher_integration.rs b/crates/schema-forge-acton/tests/tonic_dispatcher_integration.rs index 779c451..5dbc65c 100644 --- a/crates/schema-forge-acton/tests/tonic_dispatcher_integration.rs +++ b/crates/schema-forge-acton/tests/tonic_dispatcher_integration.rs @@ -184,6 +184,9 @@ async fn before_change_modifies_field() { let (addr, svc, shutdown) = spawn_server(Behavior::Modify).await; let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![binding(addr, HookEvent::BeforeChange)], ..HooksConfig::default() }; @@ -214,6 +217,9 @@ async fn before_change_abort_propagates() { let (addr, _svc, shutdown) = spawn_server(Behavior::Abort("nope".to_string())).await; let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![binding(addr, HookEvent::BeforeChange)], ..HooksConfig::default() }; @@ -232,6 +238,9 @@ async fn after_change_round_trips() { let (addr, svc, shutdown) = spawn_server(Behavior::PassThrough).await; let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![binding(addr, HookEvent::AfterChange)], ..HooksConfig::default() }; @@ -255,6 +264,9 @@ async fn before_change_array_field_dispatches_without_panic() { let (addr, svc, shutdown) = spawn_server(Behavior::PassThrough).await; let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![binding(addr, HookEvent::BeforeChange)], ..HooksConfig::default() }; @@ -309,6 +321,9 @@ async fn before_change_scalar_promoted_to_singleton_list() { let (addr, svc, shutdown) = spawn_server(Behavior::PassThrough).await; let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![binding(addr, HookEvent::BeforeChange)], ..HooksConfig::default() }; @@ -349,6 +364,9 @@ async fn unreachable_endpoint_yields_unavailable() { // Use a port that nothing is listening on (port 1 on loopback). let cfg = HooksConfig { enabled: true, + // The test server is a plaintext tonic listener on loopback, which is + // exactly the case `allow_plaintext` exists for. + allow_plaintext: true, bindings: vec![HookBinding { schema: "Translation".to_string(), event: HookEvent::BeforeChange, diff --git a/crates/schema-forge-cli/src/commands/hooks.rs b/crates/schema-forge-cli/src/commands/hooks.rs index b7ae7ca..6a03b2b 100644 --- a/crates/schema-forge-cli/src/commands/hooks.rs +++ b/crates/schema-forge-cli/src/commands/hooks.rs @@ -229,6 +229,7 @@ fn build_plan(project_name: &str, hooked: &[SchemaHooks]) -> Result = vec![ preserve("Cargo.toml", render_cargo_toml(project_name)), + preserve("config.toml", render_config_toml(project_name)), preserve("build.rs", BUILD_RS.to_string()), preserve("src/main.rs", render_main_rs(hooked)), preserve("src/hooks/mod.rs", render_hooks_mod(hooked)), @@ -313,6 +314,13 @@ fn main() -> Result<(), Box> { } "#; +/// The `acton-service` version the scaffold pins. +/// +/// Kept in step with the workspace's own pin: a hook service validates the +/// credentials this forge mints, so the two need the same token +/// implementation. Bump both together. +const SCAFFOLD_ACTON_SERVICE_VERSION: &str = "0.34.1"; + fn render_cargo_toml(project_name: &str) -> String { format!( r#"[package] @@ -321,12 +329,16 @@ version = "0.1.0" edition = "2021" [dependencies] +# The hook service runs on the same platform layer as the forge that calls it. +# This is what supplies token authentication, mutual-TLS caller authorization, +# tracing, and the health surface — see `src/main.rs`. Do not replace it with a +# bare `tonic::transport::Server`: nothing would authenticate the caller. +acton-service = {{ version = "{SCAFFOLD_ACTON_SERVICE_VERSION}", features = ["grpc", "tls"] }} prost = "0.14" tokio = {{ version = "1", features = ["full"] }} tonic = "0.14" tonic-prost = "0.14" tracing = "0.1" -tracing-subscriber = "0.3" [build-dependencies] tonic-build = "0.14" @@ -387,12 +399,27 @@ fn render_main_rs(hooked: &[SchemaHooks]) -> String { s.push_str("//! Scaffolded once by `schema-forge hooks generate` — edit freely.\n"); s.push_str("//!\n"); s.push_str("//! Subsequent runs are additive: new `@hook`-annotated schemas get\n"); - s.push_str("//! spliced into `mod pb { ... }` and the `Server::builder()` chain\n"); + s.push_str("//! spliced into `mod pb { ... }` and the `GrpcServicesBuilder` chain\n"); s.push_str("//! between the `SCHEMAFORGE_HOOKS_*` marker comments below. Keep\n"); s.push_str("//! those markers in place and your custom module imports, env-var\n"); s.push_str("//! validation, and per-service constructor wiring will survive\n"); s.push_str("//! every regen. Use `--regenerate` to opt out and rewrite this\n"); - s.push_str("//! file from scratch.\n\n"); + s.push_str("//! file from scratch.\n"); + s.push_str("//!\n"); + s.push_str("//! # Who is allowed to call this\n"); + s.push_str("//!\n"); + s.push_str("//! A hook invocation carries a snapshot of the entity's fields and the\n"); + s.push_str("//! subject claim of the user whose request triggered it, so this process\n"); + s.push_str("//! must not answer to anyone who can reach its port. Serving through\n"); + s.push_str("//! `ServiceBuilder` is what prevents that: when `config.toml` has a\n"); + s.push_str("//! `[token]` section, acton-service applies token authentication to\n"); + s.push_str("//! every registered gRPC service automatically, and the forge presents\n"); + s.push_str("//! a short-lived PASETO minted from the same key. Add `[caller_auth]`\n"); + s.push_str("//! on top to require a specific mutual-TLS SAN.\n"); + s.push_str("//!\n"); + s.push_str("//! Replacing this with a bare `tonic::transport::Server` removes all of\n"); + s.push_str("//! that silently — the RPCs keep working, they just stop being\n"); + s.push_str("//! authenticated.\n\n"); s.push_str("mod hooks;\n\n"); s.push_str("mod pb {\n"); s.push_str(PB_BEGIN); @@ -403,13 +430,24 @@ fn render_main_rs(hooked: &[SchemaHooks]) -> String { s.push_str(PB_END); s.push('\n'); s.push_str("}\n\n"); - s.push_str("use tonic::transport::Server;\n\n"); + s.push_str("use acton_service::grpc::server::GrpcServicesBuilder;\n"); + s.push_str("use acton_service::prelude::*;\n\n"); s.push_str("#[tokio::main]\n"); - s.push_str("async fn main() -> Result<(), Box> {\n"); - s.push_str(" tracing_subscriber::fmt::init();\n"); - s.push_str(" let addr = \"0.0.0.0:9090\".parse()?;\n"); - s.push_str(" tracing::info!(\"hook service listening on {addr}\");\n\n"); - s.push_str(" Server::builder()\n"); + s.push_str("async fn main() -> Result<()> {\n"); + s.push_str(" // Reads ./config.toml, then $XDG_CONFIG_HOME and /etc; `ACTON_*`\n"); + s.push_str(" // environment variables override the file. The `[grpc]` section must\n"); + s.push_str(" // set `enabled = true` or the build below is refused rather than\n"); + s.push_str(" // silently serving no RPCs.\n"); + s.push_str(" let config = Config::load()?;\n\n"); + s.push_str(" // The health service probes whatever dependencies the config declares.\n"); + s.push_str(" let state = AppState::builder().config(config.clone()).build().await?;\n\n"); + s.push_str(" // Reflection is deliberately not enabled: it would publish every\n"); + s.push_str(" // hook message definition, and therefore your entity field names, to\n"); + s.push_str(" // unauthenticated callers — reflection and health are exempt from the\n"); + s.push_str(" // token layer. Turn it on with `.with_reflection()` plus\n"); + s.push_str(" // `.add_file_descriptor_set(..)` only where that exposure is fine.\n"); + s.push_str(" let grpc_services = GrpcServicesBuilder::new()\n"); + s.push_str(" .with_health()\n"); s.push_str(SVC_BEGIN); s.push('\n'); for h in hooked { @@ -417,13 +455,74 @@ fn render_main_rs(hooked: &[SchemaHooks]) -> String { } s.push_str(SVC_END); s.push('\n'); - s.push_str(" .serve(addr)\n"); + s.push_str(" .build(Some(state));\n\n"); + s.push_str(" ServiceBuilder::new()\n"); + s.push_str(" .with_config(config)\n"); + s.push_str(" .with_grpc_services(grpc_services)\n"); + s.push_str(" .try_build()?\n"); + s.push_str(" .serve()\n"); s.push_str(" .await?;\n"); s.push_str(" Ok(())\n"); s.push_str("}\n"); s } +/// Starter `config.toml` for the scaffolded hook service. +/// +/// Emitted with `[token]` already present rather than commented out, because a +/// commented-out auth section is the same as no auth section: the service would +/// start, serve, and answer every caller, and nothing in its output would say +/// so. The key path is a placeholder that fails loudly at startup if it has not +/// been pointed at the forge's key. +fn render_config_toml(project_name: &str) -> String { + format!( + r#"# Configuration for the {project_name} hook service. +# +# Loaded by `Config::load()` in src/main.rs from this file, then from +# $XDG_CONFIG_HOME and /etc. `ACTON_*` environment variables override it. + +[service] +name = "{project_name}" +# 0.0.0.0 is safe here only because the surface below is authenticated. +# Narrow it to the interface the forge reaches you on if you can. +bind = "0.0.0.0" +port = 9090 + +[grpc] +enabled = true +# false multiplexes gRPC onto the HTTP port above, which is what the forge +# dials. Set true only if you also want a separate gRPC listener. +use_separate_port = false + +# Token authentication. acton-service applies this to every registered gRPC +# service automatically; removing this section removes that, and the RPCs go +# on working unauthenticated. +# +# `key_path` must be the same PASETO key the forge signs with, since the +# credential on an inbound hook call is minted by the forge. Copy the path +# from the forge's own `[token]` section. +[token] +format = "paseto" +key_path = "/etc/{project_name}/paseto.key" + +# Mutual-TLS caller authorization. Uncomment to require that the caller +# present a certificate whose subjectAltName is on the allowlist, in addition +# to a valid token. A private CA authenticates every workload it has ever +# issued to; this is what narrows that to the forge. +# +# [tls] +# enabled = true +# cert_path = "/etc/{project_name}/tls/server.crt" +# key_path = "/etc/{project_name}/tls/server.key" +# client_ca_path = "/etc/{project_name}/tls/ca.crt" +# +# [caller_auth] +# mode = "mtls" +# allowlist = ["schema-forge.internal"] +"# + ) +} + fn render_hooks_mod(hooked: &[SchemaHooks]) -> String { let mut s = String::new(); s.push_str("//! Per-schema hook service implementations.\n"); diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 7fd6d4f..d80aeba 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -8,7 +8,10 @@ use acton_service::middleware::paseto::PasetoAuth; use acton_service::prelude::ActorHandleInterface; use acton_service::service_builder::ServiceBuilder; use acton_service::versioning::{ApiVersion, VersionedApiBuilder}; -use schema_forge_acton::hooks::{HookDispatcher, TonicDispatcherConfig, TonicHookDispatcher}; +use schema_forge_acton::hooks::{ + HookCredentialSource, HookDispatcher, PasetoHookCredential, TonicDispatcherConfig, + TonicHookDispatcher, +}; use schema_forge_acton::{ DynForgeBackend, ForgeActor, InitForge, InitForgeData, ReplyChannel, SchemaForgeExtension, }; @@ -395,6 +398,13 @@ pub async fn run( entity_store: entity_store.clone(), tenant_config: tenant_config_layer.clone(), }; + // Hook calls are authenticated with a credential minted from this same + // generator, so a hook service validates them with the `[token]` section + // it would use for any other acton-service surface — no second key to + // distribute, and no long-lived shared secret in config. Cloned before the + // generator is moved into the route builder. + let hook_credential: Arc = + Arc::new(PasetoHookCredential::new(paseto_generator.clone())); let routes = build_versioned_routes( login_auth_store, paseto_generator, @@ -475,7 +485,11 @@ pub async fn run( let hooks_cfg = service.config().custom.schema_forge.hooks.clone(); let hook_dispatcher: Option> = if hooks_cfg.enabled && !hooks_cfg.bindings.is_empty() { - match TonicHookDispatcher::new(&hooks_cfg, TonicDispatcherConfig::default()) { + let dispatcher_cfg = TonicDispatcherConfig { + credential: Some(hook_credential), + ..TonicDispatcherConfig::default() + }; + match TonicHookDispatcher::new(&hooks_cfg, dispatcher_cfg) { Ok(d) => { output.status(&format!( " Hook dispatcher initialized with {} binding(s).", diff --git a/crates/schema-forge-cli/tests/hooks_generate.rs b/crates/schema-forge-cli/tests/hooks_generate.rs index 9bcc992..f2e91af 100644 --- a/crates/schema-forge-cli/tests/hooks_generate.rs +++ b/crates/schema-forge-cli/tests/hooks_generate.rs @@ -82,6 +82,71 @@ fn generate_emits_expected_layout() { assert!(before_md.contains("Done when")); } +/// The scaffold must serve through `acton-service`, not a bare tonic server. +/// +/// This is the regression that shipped: the emitted project had no +/// `acton-service` dependency at all, so `[token]` in its config authenticated +/// nothing and every RPC — carrying entity fields and the triggering user's +/// subject — answered whoever could reach the port. The failure is invisible +/// from the outside (the RPCs work either way), so it needs a test rather than +/// a reviewer noticing. +#[test] +fn generate_emits_an_authenticated_service() { + let workdir = TempDir::new().unwrap(); + let schema_dir = workdir.path().join("schemas"); + fs::create_dir_all(&schema_dir).unwrap(); + fs::write(schema_dir.join("translation.schema"), TRANSLATION_SCHEMA).unwrap(); + + let out_dir = workdir.path().join("hooks-service"); + schema_forge() + .args(["hooks", "generate", "--all", "--schema-dir"]) + .arg(&schema_dir) + .arg("--out-dir") + .arg(&out_dir) + .assert() + .success(); + + let cargo_toml = fs::read_to_string(out_dir.join("Cargo.toml")).unwrap(); + assert!( + cargo_toml.contains("acton-service"), + "scaffold must depend on acton-service:\n{cargo_toml}" + ); + + let main_rs = fs::read_to_string(out_dir.join("src/main.rs")).unwrap(); + assert!( + main_rs.contains("ServiceBuilder::new()") && main_rs.contains("with_grpc_services"), + "scaffold must serve through ServiceBuilder — that is what applies the \ + token layer:\n{main_rs}" + ); + assert!( + !main_rs.contains("Server::builder()"), + "a bare tonic Server bypasses every auth layer:\n{main_rs}" + ); + // Reflection is auth-exempt, so enabling it would publish the hook message + // definitions — and therefore the entity field names — unauthenticated. + // Matched as a builder call, not as a substring — the scaffold's comments + // name `.with_reflection()` as the way to opt in. + assert!( + !main_rs + .lines() + .any(|l| l.trim_start().starts_with(".with_reflection()")), + "reflection must stay opt-in:\n{main_rs}" + ); + + // A commented-out `[token]` section authenticates exactly as much as no + // section at all, so the emitted config must carry a live one. + let config_toml = fs::read_to_string(out_dir.join("config.toml")) + .expect("scaffold must emit a config.toml"); + assert!( + config_toml.contains("\n[token]\n") && config_toml.contains("format = \"paseto\""), + "config.toml must configure token auth, uncommented:\n{config_toml}" + ); + assert!( + config_toml.contains("\n[grpc]\n") && config_toml.contains("enabled = true"), + "config.toml must enable gRPC or ServiceBuilder refuses the build:\n{config_toml}" + ); +} + #[test] fn generate_preserves_existing_impl_without_force() { let workdir = TempDir::new().unwrap(); diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index bd0b9a7..4b4a634 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -35,6 +35,7 @@ example before reading anything else, jump to - 3.2 [Project layout](#32-project-layout) - 3.3 [Implement the stubs](#33-implement-the-stubs) - 3.4 [Wire format contract](#34-wire-format-contract) + - 3.5 [Securing the hook transport](#35-securing-the-hook-transport) 4. [Running SchemaForge with Hooks](#4-running-schemaforge-with-hooks) - 4.1 [Configuration](#41-configuration) - 4.2 [Observing dispatch](#42-observing-dispatch) @@ -65,10 +66,14 @@ Three properties keep hooks cheap to adopt and cheap to operate: early-exit on a per-event check, so declaring `@hook(before_change)` on one schema does not slow down reads of that schema or any other. -A hook service is a normal `acton-service` project. It ships with the -same observability, resilience, and auth primitives as any other -`acton-service`, and it runs in your own infrastructure under your own -supervision — SchemaForge never owns the process. +A hook service is a normal `acton-service` project: the scaffold emits a +`main.rs` that serves through `ServiceBuilder`, so the service gets the +same token authentication, mutual-TLS caller authorization, +observability, and resilience layers as any other `acton-service`. It +runs in your own infrastructure under your own supervision — SchemaForge +never owns the process. What it does *not* get for free is a policy: +those layers apply what your `config.toml` configures, so see +[Section 3.5](#35-securing-the-hook-transport) before you deploy one. --- @@ -246,6 +251,7 @@ prompt files are regenerated. ``` hooks-service/ ├── Cargo.toml +├── config.toml # [service] [grpc] [token], see 3.5 ├── build.rs ├── proto/ │ └── translation_hooks.proto # one per annotated schema @@ -341,7 +347,10 @@ Two patterns to note: entity before persistence. Fields you leave at `None` are left untouched; fields you set win over whatever the client submitted. -Compile and run the hook service on its own port: +Compile and run the hook service on its own port. It reads the +`config.toml` the scaffold emitted alongside `Cargo.toml`, so run it +from the project root (or point `ACTON_*` environment variables at the +settings you want): ```console $ cd hooks-service @@ -349,9 +358,28 @@ $ cargo run Compiling hooks-service v0.1.0 Finished dev [unoptimized + debuginfo] target(s) in 12.3s Running `target/debug/hooks-service` -hook service listening on 0.0.0.0:9090 +INFO gRPC health service enabled +INFO Starting hybrid HTTP+gRPC service on 0.0.0.0:9090 ``` +The startup log does not say whether authentication is on — the line +that reports it is emitted at `DEBUG`. Probe the surface instead, which +is the only check that reflects what a caller actually gets: + +```console +$ curl -s -i --http2-prior-knowledge -X POST \ + -H "content-type: application/grpc" --data-binary @/dev/null \ + http://localhost:9090/schema_forge_hooks.translation.TranslationHooks/BeforeChange +HTTP/2 200 +grpc-status: 16 +grpc-message: Authentication%20failed:%20Missing%20Authorization%20header +``` + +`grpc-status: 16` is `UNAUTHENTICATED` and is what you want to see. Any +other status means the `[token]` section is missing or malformed and the +service is answering every caller who can reach the port. `/health` is +deliberately exempt and still answers `200`. + ### 3.4 Wire format contract SchemaForge and your hook service agree on a small, predictable @@ -417,6 +445,91 @@ For detached events (`after_change`, `after_delete`), the response message is empty — the transport round-trip still happens, but its contents are ignored. +### 3.5 Securing the hook transport + +Look again at the request message in 3.4. Every hook call carries a +snapshot of the entity's fields and `user_id`, the subject claim of the +user whose request triggered it. A hook service is therefore not an +internal detail you can leave open: it holds the same data your entity +endpoints do, and it holds it without any of the Cedar policy that +guards them. + +Three controls apply, and they are independent. + +**The hook service authenticates the caller.** The scaffold's `main.rs` +serves through `ServiceBuilder`, and when `config.toml` contains a +`[token]` section acton-service applies token authentication to every +registered gRPC service automatically. SchemaForge presents a PASETO it +mints per call, valid for 60 seconds, with subject +`client:schema-forge` and role `schema-forge-hook-caller`. Point the +hook service's `[token] key_path` at the same key the forge signs with +and there is nothing further to distribute: + +```toml +# hooks-service/config.toml +[token] +format = "paseto" +key_path = "/etc/schema-forge/paseto.key" +``` + +The subject is deliberately the forge, not the end user. A hook service +that authorized on `sub` would otherwise treat every hook call as +though the triggering user had called it directly. Branch on +`Claims::is_client()` if you need to tell the two apart. + +**SchemaForge refuses to send hook payloads in the clear.** Endpoints +must be `https://`. A plaintext hop would expose the entity snapshot and +the user's subject to anything on the path, and would leave the bearer +credential replayable, so dispatch fails before the connection is +opened rather than warning and proceeding: + +``` +hook endpoint http://translation-hook:9090 is not https; refusing to send +entity data and a bearer credential in the clear. +``` + +Set `allow_plaintext = true` under `[schema_forge.hooks]` only where the +transport is already confidential — loopback, or a sidecar that +terminates TLS for the process. + +**Optionally, mutual TLS pins which peer may call.** A token proves the +call came from a forge holding the signing key. If you also want to pin +the network identity, give SchemaForge a client certificate and put +`[caller_auth]` on the hook service: + +```toml +# SchemaForge config.toml +[schema_forge.hooks.client_identity] +cert_path = "/etc/schema-forge/tls/client.crt" +key_path = "/etc/schema-forge/tls/client.key" +root_ca_path = "/etc/schema-forge/tls/ca.crt" +``` + +```toml +# hooks-service/config.toml +[tls] +enabled = true +cert_path = "/etc/hooks-service/tls/server.crt" +key_path = "/etc/hooks-service/tls/server.key" +client_ca_path = "/etc/hooks-service/tls/ca.crt" + +[caller_auth] +mode = "mtls" +allowlist = ["schema-forge.internal"] +``` + +Without the allowlist, a private CA admits every workload it has ever +issued to, so one compromised peer reaches every hook service in the +fleet. `allowlist` narrows that to named `subjectAltName` entries; +matching is byte-exact, with no wildcards. + +**gRPC reflection is off by default in the scaffold.** Reflection and +health are exempt from the token layer, so enabling reflection would +publish every hook message definition — and therefore your entity field +names — to unauthenticated callers. Turn it on with `.with_reflection()` +and `.add_file_descriptor_set(..)` only where that exposure is +acceptable. + --- ## 4. Running SchemaForge with Hooks @@ -439,14 +552,14 @@ max_concurrent_async = 100 [[schema_forge.hooks.bindings]] schema = "Translation" event = "BeforeChange" -endpoint = "http://hooks-service:9090" +endpoint = "https://hooks-service:9090" required = true descriptor_path = "/var/lib/schemaforge/hooks_descriptor.bin" [[schema_forge.hooks.bindings]] schema = "Translation" event = "AfterChange" -endpoint = "http://hooks-service:9090" +endpoint = "https://hooks-service:9090" required = false descriptor_path = "/var/lib/schemaforge/hooks_descriptor.bin" ``` @@ -458,6 +571,8 @@ Top-level fields: | `enabled` | `false` | Global kill-switch. When `false`, all hook annotations are ignored at runtime. Set this to `false` in local dev to run without hook services. | | `default_timeout_ms` | `30000` | Per-call timeout applied to any binding that does not set its own. Bumped from 5s in v0.13 (issue #11) so handlers that walk related rows or call back into SchemaForge have realistic headroom. | | `max_concurrent_async` | `100` | Upper bound on background after-hook dispatches. | +| `allow_plaintext` | `false` | Permit `http://` endpoints. Off by default because a hook payload carries entity fields, the triggering user's subject, and a replayable bearer credential. See [3.5](#35-securing-the-hook-transport). | +| `client_identity` | *unset* | Client certificate SchemaForge presents to mutual-TLS hook services, and the anchors it verifies them against: `cert_path`, `key_path`, optional `root_ca_path` and `exclusive_roots`. Resolved at startup, so a bad key fails the boot rather than the first hooked write. | | `bindings` | `[]` | List of per-(schema, event) bindings. | Per-binding fields: @@ -466,7 +581,7 @@ Per-binding fields: |---|---|---| | `schema` | yes | Schema name, PascalCase, matching the DSL. | | `event` | yes | PascalCase form of the event: `BeforeChange`, `AfterChange`, `BeforeRead`, `AfterRead`, `BeforeDelete`, `AfterDelete`. Note: config uses PascalCase here while the DSL uses `snake_case` (`before_change`). | -| `endpoint` | yes | gRPC endpoint URL, e.g. `http://translation-hooks:9090`. | +| `endpoint` | yes | gRPC endpoint URL, e.g. `https://translation-hooks:9090`. Must be `https://` unless `allow_plaintext` is set. | | `timeout_ms` | no | Per-binding override for `default_timeout_ms`. | | `required` | no (`false`) | If `true`, SchemaForge fails the CRUD request when the hook is unreachable or times out. If `false`, such failures are logged and the operation proceeds. Explicit aborts from the hook always propagate, regardless of `required`. | | `descriptor_path` | yes | Path to the compiled `FileDescriptorSet` binary that the hook scaffold's `build.rs` emits. SchemaForge loads this at startup to learn the typed request/response shape. | @@ -489,9 +604,9 @@ looks like this in the logs (`RUST_LOG=debug`): ``` DEBUG schema_forge_acton::hooks: dispatching before hook schema=Translation event=BeforeChange - endpoint=http://hooks-service:9090 required=true + endpoint=https://hooks-service:9090 required=true DEBUG schema_forge_acton::hooks::tonic_dispatcher: tonic dispatch (before) - schema=Translation event=BeforeChange endpoint=http://hooks-service:9090 + schema=Translation event=BeforeChange endpoint=https://hooks-service:9090 ``` After-hook failures log at `ERROR` and never propagate to the client: @@ -499,8 +614,8 @@ After-hook failures log at `ERROR` and never propagate to the client: ``` ERROR schema_forge_acton::hooks: after hook dispatch failed schema=Translation event=AfterChange - endpoint=http://hooks-service:9090 - error=hook at http://hooks-service:9090 unavailable: connection refused + endpoint=https://hooks-service:9090 + error=hook at https://hooks-service:9090 unavailable: connection refused ``` Startup emits a single line confirming the dispatcher is online: diff --git a/skills/schemaforge/hooks-reference.md b/skills/schemaforge/hooks-reference.md index 15fa5ee..f8b05ab 100644 --- a/skills/schemaforge/hooks-reference.md +++ b/skills/schemaforge/hooks-reference.md @@ -47,6 +47,7 @@ example before reading anything else, jump to - 3.2 [Project layout](#32-project-layout) - 3.3 [Implement the stubs](#33-implement-the-stubs) - 3.4 [Wire format contract](#34-wire-format-contract) + - 3.5 [Securing the hook transport](#35-securing-the-hook-transport) 4. [Running SchemaForge with Hooks](#4-running-schemaforge-with-hooks) - 4.1 [Configuration](#41-configuration) - 4.2 [Observing dispatch](#42-observing-dispatch) @@ -77,10 +78,14 @@ Three properties keep hooks cheap to adopt and cheap to operate: early-exit on a per-event check, so declaring `@hook(before_change)` on one schema does not slow down reads of that schema or any other. -A hook service is a normal `acton-service` project. It ships with the -same observability, resilience, and auth primitives as any other -`acton-service`, and it runs in your own infrastructure under your own -supervision — SchemaForge never owns the process. +A hook service is a normal `acton-service` project: the scaffold emits a +`main.rs` that serves through `ServiceBuilder`, so the service gets the +same token authentication, mutual-TLS caller authorization, +observability, and resilience layers as any other `acton-service`. It +runs in your own infrastructure under your own supervision — SchemaForge +never owns the process. What it does *not* get for free is a policy: +those layers apply what your `config.toml` configures, so see +[Section 3.5](#35-securing-the-hook-transport) before you deploy one. --- @@ -270,6 +275,7 @@ when you want to abandon customizations and start over. ``` hooks-service/ ├── Cargo.toml # Preserve — scaffolded once +├── config.toml # Preserve — [service] [grpc] [token] ├── build.rs # Preserve — scaffolded once ├── proto/ │ └── translation_hooks.proto # Owned — regenerated each run @@ -374,7 +380,10 @@ Two patterns to note: entity before persistence. Fields you leave at `None` are left untouched; fields you set win over whatever the client submitted. -Compile and run the hook service on its own port: +Compile and run the hook service on its own port. It reads the +`config.toml` the scaffold emitted alongside `Cargo.toml`, so run it +from the project root (or point `ACTON_*` environment variables at the +settings you want): ```console $ cd hooks-service @@ -382,9 +391,28 @@ $ cargo run Compiling hooks-service v0.1.0 Finished dev [unoptimized + debuginfo] target(s) in 12.3s Running `target/debug/hooks-service` -hook service listening on 0.0.0.0:9090 +INFO gRPC health service enabled +INFO Starting hybrid HTTP+gRPC service on 0.0.0.0:9090 ``` +The startup log does not say whether authentication is on — the line +that reports it is emitted at `DEBUG`. Probe the surface instead, which +is the only check that reflects what a caller actually gets: + +```console +$ curl -s -i --http2-prior-knowledge -X POST \ + -H "content-type: application/grpc" --data-binary @/dev/null \ + http://localhost:9090/schema_forge_hooks.translation.TranslationHooks/BeforeChange +HTTP/2 200 +grpc-status: 16 +grpc-message: Authentication%20failed:%20Missing%20Authorization%20header +``` + +`grpc-status: 16` is `UNAUTHENTICATED` and is what you want to see. Any +other status means the `[token]` section is missing or malformed and the +service is answering every caller who can reach the port. `/health` is +deliberately exempt and still answers `200`. + ### 3.4 Wire format contract SchemaForge and your hook service agree on a small, predictable @@ -494,6 +522,92 @@ For fire-and-forget events (`after_change`, `after_delete`), the response message is empty — the transport round-trip still happens, but its contents are ignored. + +### 3.5 Securing the hook transport + +Look again at the request message in 3.4. Every hook call carries a +snapshot of the entity's fields and `user_id`, the subject claim of the +user whose request triggered it. A hook service is therefore not an +internal detail you can leave open: it holds the same data your entity +endpoints do, and it holds it without any of the Cedar policy that +guards them. + +Three controls apply, and they are independent. + +**The hook service authenticates the caller.** The scaffold's `main.rs` +serves through `ServiceBuilder`, and when `config.toml` contains a +`[token]` section acton-service applies token authentication to every +registered gRPC service automatically. SchemaForge presents a PASETO it +mints per call, valid for 60 seconds, with subject +`client:schema-forge` and role `schema-forge-hook-caller`. Point the +hook service's `[token] key_path` at the same key the forge signs with +and there is nothing further to distribute: + +```toml +# hooks-service/config.toml +[token] +format = "paseto" +key_path = "/etc/schema-forge/paseto.key" +``` + +The subject is deliberately the forge, not the end user. A hook service +that authorized on `sub` would otherwise treat every hook call as +though the triggering user had called it directly. Branch on +`Claims::is_client()` if you need to tell the two apart. + +**SchemaForge refuses to send hook payloads in the clear.** Endpoints +must be `https://`. A plaintext hop would expose the entity snapshot and +the user's subject to anything on the path, and would leave the bearer +credential replayable, so dispatch fails before the connection is +opened rather than warning and proceeding: + +``` +hook endpoint http://translation-hook:9090 is not https; refusing to send +entity data and a bearer credential in the clear. +``` + +Set `allow_plaintext = true` under `[schema_forge.hooks]` only where the +transport is already confidential — loopback, or a sidecar that +terminates TLS for the process. + +**Optionally, mutual TLS pins which peer may call.** A token proves the +call came from a forge holding the signing key. If you also want to pin +the network identity, give SchemaForge a client certificate and put +`[caller_auth]` on the hook service: + +```toml +# SchemaForge config.toml +[schema_forge.hooks.client_identity] +cert_path = "/etc/schema-forge/tls/client.crt" +key_path = "/etc/schema-forge/tls/client.key" +root_ca_path = "/etc/schema-forge/tls/ca.crt" +``` + +```toml +# hooks-service/config.toml +[tls] +enabled = true +cert_path = "/etc/hooks-service/tls/server.crt" +key_path = "/etc/hooks-service/tls/server.key" +client_ca_path = "/etc/hooks-service/tls/ca.crt" + +[caller_auth] +mode = "mtls" +allowlist = ["schema-forge.internal"] +``` + +Without the allowlist, a private CA admits every workload it has ever +issued to, so one compromised peer reaches every hook service in the +fleet. `allowlist` narrows that to named `subjectAltName` entries; +matching is byte-exact, with no wildcards. + +**gRPC reflection is off by default in the scaffold.** Reflection and +health are exempt from the token layer, so enabling reflection would +publish every hook message definition — and therefore your entity field +names — to unauthenticated callers. Turn it on with `.with_reflection()` +and `.add_file_descriptor_set(..)` only where that exposure is +acceptable. + --- ## 4. Running SchemaForge with Hooks @@ -516,14 +630,14 @@ max_concurrent_async = 100 [[schema_forge.hooks.bindings]] schema = "Translation" event = "BeforeChange" -endpoint = "http://hooks-service:9090" +endpoint = "https://hooks-service:9090" required = true descriptor_path = "/var/lib/schemaforge/hooks_descriptor.bin" [[schema_forge.hooks.bindings]] schema = "Translation" event = "AfterChange" -endpoint = "http://hooks-service:9090" +endpoint = "https://hooks-service:9090" required = false descriptor_path = "/var/lib/schemaforge/hooks_descriptor.bin" ``` @@ -543,7 +657,7 @@ Per-binding fields: |---|---|---| | `schema` | yes | Schema name, PascalCase, matching the DSL. | | `event` | yes | PascalCase form of the event: `BeforeChange`, `AfterChange`, `BeforeRead`, `AfterRead`, `BeforeDelete`, `AfterDelete`. Note: config uses PascalCase here while the DSL uses `snake_case` (`before_change`). | -| `endpoint` | yes | gRPC endpoint URL, e.g. `http://translation-hooks:9090`. | +| `endpoint` | yes | gRPC endpoint URL, e.g. `https://translation-hooks:9090`. Must be `https://` unless `allow_plaintext` is set. | | `timeout_ms` | no | Per-binding override for `default_timeout_ms`. | | `required` | no (`false`) | If `true`, SchemaForge fails the CRUD request when the hook is unreachable or times out. If `false`, such failures are logged and the operation proceeds. Explicit aborts from the hook always propagate, regardless of `required`. | | `descriptor_path` | yes | Path to the compiled `FileDescriptorSet` binary that the hook scaffold's `build.rs` emits. SchemaForge loads this at startup to learn the typed request/response shape. | @@ -566,9 +680,9 @@ looks like this in the logs (`RUST_LOG=debug`): ``` DEBUG schema_forge_acton::hooks: dispatching before hook schema=Translation event=BeforeChange - endpoint=http://hooks-service:9090 required=true + endpoint=https://hooks-service:9090 required=true DEBUG schema_forge_acton::hooks::tonic_dispatcher: tonic dispatch (before) - schema=Translation event=BeforeChange endpoint=http://hooks-service:9090 + schema=Translation event=BeforeChange endpoint=https://hooks-service:9090 ``` After-hook failures log at `ERROR` and never propagate to the client: @@ -576,8 +690,8 @@ After-hook failures log at `ERROR` and never propagate to the client: ``` ERROR schema_forge_acton::hooks: after hook dispatch failed schema=Translation event=AfterChange - endpoint=http://hooks-service:9090 - error=hook at http://hooks-service:9090 unavailable: connection refused + endpoint=https://hooks-service:9090 + error=hook at https://hooks-service:9090 unavailable: connection refused ``` Startup emits a single line confirming the dispatcher is online: From af243d5857bcadbf3aae9586b34ea91d00946b56 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Mon, 3 Aug 2026 22:07:23 -0600 Subject: [PATCH 2/3] chore(deps)!: upgrade acton-service 0.34.1 -> 0.35.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.35.0 bumps acton-reactive 8.1.1 -> 9.0.0. Two things reach this workspace. `AppState::actor` now returns an owned `Option` rather than a borrow, because a restart replaces the actor and a stored handle would silently go stale. Every route handler already resolved the handle per request, which is the pattern that change is designed for, so the migration is mechanical: 18 call sites in `routes/entities.rs` and `routes/schemas.rs` now pass `&forge` to the helpers that borrow it. `ActorExtension::restart_policy` is now actually read. Before 0.35.0 the spawner used the legacy `supervise()`, which never consulted the policy and registered children with no blueprint, so no extension could be restarted at all. The declared policy was inert. That makes the default `Permanent` live for the first time, and the two extensions here want opposite answers: `HookDispatchActor` stays `Permanent`. It is `struct HookDispatchActor;` — every input travels with the `DispatchHook` message, so a replacement rebuilt from `Default` is indistinguishable from the original. This is a real gain: post-commit hook dispatch now survives a handler panic. `ForgeActor` opts out with `Temporary`. Its `configure` registers handlers but sets no state — registry, backend, tenant config, policy store, storage registry and hook dispatcher all arrive in the single `InitForge` that `serve` sends at boot, and nothing would ever send a second one. A restarted `ForgeActor` would come back with an empty registry and no backend, leaving a process that stays up and answers 404 on every entity route. That is fail-closed, but it points an operator at a data problem when the fault is a dead actor. Declining the restart keeps the failure legible and preserves 0.34.1 behaviour, where no restart was possible. Making `ForgeActor` restartable means giving it a way to re-initialise from the backend; that is its own change, not a side effect of a version bump. `a_restarted_forge_actor_would_be_unusable` pins the condition rather than the policy value, so teaching the actor to self-initialise fails the test and prompts revisiting the policy instead of quietly diverging from its rationale. 2166 tests pass, clippy clean with --features surrealdb. --- Cargo.lock | 55 +++++++++------ crates/schema-forge-acton/Cargo.toml | 2 +- crates/schema-forge-acton/src/actor.rs | 67 +++++++++++++++++++ .../src/hooks/dispatch_actor.rs | 6 ++ .../schema-forge-acton/src/routes/entities.rs | 28 ++++---- .../schema-forge-acton/src/routes/schemas.rs | 8 +-- crates/schema-forge-backend/Cargo.toml | 2 +- crates/schema-forge-cli/Cargo.toml | 2 +- 8 files changed, 127 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ead5c75..a49b80d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "acton-macro" -version = "8.1.1" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e38f7952c657ecc04cffdce225eb5771b9d03588372bcc3067e2a8605a763f" +checksum = "2dd183bb8d4ad7ff80ff1340c05b3cb646421b0d67e443613127aaf334900c4e" dependencies = [ "quote", "syn 2.0.114", @@ -32,9 +32,9 @@ dependencies = [ [[package]] name = "acton-reactive" -version = "8.1.1" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a6ba0a28db7345767ff2a3c0246a69ea8098e56052581e78bcee8858f6a410" +checksum = "4d77895b052a39ba98f84425c3169686f05027a0c86901e34527a9c986b374d9" dependencies = [ "acton-ern", "acton-macro", @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "acton-service" -version = "0.34.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f16b067374834c7c2879acc85ff4977b71fa19f5980ff09a9fb9d1c890d0a2" +checksum = "56e2d75ff51006c410a33c199e33bf78dcc789e6008ac05b5a2994e6a1cee97a" dependencies = [ "acton-reactive", "anyhow", @@ -3950,7 +3950,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4481,7 +4481,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls 0.23.40", - "socket2 0.6.2", + "socket2 0.6.5", "tokio", "tokio-rustls 0.26.4", "url", @@ -4904,9 +4904,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -6220,7 +6220,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6258,7 +6258,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -8122,12 +8122,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8968,6 +8968,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -9250,9 +9261,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9260,20 +9271,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.3", ] [[package]] @@ -9493,7 +9504,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.2", + "socket2 0.6.5", "sync_wrapper", "tokio", "tokio-rustls 0.26.4", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 65818e6..a33535d 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -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"] } 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 } diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index 8932300..42b78e1 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -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 + } } // --------------------------------------------------------------------------- @@ -544,3 +568,46 @@ fn configure_backend_operations(actor: &mut ManagedActor) { }) }); } + +#[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 + ); + } +} diff --git a/crates/schema-forge-acton/src/hooks/dispatch_actor.rs b/crates/schema-forge-acton/src/hooks/dispatch_actor.rs index 86d4003..ca2658c 100644 --- a/crates/schema-forge-acton/src/hooks/dispatch_actor.rs +++ b/crates/schema-forge-acton/src/hooks/dispatch_actor.rs @@ -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) { actor.act_on::(|_actor, ctx| { diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index bce4658..393bba5 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -1177,7 +1177,7 @@ async fn execute_entity_query( // relation-display machinery treats derived fields identically to // stored RefArrays. populate_derived_collections( - forge, + &forge, schema_def, &mut visible_entities, claims, @@ -1190,7 +1190,7 @@ async fn execute_entity_query( // relation field is still scrubbed from the envelope alongside its // `__display` sibling below. let display_map = if resolve_relations && !visible_entities.is_empty() { - resolve_relation_displays(forge, schema_def, &visible_entities, claims, &tenant_config) + resolve_relation_displays(&forge, schema_def, &visible_entities, claims, &tenant_config) .await? } else { HashMap::new() @@ -2019,7 +2019,7 @@ pub async fn create_entity( // `related..` is dereferenced to its tenant-scoped related row and // injected as a CEL binding before the pure evaluator runs. check_requires_with_related( - forge, + &forge, &schema_def, &fields, claims.as_ref(), @@ -2035,7 +2035,7 @@ pub async fn create_entity( // (possibly mutated) fields. let hooks_config = state.config().custom.schema_forge.hooks.clone(); let hook_dispatcher = if hooks_config.enabled && schema_def.has_hooks() { - fetch_hook_dispatcher(forge).await + fetch_hook_dispatcher(&forge).await } else { None }; @@ -2183,7 +2183,7 @@ pub async fn list_entities( // before_read hook gate (no entity_id, no fields — list scope). let hooks_config = state.config().custom.schema_forge.hooks.clone(); if hooks_config.enabled && schema_def.hook_for(HookEvent::BeforeRead).is_some() { - if let Some(dispatcher) = fetch_hook_dispatcher(forge).await { + if let Some(dispatcher) = fetch_hook_dispatcher(&forge).await { let mut empty = BTreeMap::new(); apply_read_hook( BeforeHookCtx { @@ -2328,7 +2328,7 @@ pub async fn query_entities( // before_read hook gate (no entity_id, no fields — query scope). let hooks_config = state.config().custom.schema_forge.hooks.clone(); if hooks_config.enabled && schema_def.hook_for(HookEvent::BeforeRead).is_some() { - if let Some(dispatcher) = fetch_hook_dispatcher(forge).await { + if let Some(dispatcher) = fetch_hook_dispatcher(&forge).await { let mut empty = BTreeMap::new(); apply_read_hook( BeforeHookCtx { @@ -2475,7 +2475,7 @@ pub async fn get_entity( && (schema_def.hook_for(HookEvent::BeforeRead).is_some() || schema_def.hook_for(HookEvent::AfterRead).is_some()) { - fetch_hook_dispatcher(forge).await + fetch_hook_dispatcher(&forge).await } else { None }; @@ -2577,7 +2577,7 @@ pub async fn get_entity( // helper handles one or many entities the same way. let mut single = [entity]; populate_derived_collections( - forge, + &forge, &schema_def, &mut single, claims.as_ref(), @@ -2596,7 +2596,7 @@ pub async fn get_entity( if parse_truthy_flag(¶ms, "resolve") { let entities_slice = std::slice::from_ref(&entity); let display_map = resolve_relation_displays( - forge, + &forge, &schema_def, entities_slice, claims.as_ref(), @@ -2738,7 +2738,7 @@ pub async fn update_entity( // CEL @require validation rules (#92) — fail-closed, in-transaction, // pre-persistence. Cross-entity reads (#95) resolved before evaluation. check_requires_with_related( - forge, + &forge, &schema_def, &fields, claims.as_ref(), @@ -2753,7 +2753,7 @@ pub async fn update_entity( // validation, then `before_change` runs on the (possibly mutated) fields. let hooks_config = state.config().custom.schema_forge.hooks.clone(); let hook_dispatcher = if hooks_config.enabled && schema_def.has_hooks() { - fetch_hook_dispatcher(forge).await + fetch_hook_dispatcher(&forge).await } else { None }; @@ -3015,7 +3015,7 @@ pub async fn patch_entity( // predicates that reference unpatched fields still see their current // values. Cross-entity reads (#95) resolved before evaluation. check_requires_with_related( - forge, + &forge, &schema_def, &merged, claims.as_ref(), @@ -3029,7 +3029,7 @@ pub async fn patch_entity( // finalized post-patch state. let hooks_config = state.config().custom.schema_forge.hooks.clone(); let hook_dispatcher = if hooks_config.enabled && schema_def.has_hooks() { - fetch_hook_dispatcher(forge).await + fetch_hook_dispatcher(&forge).await } else { None }; @@ -3251,7 +3251,7 @@ pub async fn delete_entity( // hook is configured so the dispatcher sees the fields being deleted. let hooks_config = state.config().custom.schema_forge.hooks.clone(); let hook_dispatcher = if hooks_config.enabled && schema_def.has_hooks() { - fetch_hook_dispatcher(forge).await + fetch_hook_dispatcher(&forge).await } else { None }; diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 092af6c..1cd3ae9 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -533,14 +533,14 @@ pub async fn create_schema( // 4a. Run the inverse-relation pairing pass across the full registry so // any `-> X[]` field paired with an FK from an existing schema is marked // as derived before the migration plan is generated. - pair_with_registry(forge, &mut definition).await?; + pair_with_registry(&forge, &mut definition).await?; // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB // migration. The actor will recompile and atomically swap on InsertSchema // anyway, but doing the dry-run here means a malformed schema is rejected // with a 400 instead of leaving the database in a state the running // policy bundle can't reason about. - precheck_policy_bundle(&state, forge, &definition, false).await?; + precheck_policy_bundle(&state, &forge, &definition, false).await?; // 5. Generate migration plan let plan = DiffEngine::create_new(&definition); @@ -770,11 +770,11 @@ pub async fn update_schema( // 4a. Run the inverse-relation pairing pass before diffing, so newly // added `-> X[]` fields are classified as derived (and therefore // produce no AddRelation step for a physical column). - pair_with_registry(forge, &mut new_definition).await?; + pair_with_registry(&forge, &mut new_definition).await?; // 4b. Dry-run the Cedar bundle for the proposed registry state so an // invalid schema fails fast — before any DB migration. - precheck_policy_bundle(&state, forge, &new_definition, false).await?; + precheck_policy_bundle(&state, &forge, &new_definition, false).await?; // 5. Compute diff and generate migration plan let plan = DiffEngine::diff(&old_schema, &new_definition); diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index 9a99f7a..9e4ea8b 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -4,7 +4,7 @@ version = "0.13.0" edition = "2021" [dependencies] -acton-service = { version = "0.34.1", default-features = false, features = ["crypto-aws-lc-rs"] } +acton-service = { version = "0.35.0", default-features = false, features = ["crypto-aws-lc-rs"] } argon2 = { version = "0.5", features = ["std"] } async-trait = "0.1.89" chrono = { version = "0.4.44", features = ["serde"] } diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index 7f640c8..7b2a6f8 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -27,7 +27,7 @@ console = "0.15" dialoguer = "0.11" glob = "0.3" axum = { version = "0.8" } -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"] } heck = "0.5.0" minijinja = "2.19.0" tracing = "0.1.44" From 91d19d2ccfc481c2c0cac3a42d59c824bfef110c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 4 Aug 2026 11:09:14 -0600 Subject: [PATCH 3/3] chore(release): bump cli 0.36.0, acton 0.35.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v0.36.0 — authenticated hook transport (PR #125) and the acton-service 0.34.1 -> 0.35.0 upgrade (PR #126). Minor bumps, both crates. `schema-forge-acton` gains public surface (`hooks::credential`, `HooksConfig::{allow_plaintext, client_identity}`, `HookError::InsecureEndpoint`) and changes hook dispatch behaviour: plaintext endpoints are now refused at startup and every hook RPC carries a per-call PASETO bearer. `schema-forge-cli` changes what `hooks generate` emits: the scaffold boots through `ServiceBuilder` with a live `[token]` section instead of a bare tonic server, so a generated hook service authenticates its callers out of the box. --- Cargo.lock | 4 ++-- crates/schema-forge-acton/Cargo.toml | 2 +- crates/schema-forge-cli/Cargo.toml | 2 +- skills/schemaforge/SKILL.md | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e75da10..474ab25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7326,7 +7326,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.34.0" +version = "0.35.0" dependencies = [ "acton-service", "arc-swap", @@ -7411,7 +7411,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.35.0" +version = "0.36.0" dependencies = [ "acton-service", "assert_cmd", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 6f9edb6..39b64c1 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.34.0" +version = "0.35.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index 7b2a6f8..aba278d 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.35.0" +version = "0.36.0" edition = "2021" [[bin]] diff --git a/skills/schemaforge/SKILL.md b/skills/schemaforge/SKILL.md index 0a76e1b..14a4583 100644 --- a/skills/schemaforge/SKILL.md +++ b/skills/schemaforge/SKILL.md @@ -9,7 +9,7 @@ description: Use when writing, creating, editing, or reviewing SchemaForge .sche SchemaForge is an Adaptive Object Model runtime with a human-readable DSL. One `.schema` file produces database tables, REST API endpoints, migrations, Cedar authorization policies, and OpenAPI specs — no recompilation required. -**Version:** 0.35.0 +**Version:** 0.36.0 **Core principle:** Schemas are the single source of truth for the entire entity lifecycle. Authorization is **Cedar-canonical**: every read/write/delete decision flows through the embedded Cedar engine — there are no parallel custom guards. @@ -29,8 +29,8 @@ SchemaForge is an Adaptive Object Model runtime with a human-readable DSL. One ` | `schema-forge-backend` | 0.13.0 | Backend trait abstraction (depends on acton-service); owns the `PLATFORM_ADMIN_ROLE` constant, `EntityAuthStore` (the user-mgmt impl over the system `User` schema), and the typed `BackendError::UniqueViolation` discriminator | | `schema-forge-surrealdb` | 0.9.0 | SurrealDB backend implementation (incl. `DEFINE INDEX ... UNIQUE` codegen, unique-violation reclassification, native `duration`/`bytes` storage, and fail-closed rejection of negative durations) | | `schema-forge-postgres` | 0.8.0 | PostgreSQL backend implementation (via sqlx), incl. JSONB-backed file/map columns, `BIGINT`-nanosecond durations, `BYTEA` bytes with octet-length CHECK, and SQLSTATE 23505 → typed `UniqueViolation` mapping | -| `schema-forge-acton` | 0.34.0 | Axum/acton-service integration: REST API, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store (hot-recompiled atomically on schema apply), auth, hook dispatcher, S3 storage registry (`aws-sdk-s3`), and the 409 `unique_violation` HTTP error envelope | -| `schema-forge-cli` | 0.35.0 | CLI binary (`schemaforge`) built with clap derive; routes all configuration through `acton_service::Config` (single source of truth); ships `policies validate`, `bootstrap-admin`, `entity file upload`/`download` (presigned S3 handshake for `file` fields), and a site generator that surfaces `unique` as an inline form hint plus a 409-routed `setError` for CI / first-run provisioning | +| `schema-forge-acton` | 0.35.0 | Axum/acton-service integration: REST API, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store (hot-recompiled atomically on schema apply), auth, the hook dispatcher (TLS-only endpoints, per-call PASETO bearer minted for `client:schema-forge`), S3 storage registry (`aws-sdk-s3`), and the 409 `unique_violation` HTTP error envelope | +| `schema-forge-cli` | 0.36.0 | CLI binary (`schemaforge`) built with clap derive; routes all configuration through `acton_service::Config` (single source of truth); ships `policies validate`, `bootstrap-admin`, `entity file upload`/`download` (presigned S3 handshake for `file` fields), a `hooks generate` scaffold that boots through `ServiceBuilder` so `[token]` auth is enforced on every hook RPC, and a site generator that surfaces `unique` as an inline form hint plus a 409-routed `setError` for CI / first-run provisioning | ## Before You Build: acton-service Owns the Platform Layer