From 33386a906a25ace6e1e2b8a311eb362f462d11ef Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 14 Oct 2026 01:44:33 -0700 Subject: [PATCH] feat(auth): add scoped application and webhook signing for Durable Streams --- Cargo.lock | 174 +++--- docs/api/content/tokens/create.mdx | 4 +- docs/api/content/tokens/inspect.mdx | 4 +- docs/api/error-registry.json | 36 ++ engine/artifacts/errors/signing.conflict.json | 5 + .../errors/signing.invalid_input.json | 5 + .../artifacts/errors/signing.not_found.json | 5 + .../errors/signing.payload_too_large.json | 5 + .../artifacts/errors/signing.throttled.json | 5 + .../artifacts/errors/signing.unavailable.json | 5 + engine/artifacts/openapi.json | 556 +++++++++++++++++- engine/packages/api-peer/Cargo.toml | 2 + engine/packages/api-peer/src/auth/mod.rs | 2 + .../api-peer/src/auth/webhook_signing.rs | 150 +++++ engine/packages/api-peer/src/auth/webhooks.rs | 138 +++++ engine/packages/api-peer/src/lib.rs | 1 + engine/packages/api-peer/src/namespaces.rs | 6 + engine/packages/api-peer/src/router.rs | 19 +- .../api-peer/tests/unit/webhook_signing.rs | 18 + .../api-public/src/auth/application_tokens.rs | 98 +++ engine/packages/api-public/src/auth/jwks.rs | 52 ++ engine/packages/api-public/src/auth/mod.rs | 7 + .../packages/api-public/src/auth/signing.rs | 76 +++ .../api-public/src/auth/strict_json.rs | 70 +++ .../api-public/src/auth/webhook_discovery.rs | 84 +++ .../api-public/src/auth/webhook_keys.rs | 156 +++++ .../api-public/src/auth/webhook_signing.rs | 68 +++ engine/packages/api-public/src/ctx.rs | 9 +- engine/packages/api-public/src/datacenters.rs | 8 +- engine/packages/api-public/src/errors.rs | 21 + engine/packages/api-public/src/namespaces.rs | 7 +- engine/packages/api-public/src/router.rs | 45 ++ .../api-public/tests/unit/signing_json.rs | 18 + .../api-types/src/auth/application_tokens.rs | 22 + engine/packages/api-types/src/auth/mod.rs | 2 + engine/packages/api-types/src/auth/tokens.rs | 12 + .../packages/api-types/src/auth/webhooks.rs | 66 +++ engine/packages/auth-jwt/Cargo.toml | 3 + engine/packages/auth-jwt/src/application.rs | 184 ++++++ engine/packages/auth-jwt/src/codec.rs | 17 +- engine/packages/auth-jwt/src/grants.rs | 114 ++-- engine/packages/auth-jwt/src/jwks.rs | 53 ++ .../auth-jwt/src/key_ring_cache/mod.rs | 7 + engine/packages/auth-jwt/src/lib.rs | 6 +- engine/packages/auth-jwt/src/ops/mod.rs | 1 + engine/packages/auth-jwt/src/ops/webhook.rs | 245 ++++++++ engine/packages/auth-jwt/src/webhook.rs | 411 +++++++++++++ engine/packages/auth-jwt/src/workflows/mod.rs | 4 + .../src/workflows/webhook_recovery.rs | 89 +++ .../src/workflows/webhook_rotation.rs | 65 ++ .../auth-jwt/tests/unit/application.rs | 110 ++++ engine/packages/auth-jwt/tests/unit/jwks.rs | 43 ++ .../packages/auth-jwt/tests/unit/webhook.rs | 159 +++++ .../auth-jwt/tests/unit/webhook_recovery.rs | 125 ++++ engine/packages/auth-policy/src/errors.rs | 26 + engine/packages/auth-policy/src/types.rs | 16 + engine/packages/bootstrap/src/lib.rs | 7 + engine/packages/config/src/config/auth.rs | 12 +- .../packages/guard/src/routing/api_public.rs | 35 +- engine/packages/guard/src/routing/mod.rs | 9 +- .../namespace/src/ops/exists_authoritative.rs | 29 + engine/packages/namespace/src/ops/mod.rs | 2 + .../src/ops/signing_recovery_page.rs | 48 ++ .../src/http_stream/handler.rs | 2 +- .../packages/pegboard-gateway3/src/metrics.rs | 2 +- engine/sdks/rust/data/src/lib.rs | 8 +- .../rust/data/src/versioned/auth_grants.rs | 167 +++++- .../src/versioned/auth_webhook_key_ring.rs | 35 ++ engine/sdks/rust/data/src/versioned/mod.rs | 2 + .../sdks/rust/envoy-client/src/actor/http.rs | 4 +- engine/sdks/schemas/data/auth.grants.v2.bare | 44 ++ .../data/auth.webhook_key_ring.v1.bare | 13 + .../rivetkit-core/src/actor/context.rs | 8 +- .../rivetkit-core/src/actor/metrics.rs | 4 +- .../rivetkit-core/src/actor/schedule.rs | 2 +- .../packages/rivetkit-core/src/actor/sleep.rs | 2 +- .../packages/rivetkit-core/src/actor/state.rs | 2 +- 77 files changed, 3904 insertions(+), 172 deletions(-) create mode 100644 engine/artifacts/errors/signing.conflict.json create mode 100644 engine/artifacts/errors/signing.invalid_input.json create mode 100644 engine/artifacts/errors/signing.not_found.json create mode 100644 engine/artifacts/errors/signing.payload_too_large.json create mode 100644 engine/artifacts/errors/signing.throttled.json create mode 100644 engine/artifacts/errors/signing.unavailable.json create mode 100644 engine/packages/api-peer/src/auth/mod.rs create mode 100644 engine/packages/api-peer/src/auth/webhook_signing.rs create mode 100644 engine/packages/api-peer/src/auth/webhooks.rs create mode 100644 engine/packages/api-peer/tests/unit/webhook_signing.rs create mode 100644 engine/packages/api-public/src/auth/application_tokens.rs create mode 100644 engine/packages/api-public/src/auth/jwks.rs create mode 100644 engine/packages/api-public/src/auth/signing.rs create mode 100644 engine/packages/api-public/src/auth/strict_json.rs create mode 100644 engine/packages/api-public/src/auth/webhook_discovery.rs create mode 100644 engine/packages/api-public/src/auth/webhook_keys.rs create mode 100644 engine/packages/api-public/src/auth/webhook_signing.rs create mode 100644 engine/packages/api-public/tests/unit/signing_json.rs create mode 100644 engine/packages/api-types/src/auth/application_tokens.rs create mode 100644 engine/packages/api-types/src/auth/webhooks.rs create mode 100644 engine/packages/auth-jwt/src/application.rs create mode 100644 engine/packages/auth-jwt/src/jwks.rs create mode 100644 engine/packages/auth-jwt/src/ops/webhook.rs create mode 100644 engine/packages/auth-jwt/src/webhook.rs create mode 100644 engine/packages/auth-jwt/src/workflows/webhook_recovery.rs create mode 100644 engine/packages/auth-jwt/src/workflows/webhook_rotation.rs create mode 100644 engine/packages/auth-jwt/tests/unit/application.rs create mode 100644 engine/packages/auth-jwt/tests/unit/jwks.rs create mode 100644 engine/packages/auth-jwt/tests/unit/webhook.rs create mode 100644 engine/packages/auth-jwt/tests/unit/webhook_recovery.rs create mode 100644 engine/packages/namespace/src/ops/exists_authoritative.rs create mode 100644 engine/packages/namespace/src/ops/signing_recovery_page.rs create mode 100644 engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs create mode 100644 engine/sdks/schemas/data/auth.grants.v2.bare create mode 100644 engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare diff --git a/Cargo.lock b/Cargo.lock index 65dc1f5f05..664412ea2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1320,7 +1320,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "datacenter" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -1379,7 +1379,7 @@ dependencies = [ [[package]] name = "depot" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-channel", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "depot-client-embedded" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -1702,7 +1702,7 @@ checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" [[package]] name = "epoxy" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "epoxy-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "gasoline" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-stream", @@ -2151,7 +2151,7 @@ dependencies = [ [[package]] name = "gasoline-macros" -version = "2.3.19" +version = "2.3.20" dependencies = [ "proc-macro2", "quote", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "gasoline-runtime" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "epoxy", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "namespace" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "epoxy", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "pegboard" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "base64 0.22.1", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "pegboard-envoy" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "pegboard-gateway" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "pegboard-gateway2" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -4167,7 +4167,7 @@ dependencies = [ [[package]] name = "pegboard-gateway3" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "pegboard-outbound" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "epoxy", @@ -4230,7 +4230,7 @@ dependencies = [ [[package]] name = "pegboard-runner" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -5102,7 +5102,7 @@ dependencies = [ [[package]] name = "rivet-actor-runtime-socket-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -5114,7 +5114,7 @@ dependencies = [ [[package]] name = "rivet-api-builder" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -5157,7 +5157,7 @@ dependencies = [ [[package]] name = "rivet-api-peer" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -5174,6 +5174,8 @@ dependencies = [ "rivet-api-builder", "rivet-api-types", "rivet-api-util", + "rivet-auth-jwt", + "rivet-auth-policy", "rivet-config", "rivet-error", "rivet-pools", @@ -5193,7 +5195,7 @@ dependencies = [ [[package]] name = "rivet-api-public" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -5233,7 +5235,7 @@ dependencies = [ [[package]] name = "rivet-api-public-openapi-gen" -version = "2.3.19" +version = "2.3.20" dependencies = [ "rivet-api-public", "serde_json", @@ -5242,7 +5244,7 @@ dependencies = [ [[package]] name = "rivet-api-types" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -5258,7 +5260,7 @@ dependencies = [ [[package]] name = "rivet-api-util" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -5316,7 +5318,7 @@ dependencies = [ [[package]] name = "rivet-auth" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -5332,7 +5334,7 @@ dependencies = [ [[package]] name = "rivet-auth-jwt" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "base64 0.22.1", @@ -5344,6 +5346,7 @@ dependencies = [ "hex", "jsonwebtoken", "lazy_static", + "namespace", "rand 0.8.5", "rivet-auth-policy", "rivet-config", @@ -5359,13 +5362,14 @@ dependencies = [ "tokio", "tracing", "universaldb", + "url", "vbare", "zeroize", ] [[package]] name = "rivet-auth-policy" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "rivet-error", @@ -5376,7 +5380,7 @@ dependencies = [ [[package]] name = "rivet-bootstrap" -version = "2.3.19" +version = "2.3.20" dependencies = [ "datacenter", "depot", @@ -5399,7 +5403,7 @@ dependencies = [ [[package]] name = "rivet-build-meta" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "epoxy-protocol", @@ -5413,7 +5417,7 @@ dependencies = [ [[package]] name = "rivet-cache" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -5439,7 +5443,7 @@ dependencies = [ [[package]] name = "rivet-cache-purge" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -5456,14 +5460,14 @@ dependencies = [ [[package]] name = "rivet-cache-result" -version = "2.3.19" +version = "2.3.20" dependencies = [ "rivet-util", ] [[package]] name = "rivet-cli" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anstyle", "anyhow", @@ -5484,7 +5488,7 @@ dependencies = [ [[package]] name = "rivet-config" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "chrono", @@ -5507,7 +5511,7 @@ dependencies = [ [[package]] name = "rivet-config-schema-gen" -version = "2.3.19" +version = "2.3.20" dependencies = [ "rivet-config", "schemars 0.8.22", @@ -5516,7 +5520,7 @@ dependencies = [ [[package]] name = "rivet-data" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -5530,7 +5534,7 @@ dependencies = [ [[package]] name = "rivet-depot-client" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -5559,11 +5563,11 @@ dependencies = [ [[package]] name = "rivet-depot-client-types" -version = "2.3.19" +version = "2.3.20" [[package]] name = "rivet-depot-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -5574,7 +5578,7 @@ dependencies = [ [[package]] name = "rivet-dynamic-config" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -5592,7 +5596,7 @@ dependencies = [ [[package]] name = "rivet-engine" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -5675,7 +5679,7 @@ dependencies = [ [[package]] name = "rivet-env" -version = "2.3.19" +version = "2.3.20" dependencies = [ "lazy_static", "uuid", @@ -5683,7 +5687,7 @@ dependencies = [ [[package]] name = "rivet-envoy-client" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -5715,7 +5719,7 @@ dependencies = [ [[package]] name = "rivet-envoy-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "hex", @@ -5731,7 +5735,7 @@ dependencies = [ [[package]] name = "rivet-error" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "indoc", @@ -5743,7 +5747,7 @@ dependencies = [ [[package]] name = "rivet-error-macros" -version = "2.3.19" +version = "2.3.20" dependencies = [ "indoc", "proc-macro2", @@ -5754,7 +5758,7 @@ dependencies = [ [[package]] name = "rivet-guard" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -5819,7 +5823,7 @@ dependencies = [ [[package]] name = "rivet-guard-core" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -5870,7 +5874,7 @@ dependencies = [ [[package]] name = "rivet-logs" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "chrono", @@ -5884,7 +5888,7 @@ dependencies = [ [[package]] name = "rivet-metrics" -version = "2.3.19" +version = "2.3.20" dependencies = [ "lazy_static", "prometheus", @@ -5892,7 +5896,7 @@ dependencies = [ [[package]] name = "rivet-metrics-server" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "console-subscriber", @@ -5910,7 +5914,7 @@ dependencies = [ [[package]] name = "rivet-outbound-guard" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "ipnet", @@ -5924,7 +5928,7 @@ dependencies = [ [[package]] name = "rivet-perf" -version = "2.3.19" +version = "2.3.20" dependencies = [ "prometheus", "tokio", @@ -5934,7 +5938,7 @@ dependencies = [ [[package]] name = "rivet-pools" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "clickhouse", @@ -5967,7 +5971,7 @@ dependencies = [ [[package]] name = "rivet-postgres-util" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "rustls", @@ -5978,7 +5982,7 @@ dependencies = [ [[package]] name = "rivet-profiling" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -5993,7 +5997,7 @@ dependencies = [ [[package]] name = "rivet-runner-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -6011,7 +6015,7 @@ dependencies = [ [[package]] name = "rivet-runtime" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "console-subscriber", @@ -6040,7 +6044,7 @@ dependencies = [ [[package]] name = "rivet-service-manager" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "chrono", @@ -6057,7 +6061,7 @@ dependencies = [ [[package]] name = "rivet-telemetry" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "rivet-config", @@ -6067,7 +6071,7 @@ dependencies = [ [[package]] name = "rivet-term" -version = "2.3.19" +version = "2.3.20" dependencies = [ "console", "derive_builder 0.12.0", @@ -6080,7 +6084,7 @@ dependencies = [ [[package]] name = "rivet-test-deps" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -6098,7 +6102,7 @@ dependencies = [ [[package]] name = "rivet-test-deps-docker" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "portpicker", @@ -6115,7 +6119,7 @@ dependencies = [ [[package]] name = "rivet-test-envoy" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-stream", @@ -6131,7 +6135,7 @@ dependencies = [ [[package]] name = "rivet-tracing-utils" -version = "2.3.19" +version = "2.3.20" dependencies = [ "futures-util", "lazy_static", @@ -6141,7 +6145,7 @@ dependencies = [ [[package]] name = "rivet-types" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "gasoline", @@ -6158,7 +6162,7 @@ dependencies = [ [[package]] name = "rivet-universaldb-commit" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -6170,7 +6174,7 @@ dependencies = [ [[package]] name = "rivet-ups-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "base64 0.22.1", @@ -6183,7 +6187,7 @@ dependencies = [ [[package]] name = "rivet-util" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -6211,7 +6215,7 @@ dependencies = [ [[package]] name = "rivet-util-id" -version = "2.3.19" +version = "2.3.20" dependencies = [ "serde", "thiserror 1.0.69", @@ -6222,7 +6226,7 @@ dependencies = [ [[package]] name = "rivet-util-serde" -version = "2.3.19" +version = "2.3.20" dependencies = [ "indexmap 2.14.0", "serde", @@ -6232,7 +6236,7 @@ dependencies = [ [[package]] name = "rivet-version-management" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "futures-util", @@ -6252,7 +6256,7 @@ dependencies = [ [[package]] name = "rivet-workflow-worker" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "datacenter", @@ -6269,7 +6273,7 @@ dependencies = [ [[package]] name = "rivetkit" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -6298,7 +6302,7 @@ dependencies = [ [[package]] name = "rivetkit-actor-persist" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -6310,7 +6314,7 @@ dependencies = [ [[package]] name = "rivetkit-client" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "axum 0.8.4", @@ -6344,7 +6348,7 @@ dependencies = [ [[package]] name = "rivetkit-client-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -6356,7 +6360,7 @@ dependencies = [ [[package]] name = "rivetkit-core" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -6419,7 +6423,7 @@ dependencies = [ [[package]] name = "rivetkit-engine-process" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "libc", @@ -6436,7 +6440,7 @@ dependencies = [ [[package]] name = "rivetkit-inspector-protocol" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "serde", @@ -6448,7 +6452,7 @@ dependencies = [ [[package]] name = "rivetkit-napi" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -6474,7 +6478,7 @@ dependencies = [ [[package]] name = "rivetkit-shared-types" -version = "2.3.19" +version = "2.3.20" dependencies = [ "serde", "serde_json", @@ -6482,7 +6486,7 @@ dependencies = [ [[package]] name = "rivetkit-wasm" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "console_error_panic_hook", @@ -7666,7 +7670,7 @@ dependencies = [ [[package]] name = "test-snapshot-gen" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -8449,7 +8453,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "universaldb" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", @@ -8487,7 +8491,7 @@ dependencies = [ [[package]] name = "universalpubsub" -version = "2.3.19" +version = "2.3.20" dependencies = [ "anyhow", "async-trait", diff --git a/docs/api/content/tokens/create.mdx b/docs/api/content/tokens/create.mdx index d64a11c8d2..3b513383ce 100644 --- a/docs/api/content/tokens/create.mdx +++ b/docs/api/content/tokens/create.mdx @@ -53,8 +53,8 @@ Content type: `application/json`. | --- | --- | --- | --- | | `duration` | `integer \| null` | No | Lifetime in seconds. Omit for the default lifetime. | | `grants` | `object[]` | Yes | Permissions the token carries. | -| `grants[].operations` | `("read" \| "update" \| "list" \| "create" \| "delete")[]` | Yes | Operations the token may perform on the target. | -| `grants[].resource` | `"namespace" \| "actor" \| "runner" \| "runner_config" \| "datacenter" \| "actor_gateway" \| "actor_kv"` | Yes | Resource type the grant applies to. | +| `grants[].operations` | `("read" \| "update" \| "list" \| "create" \| "delete" \| "append" \| "close")[]` | Yes | Operations the token may perform on the target. | +| `grants[].resource` | `"namespace" \| "actor" \| "runner" \| "runner_config" \| "datacenter" \| "actor_gateway" \| "actor_kv" \| "actor_stream" \| "durable_stream" \| "application_token" \| "webhook_signing"` | Yes | Resource type the grant applies to. | | `grants[].target` | `"any" \| object` | Yes | `"any"` for every resource of this type, or `{ "id": "..." }` for one resource. | | `namespace` | `string` | Yes | Namespace the token is valid in. | | `subject` | `string \| null` | No | Free-form subject to attach to the token, such as a user ID. | diff --git a/docs/api/content/tokens/inspect.mdx b/docs/api/content/tokens/inspect.mdx index 680be5e760..97b74b21f0 100644 --- a/docs/api/content/tokens/inspect.mdx +++ b/docs/api/content/tokens/inspect.mdx @@ -34,8 +34,8 @@ Send your namespace token as a bearer token in the `Authorization` header. Every | --- | --- | --- | | `expires_ts` | `integer` | When the token expires, in Unix milliseconds. | | `grants` | `object[]` | Permissions the token carries. | -| `grants[].operations` | `("read" \| "update" \| "list" \| "create" \| "delete")[]` | Operations the token may perform on the target. | -| `grants[].resource` | `"namespace" \| "actor" \| "runner" \| "runner_config" \| "datacenter" \| "actor_gateway" \| "actor_kv"` | Resource type the grant applies to. | +| `grants[].operations` | `("read" \| "update" \| "list" \| "create" \| "delete" \| "append" \| "close")[]` | Operations the token may perform on the target. | +| `grants[].resource` | `"namespace" \| "actor" \| "runner" \| "runner_config" \| "datacenter" \| "actor_gateway" \| "actor_kv" \| "actor_stream" \| "durable_stream" \| "application_token" \| "webhook_signing"` | Resource type the grant applies to. | | `grants[].target` | `"any" \| object` | `"any"` for every resource of this type, or `{ "id": "..." }` for one resource. | | `issued_ts` | `integer` | When the token was issued, in Unix milliseconds. | | `namespace_id` | `string` | ID of the namespace the token is scoped to. | diff --git a/docs/api/error-registry.json b/docs/api/error-registry.json index 632e5b5a67..569fd2913f 100644 --- a/docs/api/error-registry.json +++ b/docs/api/error-registry.json @@ -1478,6 +1478,42 @@ } ] }, + { + "name": "signing", + "description": null, + "errors": [ + { + "code": "conflict", + "message": "The signing key generation or request ID conflicts with current state.", + "statuses": [] + }, + { + "code": "invalid_input", + "message": "Invalid signing request.", + "statuses": [] + }, + { + "code": "not_found", + "message": "The namespace signing keys have not been provisioned.", + "statuses": [] + }, + { + "code": "payload_too_large", + "message": "The signing payload or token exceeds its size limit.", + "statuses": [] + }, + { + "code": "throttled", + "message": "The namespace signing quota or service capacity is exhausted.", + "statuses": [] + }, + { + "code": "unavailable", + "message": "The signing service is temporarily unavailable.", + "statuses": [] + } + ] + }, { "name": "sqlite", "description": null, diff --git a/engine/artifacts/errors/signing.conflict.json b/engine/artifacts/errors/signing.conflict.json new file mode 100644 index 0000000000..85f154a70e --- /dev/null +++ b/engine/artifacts/errors/signing.conflict.json @@ -0,0 +1,5 @@ +{ + "code": "conflict", + "group": "signing", + "message": "The signing key generation or request ID conflicts with current state." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.invalid_input.json b/engine/artifacts/errors/signing.invalid_input.json new file mode 100644 index 0000000000..e43eb5e129 --- /dev/null +++ b/engine/artifacts/errors/signing.invalid_input.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_input", + "group": "signing", + "message": "Invalid signing request." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.not_found.json b/engine/artifacts/errors/signing.not_found.json new file mode 100644 index 0000000000..1651e1c638 --- /dev/null +++ b/engine/artifacts/errors/signing.not_found.json @@ -0,0 +1,5 @@ +{ + "code": "not_found", + "group": "signing", + "message": "The namespace signing keys have not been provisioned." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.payload_too_large.json b/engine/artifacts/errors/signing.payload_too_large.json new file mode 100644 index 0000000000..fe17b2d639 --- /dev/null +++ b/engine/artifacts/errors/signing.payload_too_large.json @@ -0,0 +1,5 @@ +{ + "code": "payload_too_large", + "group": "signing", + "message": "The signing payload or token exceeds its size limit." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.throttled.json b/engine/artifacts/errors/signing.throttled.json new file mode 100644 index 0000000000..679cc7ee60 --- /dev/null +++ b/engine/artifacts/errors/signing.throttled.json @@ -0,0 +1,5 @@ +{ + "code": "throttled", + "group": "signing", + "message": "The namespace signing quota or service capacity is exhausted." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.unavailable.json b/engine/artifacts/errors/signing.unavailable.json new file mode 100644 index 0000000000..be8c0df7a5 --- /dev/null +++ b/engine/artifacts/errors/signing.unavailable.json @@ -0,0 +1,5 @@ +{ + "code": "unavailable", + "group": "signing", + "message": "The signing service is temporarily unavailable." +} \ No newline at end of file diff --git a/engine/artifacts/openapi.json b/engine/artifacts/openapi.json index aae3b626fe..7099824f4d 100644 --- a/engine/artifacts/openapi.json +++ b/engine/artifacts/openapi.json @@ -11,9 +11,31 @@ "name": "Apache-2.0", "identifier": "Apache-2.0" }, - "version": "2.3.19" + "version": "2.3.20" }, "paths": { + "/.well-known/jwks.json": { + "get": { + "tags": [ + "auth::jwks" + ], + "summary": "Publishes only the public halves of Rivet's currently valid signing keys.", + "operationId": "auth_jwks_get", + "responses": { + "200": { + "description": "Public JSON Web Key Set", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + {} + ] + } + }, "/actors": { "get": { "tags": [ @@ -458,6 +480,41 @@ ] } }, + "/auth/application-tokens": { + "post": { + "tags": [ + "auth::application_tokens" + ], + "operationId": "auth_application_tokens_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthApplicationTokenCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthApplicationTokenCreateResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/auth/tokens": { "post": { "tags": [ @@ -518,6 +575,71 @@ ] } }, + "/auth/webhooks/ensure": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhooks_ensure", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookMetadata" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/auth/webhooks/sign": { + "post": { + "tags": [ + "auth::webhook_signing" + ], + "operationId": "auth_webhooks_sign", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookSignResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/datacenters": { "get": { "tags": [ @@ -765,6 +887,198 @@ ] } }, + "/namespaces/{namespace}/auth/webhook-keys": { + "get": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_get", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhook-keys/revoke": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_revoke", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookRevokeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhook-keys/rotate": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_rotate", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookRotateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhooks": { + "get": { + "tags": [ + "auth::webhook_discovery" + ], + "operationId": "auth_webhooks_metadata", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Immutable namespace ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookMetadata" + } + } + } + } + }, + "security": [] + } + }, + "/namespaces/{namespace}/auth/webhooks/jwks.json": { + "get": { + "tags": [ + "auth::webhook_discovery" + ], + "operationId": "auth_webhooks_jwks", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Immutable namespace ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/jwk-set+json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookJwks" + } + } + } + } + }, + "security": [] + } + }, "/runner-configs": { "get": { "tags": [ @@ -1475,6 +1789,65 @@ "type": "object", "additionalProperties": false }, + "AuthApplicationTokenCreateRequest": { + "type": "object", + "required": [ + "audience", + "purpose", + "claims" + ], + "properties": { + "audience": { + "type": "string" + }, + "claims": { + "type": "object", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "duration": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "purpose": { + "type": "string" + }, + "subject": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "AuthApplicationTokenCreateResponse": { + "type": "object", + "required": [ + "token", + "issued_ts", + "expires_ts" + ], + "properties": { + "expires_ts": { + "type": "integer", + "format": "int64" + }, + "issued_ts": { + "type": "integer", + "format": "int64" + }, + "token": { + "type": "string" + } + } + }, "AuthOperationKind": { "type": "string", "enum": [ @@ -1482,7 +1855,9 @@ "update", "list", "create", - "delete" + "delete", + "append", + "close" ] }, "AuthTokenCreateRequest": { @@ -1607,7 +1982,11 @@ "runner_config", "datacenter", "actor_gateway", - "actor_kv" + "actor_kv", + "actor_stream", + "durable_stream", + "application_token", + "webhook_signing" ] }, "AuthTokenTargetScope": { @@ -1631,6 +2010,177 @@ } ] }, + "AuthWebhookJwks": { + "type": "object", + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthWebhookPublicKey" + } + } + } + }, + "AuthWebhookKeyStatus": { + "type": "object", + "required": [ + "generation", + "active_kid" + ], + "properties": { + "active_kid": { + "type": "string" + }, + "generation": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "last_emergency_generation": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "last_emergency_request_id": { + "type": [ + "string", + "null" + ] + }, + "pending_kid": { + "type": [ + "string", + "null" + ] + } + } + }, + "AuthWebhookMetadata": { + "type": "object", + "required": [ + "namespace_id", + "alg", + "kid", + "jwks_url", + "generation" + ], + "properties": { + "alg": { + "type": "string" + }, + "generation": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "jwks_url": { + "type": "string" + }, + "kid": { + "type": "string" + }, + "namespace_id": { + "$ref": "#/components/schemas/RivetId" + } + } + }, + "AuthWebhookPublicKey": { + "type": "object", + "required": [ + "kty", + "crv", + "kid", + "use", + "alg", + "x" + ], + "properties": { + "alg": { + "type": "string" + }, + "crv": { + "type": "string" + }, + "kid": { + "type": "string" + }, + "kty": { + "type": "string" + }, + "use": { + "type": "string" + }, + "x": { + "type": "string" + } + } + }, + "AuthWebhookRevokeRequest": { + "type": "object", + "required": [ + "request_id", + "expected_generation", + "kids" + ], + "properties": { + "expected_generation": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "kids": { + "type": "array", + "items": { + "type": "string" + } + }, + "request_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "AuthWebhookRotateRequest": { + "type": "object", + "required": [ + "expected_generation" + ], + "properties": { + "expected_generation": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "AuthWebhookSignResponse": { + "type": "object", + "required": [ + "timestamp", + "kid", + "signature" + ], + "properties": { + "kid": { + "type": "string" + }, + "signature": { + "type": "string", + "description": "Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body." + }, + "timestamp": { + "type": "string", + "description": "Canonical decimal Unix seconds, generated by the control plane." + } + } + }, "CrashPolicy": { "type": "string", "enum": [ diff --git a/engine/packages/api-peer/Cargo.toml b/engine/packages/api-peer/Cargo.toml index ef809eb7b4..745ce8998e 100644 --- a/engine/packages/api-peer/Cargo.toml +++ b/engine/packages/api-peer/Cargo.toml @@ -21,6 +21,8 @@ pegboard.workspace = true rivet-api-builder.workspace = true rivet-api-types.workspace = true rivet-api-util.workspace = true +rivet-auth-jwt = { workspace = true, features = ["service"] } +rivet-auth-policy.workspace = true rivet-config.workspace = true rivet-error.workspace = true rivet-pools.workspace = true diff --git a/engine/packages/api-peer/src/auth/mod.rs b/engine/packages/api-peer/src/auth/mod.rs new file mode 100644 index 0000000000..b973b20bf1 --- /dev/null +++ b/engine/packages/api-peer/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod webhook_signing; +pub mod webhooks; diff --git a/engine/packages/api-peer/src/auth/webhook_signing.rs b/engine/packages/api-peer/src/auth/webhook_signing.rs new file mode 100644 index 0000000000..1ff8975a74 --- /dev/null +++ b/engine/packages/api-peer/src/auth/webhook_signing.rs @@ -0,0 +1,150 @@ +//! Exact-byte internal transport. No private keys or payloads enter workflow history. +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use futures_util::StreamExt; +use gas::prelude::*; +use rivet_api_builder::{ + ApiCtx, ApiError, + extract::{Extension, Json, Path}, +}; +use rivet_api_types::auth::webhooks::SignResponse; +use rivet_auth_jwt::{ops::webhook as ops, webhook}; +use rivet_auth_policy::errors::Signing; +use std::sync::Arc; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +// Reservations include both the upload and the contiguous Ed25519 signing message. +// Holding the permit in the blocking task also bounds cancelled/in-flight signing work. +static BUFFER_BUDGET: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(Semaphore::new(128))); +pub const DEADLINE_HEADER: &str = "x-rivet-signing-deadline-ms"; + +pub async fn read_body(request: Request) -> Result<(Vec, OwnedSemaphorePermit)> { + if request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + != Some("application/octet-stream") + || request + .headers() + .get(axum::http::header::CONTENT_ENCODING) + .is_some_and(|h| h != "identity") + { + return Err(Signing::InvalidInput.build()); + } + let permit = BUFFER_BUDGET + .clone() + .try_acquire_many_owned(64) + .map_err(|_| Signing::Throttled.build())?; + let body = tokio::time::timeout(std::time::Duration::from_secs(30), async move { + let mut stream = request.into_body().into_data_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| Signing::InvalidInput.build())?; + if chunk.len() > webhook::MAX_PAYLOAD_BYTES.saturating_sub(bytes.len()) { + return Err(Signing::PayloadTooLarge.build()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + }) + .await + .map_err(|_| Signing::Unavailable.build())??; + Ok((body, permit)) +} + +#[tracing::instrument(skip_all)] +pub async fn sign( + Extension(ctx): Extension, + Path((namespace_id,)): Path<(Id,)>, + request: Request, +) -> Response { + let result = async { + let deadline = request + .headers() + .get(DEADLINE_HEADER) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.parse::().ok()) + .ok_or_else(|| Signing::InvalidInput.build())?; + let (body, permit) = read_body(request).await?; + sign_body(&ctx, namespace_id, deadline, body, permit).await + } + .await; + let mut response = match result { + Ok(body) => Json(body).into_response(), + Err(error) => ApiError::from(error).into_response(), + }; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub async fn sign_body( + ctx: &StandaloneCtx, + namespace_id: Id, + deadline: i64, + body: Vec, + permit: OwnedSemaphorePermit, +) -> Result { + if !ctx.config().is_leader() || !ctx.config().auth_required()?.jwt.issuance_enabled() { + return Err(Signing::Unavailable.build()); + } + if !ctx + .op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + { + return Err(Signing::NotFound.build()); + } + let snapshot = ctx + .op(ops::get::Input { namespace_id }) + .await + .map_err(super::webhooks::map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + let config_generation = ctx.config().auth_required()?.jwt.issuer_generation(); + if snapshot.ring.keys.leader_datacenter_id != ctx.config().dc_label() + || snapshot.ring.keys.leader_config_generation != config_generation + { + return Err(Signing::Unavailable.build()); + } + let read_started = snapshot.read_started; + let response = tokio::task::spawn_blocking(move || { + let _permit = permit; + let now = rivet_util::timestamp::now(); + if now >= deadline || read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + let response = + webhook::sign(&snapshot.ring, &body, now).map_err(|_| Signing::Unavailable.build())?; + let after = rivet_util::timestamp::now(); + if after < now + || after >= deadline + || after >= snapshot.ring.keys.active.sign_until_ts + || read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE + { + return Err(Signing::Unavailable.build()); + } + Ok(SignResponse { + timestamp: response.timestamp, + kid: response.kid, + signature: response.signature, + }) + }) + .await + .map_err(|_| Signing::Unavailable.build())??; + // Scheduling pauses cannot extend the lease before sending the reply. + if read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE + || rivet_util::timestamp::now() >= deadline + { + return Err(Signing::Unavailable.build()); + } + Ok(response) +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_signing.rs"] +mod tests; diff --git a/engine/packages/api-peer/src/auth/webhooks.rs b/engine/packages/api-peer/src/auth/webhooks.rs new file mode 100644 index 0000000000..97ddd808d2 --- /dev/null +++ b/engine/packages/api-peer/src/auth/webhooks.rs @@ -0,0 +1,138 @@ +//! Internal leader-local lifecycle API. Public handlers authenticate and authorize first. +use anyhow::Result; +use gas::prelude::*; +use rivet_api_builder::ApiCtx; +use rivet_api_types::auth::webhooks::KeyStatus; +use rivet_auth_jwt::{ + ops::webhook::{self, Change}, + webhook::{Error, Ring}, +}; +use rivet_auth_policy::errors::Signing; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MutationRequest { + pub namespace_id: Id, + pub change: Change, +} + +#[tracing::instrument(skip_all)] +pub async fn mutate( + ctx: ApiCtx, + _path: (), + _query: (), + request: MutationRequest, +) -> Result { + if matches!(request.change, Change::Ensure) { + webhook::ensure_workflow(&ctx, request.namespace_id) + .await + .map_err(map_error)?; + } + let ring = ctx + .op(webhook::mutate::Input { + namespace_id: request.namespace_id, + change: request.change, + }) + .await + .map_err(map_error)?; + Ok(status(&ring)) +} + +#[tracing::instrument(skip_all)] +pub async fn get(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + let snapshot = ctx + .op(webhook::get::Input { namespace_id }) + .await + .map_err(map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + Ok(status(&snapshot.ring)) +} + +pub fn status(ring: &Ring) -> KeyStatus { + KeyStatus { + generation: ring.keys.generation, + active_kid: rivet_auth_jwt::webhook::public_kid(&ring.keys.active.key.public_key), + pending_kid: ring + .keys + .pending + .as_ref() + .map(|key| rivet_auth_jwt::webhook::public_kid(&key.key.public_key)), + last_emergency_request_id: ring + .last_emergency + .as_ref() + .map(|r| Uuid::from_bytes(r.request_id).to_string()), + last_emergency_generation: ring.last_emergency.as_ref().map(|r| r.committed_generation), + } +} + +pub fn map_error(error: anyhow::Error) -> anyhow::Error { + match error.downcast_ref::() { + Some(Error::InvalidInput) => Signing::InvalidInput.build(), + Some(Error::NotFound) => Signing::NotFound.build(), + Some(Error::Conflict) => Signing::Conflict.build(), + _ => Signing::Unavailable.build(), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Discovery { + pub jwks: rivet_api_types::auth::webhooks::Jwks, + pub metadata: Option, +} + +#[tracing::instrument(skip_all)] +pub async fn discovery(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + if !ctx + .op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + { + return Err(Signing::NotFound.build()); + } + let snapshot = ctx + .op(webhook::get::Input { namespace_id }) + .await + .map_err(map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + let ring = &snapshot.ring; + let now = rivet_util::timestamp::now(); + let current = now >= ring.keys.active.activated_ts + && now < ring.keys.active.sign_until_ts + && ring.keys.leader_datacenter_id == ctx.config().dc_label() + && ring.keys.leader_config_generation + == ctx.config().auth_required()?.jwt.issuer_generation(); + let base = &ctx.config().leader_dc()?.public_url; + let metadata = if current { + Some(rivet_api_types::auth::webhooks::Metadata { + namespace_id, + alg: "ed25519".into(), + kid: rivet_auth_jwt::webhook::public_kid(&ring.keys.active.key.public_key), + jwks_url: base + .join(&format!( + "namespaces/{namespace_id}/auth/webhooks/jwks.json" + ))? + .to_string(), + generation: ring.keys.generation, + }) + } else { + None + }; + let keys = ring + .public_keys(now) + .into_iter() + .map(|key| rivet_api_types::auth::webhooks::PublicKey { + kty: key.kty, + crv: key.crv, + kid: key.kid, + public_key_use: key.public_key_use, + alg: key.alg, + x: key.x, + }) + .collect(); + if snapshot.read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + Ok(Discovery { + jwks: rivet_api_types::auth::webhooks::Jwks { keys }, + metadata, + }) +} diff --git a/engine/packages/api-peer/src/lib.rs b/engine/packages/api-peer/src/lib.rs index 9fc1427869..310616e322 100644 --- a/engine/packages/api-peer/src/lib.rs +++ b/engine/packages/api-peer/src/lib.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use anyhow::*; pub mod actors; +pub mod auth; pub mod depot_inspect; pub mod envoys; pub mod internal; diff --git a/engine/packages/api-peer/src/namespaces.rs b/engine/packages/api-peer/src/namespaces.rs index 9753446de7..d06c1ba543 100644 --- a/engine/packages/api-peer/src/namespaces.rs +++ b/engine/packages/api-peer/src/namespaces.rs @@ -6,6 +6,12 @@ use rivet_util::Id; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +#[tracing::instrument(skip_all)] +pub async fn exists_authoritative(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + ctx.op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await +} + #[tracing::instrument(skip_all)] pub async fn list(ctx: ApiCtx, _path: (), query: ListQuery) -> Result { let namespace_ids = [ diff --git a/engine/packages/api-peer/src/router.rs b/engine/packages/api-peer/src/router.rs index ccf9d99d6c..897183760b 100644 --- a/engine/packages/api-peer/src/router.rs +++ b/engine/packages/api-peer/src/router.rs @@ -1,6 +1,6 @@ use rivet_api_builder::{create_router, prelude::*}; -use crate::{actors, depot_inspect, envoys, internal, namespaces, runner_configs, runners}; +use crate::{actors, auth, depot_inspect, envoys, internal, namespaces, runner_configs, runners}; #[tracing::instrument(level = "debug", skip_all)] pub async fn router( @@ -10,8 +10,25 @@ pub async fn router( create_router("api-peer", config, pools, |mut router| { router = epoxy::http_routes::mount_routes(router); router + .route("/auth/webhooks/mutate", post(auth::webhooks::mutate)) + .route( + "/auth/webhooks/{namespace_id}/status", + get(auth::webhooks::get), + ) + .route( + "/auth/webhooks/{namespace_id}/discovery", + get(auth::webhooks::discovery), + ) + .route( + "/auth/webhooks/{namespace_id}/sign", + axum::routing::post(auth::webhook_signing::sign), + ) // MARK: Namespaces .route("/namespaces", get(namespaces::list)) + .route( + "/namespaces/{namespace_id}/exists-authoritative", + get(namespaces::exists_authoritative), + ) .route("/namespaces", post(namespaces::create)) // MARK: Runner configs .route("/runner-configs", get(runner_configs::list)) diff --git a/engine/packages/api-peer/tests/unit/webhook_signing.rs b/engine/packages/api-peer/tests/unit/webhook_signing.rs new file mode 100644 index 0000000000..5ca30f0b45 --- /dev/null +++ b/engine/packages/api-peer/tests/unit/webhook_signing.rs @@ -0,0 +1,18 @@ +use super::*; +#[tokio::test] +async fn raw_body_is_preserved_and_content_encoding_is_rejected() { + let body = vec![0, 255, b'\r', b'\n', b'{', b'}']; + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from(body.clone())) + .unwrap(); + let (actual, permit) = read_body(request).await.unwrap(); + assert_eq!(actual, body); + drop(permit); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .header("content-encoding", "gzip") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(read_body(request).await.is_err()); +} diff --git a/engine/packages/api-public/src/auth/application_tokens.rs b/engine/packages/api-public/src/auth/application_tokens.rs new file mode 100644 index 0000000000..bd65d0e7ac --- /dev/null +++ b/engine/packages/api-public/src/auth/application_tokens.rs @@ -0,0 +1,98 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::{ + ApiError, + extract::{Extension, Json}, +}; +use rivet_api_types::auth::application_tokens::{CreateRequest, CreateResponse}; +use rivet_auth::ResourceKind; +use rivet_auth_jwt::{TokenError, application}; + +#[utoipa::path( + post, operation_id = "auth_application_tokens_create", path = "/auth/application-tokens", + request_body(content = CreateRequest, content_type = "application/json"), + responses((status = 200, body = CreateResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn create(Extension(ctx): Extension, request: Request) -> Response { + signing::no_store(match create_inner(&ctx, request).await { + Ok(response) => Json(response).into_response(), + Err(error) => ApiError::from(error).into_response(), + }) +} + +async fn create_inner(ctx: &ApiCtx, request: Request) -> Result { + let (namespace_id, _) = signing::credential(ctx, ResourceKind::ApplicationToken).await?; + if request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(str::trim) + != Some("application/json") + || request + .headers() + .contains_key(axum::http::header::CONTENT_ENCODING) + { + return Err(Signing::InvalidInput.build()); + } + let bytes = tokio::time::timeout( + std::time::Duration::from_secs(30), + axum::body::to_bytes(request.into_body(), 16 * 1024), + ) + .await + .map_err(|_| Signing::Unavailable.build())? + .map_err(|_| Signing::PayloadTooLarge.build())?; + let body: CreateRequest = super::strict_json::parse(&bytes)?; + let jwt = &ctx.config().auth_required()?.jwt; + application::validate_application_data( + &body.audience, + &body.purpose, + body.subject.as_deref(), + &body.claims, + jwt.audience(), + ) + .map_err(|_| Signing::InvalidInput.build())?; + let duration = body.duration.unwrap_or(jwt.default_duration().as_secs()); + if duration == 0 || duration > jwt.max_duration().as_secs() { + return Err(Signing::InvalidInput.build()); + } + let (current_namespace, deadline) = + signing::credential(ctx, ResourceKind::ApplicationToken).await?; + if current_namespace != namespace_id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let expires_no_later_than_ts = + signing::expiration(rivet_util::timestamp::now(), duration, deadline)?; + let issued = ctx + .jwt_key_ring_cache()? + .issue_application( + &application::service::IssueRequest { + namespace_id, + audience: body.audience, + purpose: body.purpose, + subject: body.subject, + app: body.claims, + expires_no_later_than_ts, + }, + jwt.max_duration().as_secs(), + ) + .await + .map_err(|err| { + if err.downcast_ref::() == Some(&TokenError::TokenTooLarge) { + Signing::PayloadTooLarge.build() + } else { + Signing::Unavailable.build() + } + })?; + Ok(CreateResponse { + token: issued.token, + issued_ts: issued.issued_ts, + expires_ts: issued.expires_ts, + }) +} diff --git a/engine/packages/api-public/src/auth/jwks.rs b/engine/packages/api-public/src/auth/jwks.rs new file mode 100644 index 0000000000..f030e3891d --- /dev/null +++ b/engine/packages/api-public/src/auth/jwks.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use axum::{ + http::header::{CACHE_CONTROL, CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::{ApiError, extract::Extension}; + +use crate::ctx::ApiCtx; + +/// Publishes only the public halves of Rivet's currently valid signing keys. +#[utoipa::path( + get, + operation_id = "auth_jwks_get", + path = "/.well-known/jwks.json", + responses((status = 200, description = "Public JSON Web Key Set", body = serde_json::Value)), + security(()) +)] +#[tracing::instrument(skip_all)] +pub async fn get(Extension(ctx): Extension) -> Response { + match get_inner(ctx).await { + Ok((json, max_age)) => ( + [ + (CONTENT_TYPE, "application/jwk-set+json".to_owned()), + (CACHE_CONTROL, format!("public, max-age={max_age}")), + ], + json, + ) + .into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn get_inner(ctx: ApiCtx) -> Result<(String, u64)> { + ctx.skip_auth(); + let snapshot = ctx + .jwt_key_ring_cache() + .map_err(|_| rivet_auth::errors::Auth::VerificationUnavailable.build())? + .public_snapshot() + .await + .map_err(rivet_auth_jwt::key_ring_cache::VerificationFailure::into_error)?; + let now_ms = rivet_util::timestamp::now(); + let jwks = rivet_auth_jwt::jwks::JwkSet::from_verification_keys(&snapshot.keys, now_ms); + let jwt = &ctx.config().auth_required()?.jwt; + // External verifiers learn the pending key before it becomes active. Keep public caches + // shorter than that publication lead, and bounded after an emergency key revocation. + let max_age = jwt + .verifier_cache_ttl() + .as_secs() + .min(jwt.key_publish_lead().as_secs().saturating_sub(1)) + .min(60); + Ok((serde_json::to_string(&jwks)?, max_age)) +} diff --git a/engine/packages/api-public/src/auth/mod.rs b/engine/packages/api-public/src/auth/mod.rs index 5c76635567..811daf4a05 100644 --- a/engine/packages/api-public/src/auth/mod.rs +++ b/engine/packages/api-public/src/auth/mod.rs @@ -1 +1,8 @@ +pub mod application_tokens; +pub mod jwks; +mod signing; +mod strict_json; pub mod tokens; +pub mod webhook_discovery; +pub mod webhook_keys; +pub mod webhook_signing; diff --git a/engine/packages/api-public/src/auth/signing.rs b/engine/packages/api-public/src/auth/signing.rs new file mode 100644 index 0000000000..a941bbc227 --- /dev/null +++ b/engine/packages/api-public/src/auth/signing.rs @@ -0,0 +1,76 @@ +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use rivet_auth::{AccessNamespaceScope, OperationKind, ResourceKind, TargetScope}; +use rivet_util::Id; + +/// Signing is available only to namespace-scoped service JWTs with an explicit grant. +pub async fn credential(ctx: &ApiCtx, resource: ResourceKind) -> Result<(Id, i64)> { + if !ctx.config().auth_required()?.jwt.issuance_enabled() { + ctx.skip_auth(); + return Err(Signing::Unavailable.build()); + } + let authenticated = ctx.authenticate().await?; + let jwt = authenticated.require_jwt()?; + let namespace_id = jwt.verified().token.namespace_id; + let expires_ts = i64::try_from(jwt.verified().token.claims.exp) + .ok() + .and_then(|seconds| seconds.checked_mul(1000)) + .ok_or_else(|| Signing::InvalidInput.build())?; + if expires_ts <= rivet_util::timestamp::now() { + return Err(rivet_auth::errors::Auth::TokenExpired.build()); + } + ctx.auth( + AccessNamespaceScope::Id(namespace_id), + resource, + TargetScope::Any, + OperationKind::Create, + ) + .await?; + namespace_exists(ctx, namespace_id).await?; + Ok((namespace_id, expires_ts)) +} + +pub async fn namespace_exists(ctx: &ApiCtx, namespace_id: Id) -> Result<()> { + let exists = if ctx.config().is_leader() { + ctx.op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + } else { + rivet_api_util::request_remote_datacenter::( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/namespaces/{namespace_id}/exists-authoritative"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await? + }; + if !exists { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + Ok(()) +} + +pub fn no_store(mut response: axum::response::Response) -> axum::response::Response { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub fn expiration(now: i64, seconds: u64, parent: i64) -> Result { + let exp = now + .checked_add( + i64::try_from(seconds) + .ok() + .and_then(|s| s.checked_mul(1000)) + .ok_or_else(|| Signing::InvalidInput.build())?, + ) + .ok_or_else(|| Signing::InvalidInput.build())? + .min(parent); + if exp / 1000 <= now / 1000 { + return Err(rivet_auth::errors::Auth::TokenExpired.build()); + } + Ok(exp) +} diff --git a/engine/packages/api-public/src/auth/strict_json.rs b/engine/packages/api-public/src/auth/strict_json.rs new file mode 100644 index 0000000000..519b4f4fba --- /dev/null +++ b/engine/packages/api-public/src/auth/strict_json.rs @@ -0,0 +1,70 @@ +//! Reject duplicate JSON members before converting to a typed request. Never return input text. +use serde::{ + Deserialize, Deserializer, + de::{self, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::{Map, Value}; +use std::fmt; + +struct Unique(Value); +impl<'de> Deserialize<'de> for Unique { + fn deserialize>(d: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = Unique; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("JSON without duplicate fields") + } + fn visit_map>(self, mut m: M) -> Result { + let mut values = Map::new(); + while let Some((key, Unique(value))) = m.next_entry::()? { + if values.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate JSON member")); + } + } + Ok(Unique(Value::Object(values))) + } + fn visit_seq>(self, mut s: S) -> Result { + let mut values = Vec::new(); + while let Some(Unique(value)) = s.next_element()? { + values.push(value); + } + Ok(Unique(Value::Array(values))) + } + fn visit_bool(self, v: bool) -> Result { + Ok(Unique(v.into())) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Unique(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Unique(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Unique(Value::Number( + serde_json::Number::from_f64(v).ok_or_else(|| E::custom("invalid number"))?, + ))) + } + fn visit_str(self, v: &str) -> Result { + Ok(Unique(v.into())) + } + fn visit_string(self, v: String) -> Result { + Ok(Unique(v.into())) + } + fn visit_unit(self) -> Result { + Ok(Unique(Value::Null)) + } + } + d.deserialize_any(UniqueVisitor) + } +} + +pub fn parse(bytes: &[u8]) -> anyhow::Result { + let unique: Unique = + serde_json::from_slice(bytes).map_err(|_| crate::errors::Signing::InvalidInput.build())?; + serde_json::from_value(unique.0).map_err(|_| crate::errors::Signing::InvalidInput.build()) +} + +#[cfg(test)] +#[path = "../../tests/unit/signing_json.rs"] +mod tests; diff --git a/engine/packages/api-public/src/auth/webhook_discovery.rs b/engine/packages/api-public/src/auth/webhook_discovery.rs new file mode 100644 index 0000000000..7603934469 --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_discovery.rs @@ -0,0 +1,84 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + http::{HeaderValue, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::{ + ApiError, + extract::{Extension, Json, Path}, +}; +use rivet_api_peer::auth::webhooks::Discovery; +use rivet_api_types::auth::webhooks::{Jwks, Metadata}; +use rivet_util::Id; + +#[utoipa::path(get, operation_id = "auth_webhooks_jwks", path = "/namespaces/{namespace}/auth/webhooks/jwks.json", + params(("namespace" = String, Path, description = "Immutable namespace ID")), + responses((status = 200, body = Jwks, content_type = "application/jwk-set+json")), security())] +#[tracing::instrument(skip_all)] +pub async fn jwks( + Extension(ctx): Extension, + Path((namespace,)): Path<(String,)>, +) -> Response { + ctx.skip_auth(); + signing::no_store(match parse_and_get(&ctx, &namespace).await { + Ok(discovery) => { + let mut response = Json(discovery.jwks).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/jwk-set+json"), + ); + response + } + Err(error) => ApiError::from(error).into_response(), + }) +} + +#[utoipa::path(get, operation_id = "auth_webhooks_metadata", path = "/namespaces/{namespace}/auth/webhooks", + params(("namespace" = String, Path, description = "Immutable namespace ID")), + responses((status = 200, body = Metadata)), security())] +#[tracing::instrument(skip_all)] +pub async fn metadata( + Extension(ctx): Extension, + Path((namespace,)): Path<(String,)>, +) -> Response { + ctx.skip_auth(); + signing::no_store( + match parse_and_get(&ctx, &namespace) + .await + .and_then(|d| d.metadata.ok_or_else(|| Signing::Unavailable.build())) + { + Ok(metadata) => Json(metadata).into_response(), + Err(error) => ApiError::from(error).into_response(), + }, + ) +} + +async fn parse_and_get(ctx: &ApiCtx, namespace: &str) -> Result { + let id = namespace + .parse::() + .map_err(|_| Signing::NotFound.build())?; + get(ctx, id).await +} + +pub(crate) async fn get(ctx: &ApiCtx, id: Id) -> Result { + let started = std::time::Instant::now(); + let response = if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::discovery(ctx.clone().into(), (id,), ()).await? + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/auth/webhooks/{id}/discovery"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await? + }; + if started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + Ok(response) +} diff --git a/engine/packages/api-public/src/auth/webhook_keys.rs b/engine/packages/api-public/src/auth/webhook_keys.rs new file mode 100644 index 0000000000..a17e6cd86f --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_keys.rs @@ -0,0 +1,156 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::response::{IntoResponse, Response}; +use rivet_api_builder::{ + ApiError, + extract::{Extension, Json, Path}, +}; +use rivet_api_peer::auth::webhooks::MutationRequest; +use rivet_api_types::auth::webhooks::{KeyStatus, Metadata, RevokeRequest, RotateRequest}; +use rivet_auth::{AccessNamespaceScope, OperationKind, ResourceKind, TargetScope}; +use rivet_auth_jwt::ops::webhook::Change; +use rivet_util::Id; + +#[utoipa::path(post, operation_id = "auth_webhooks_ensure", path = "/auth/webhooks/ensure", + responses((status = 200, body = Metadata)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn ensure(Extension(ctx): Extension) -> Response { + let result: Result = async { + let (namespace_id, _) = signing::credential(&ctx, ResourceKind::WebhookSigning).await?; + mutate(&ctx, namespace_id, Change::Ensure).await?; + super::webhook_discovery::get(&ctx, namespace_id) + .await? + .metadata + .ok_or_else(|| Signing::Unavailable.build()) + } + .await; + signing::no_store(match result { + Ok(body) => Json(body).into_response(), + Err(error) => ApiError::from(error).into_response(), + }) +} + +#[utoipa::path(get, operation_id = "auth_webhook_keys_get", path = "/namespaces/{namespace}/auth/webhook-keys", + params(("namespace" = String, Path, description = "Namespace name")), + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn get(Extension(ctx): Extension, Path((name,)): Path<(String,)>) -> Response { + respond( + async { + let id = authorize(&ctx, name, OperationKind::Read).await?; + if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::get(ctx.into(), (id,), ()).await + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/auth/webhooks/{id}/status"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await + } + } + .await, + ) +} + +#[utoipa::path(post, operation_id = "auth_webhook_keys_rotate", path = "/namespaces/{namespace}/auth/webhook-keys/rotate", + params(("namespace" = String, Path, description = "Namespace name")), request_body = RotateRequest, + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn rotate( + Extension(ctx): Extension, + Path((name,)): Path<(String,)>, + Json(body): Json, +) -> Response { + respond( + async { + let id = authorize(&ctx, name, OperationKind::Update).await?; + mutate( + &ctx, + id, + Change::Rotate { + expected_generation: body.expected_generation, + }, + ) + .await + } + .await, + ) +} + +#[utoipa::path(post, operation_id = "auth_webhook_keys_revoke", path = "/namespaces/{namespace}/auth/webhook-keys/revoke", + params(("namespace" = String, Path, description = "Namespace name")), request_body = RevokeRequest, + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn revoke( + Extension(ctx): Extension, + Path((name,)): Path<(String,)>, + Json(body): Json, +) -> Response { + respond( + async { + let id = authorize(&ctx, name, OperationKind::Delete).await?; + let request_id = gas::prelude::Uuid::parse_str(&body.request_id) + .map_err(|_| Signing::InvalidInput.build())? + .into_bytes(); + mutate( + &ctx, + id, + Change::Revoke { + request_id, + expected_generation: body.expected_generation, + kids: body.kids, + }, + ) + .await + } + .await, + ) +} + +async fn authorize(ctx: &ApiCtx, name: String, operation: OperationKind) -> Result { + let ns = ctx + .op(namespace::ops::resolve_for_name_global::Input { name }) + .await? + .ok_or_else(|| Signing::NotFound.build())?; + ctx.auth( + AccessNamespaceScope::Id(ns.namespace_id), + ResourceKind::WebhookSigning, + TargetScope::Any, + operation, + ) + .await?; + signing::namespace_exists(ctx, ns.namespace_id).await?; + Ok(ns.namespace_id) +} + +pub(crate) async fn mutate(ctx: &ApiCtx, namespace_id: Id, change: Change) -> Result { + let request = MutationRequest { + namespace_id, + change, + }; + if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::mutate(ctx.clone().into(), (), (), request).await + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + "/auth/webhooks/mutate", + rivet_api_util::Method::POST, + Option::<&()>::None, + Some(&request), + ) + .await + } +} + +fn respond(result: Result) -> Response { + signing::no_store(match result { + Ok(body) => Json(body).into_response(), + Err(error) => ApiError::from(error).into_response(), + }) +} diff --git a/engine/packages/api-public/src/auth/webhook_signing.rs b/engine/packages/api-public/src/auth/webhook_signing.rs new file mode 100644 index 0000000000..5f37f4cc60 --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_signing.rs @@ -0,0 +1,68 @@ +use super::{signing, webhook_keys}; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::{ + ApiError, + extract::{Extension, Json}, +}; +use rivet_api_peer::auth::webhook_signing::{self as peer, DEADLINE_HEADER}; +use rivet_api_types::auth::webhooks::SignResponse; +use rivet_auth::ResourceKind; + +#[utoipa::path(post, operation_id = "auth_webhooks_sign", path = "/auth/webhooks/sign", + request_body(content = Vec, content_type = "application/octet-stream"), + responses((status = 200, body = SignResponse)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn sign(Extension(ctx): Extension, request: Request) -> Response { + signing::no_store(match sign_inner(&ctx, request).await { + Ok(body) => Json(body).into_response(), + Err(error) => ApiError::from(error).into_response(), + }) +} + +async fn sign_inner(ctx: &ApiCtx, request: Request) -> Result { + let (namespace_id, _) = signing::credential(ctx, ResourceKind::WebhookSigning).await?; + // Provisions lazily and repairs workflow dispatch after an interrupted ensure. + webhook_keys::mutate( + ctx, + namespace_id, + rivet_auth_jwt::ops::webhook::Change::Ensure, + ) + .await?; + let (body, permit) = peer::read_body(request).await?; + let check_started = rivet_util::timestamp::now(); + let (current_namespace, expires_ts) = + signing::credential(ctx, ResourceKind::WebhookSigning).await?; + if current_namespace != namespace_id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let deadline = expires_ts.min(check_started.saturating_add(5_000)); + if ctx.config().is_leader() { + return peer::sign_body(ctx, namespace_id, deadline, body, permit).await; + } + let _permit = permit; + let url = ctx + .config() + .leader_dc()? + .peer_url + .join(&format!("/auth/webhooks/{namespace_id}/sign"))?; + let response = rivet_pools::reqwest::client() + .await? + .post(url) + .header("content-type", "application/octet-stream") + .header(DEADLINE_HEADER, deadline.to_string()) + .timeout(std::time::Duration::from_secs(5)) + .body(body) + .send() + .await + .map_err(|_| Signing::Unavailable.build())?; + let result: SignResponse = rivet_api_util::parse_response(response).await?; + if rivet_util::timestamp::now() >= deadline { + return Err(Signing::Unavailable.build()); + } + Ok(result) +} diff --git a/engine/packages/api-public/src/ctx.rs b/engine/packages/api-public/src/ctx.rs index d1d8686a3a..90927dde7f 100644 --- a/engine/packages/api-public/src/ctx.rs +++ b/engine/packages/api-public/src/ctx.rs @@ -41,8 +41,13 @@ impl ApiCtx { ) -> Result<()> { self.authentication_handled.store(true, Ordering::Relaxed); if self.config().insecure_allow_unauthenticated() - && !matches!(resource, ResourceKind::Token | ResourceKind::Jwt) - { + && !matches!( + resource, + ResourceKind::Token + | ResourceKind::Jwt + | ResourceKind::ApplicationToken + | ResourceKind::WebhookSigning + ) { return Ok(()); } let token = self diff --git a/engine/packages/api-public/src/datacenters.rs b/engine/packages/api-public/src/datacenters.rs index 3cde4ee99c..fc49d83021 100644 --- a/engine/packages/api-public/src/datacenters.rs +++ b/engine/packages/api-public/src/datacenters.rs @@ -29,8 +29,14 @@ pub async fn list(Extension(ctx): Extension) -> Response { #[tracing::instrument(level = "debug", skip_all)] async fn list_inner(ctx: ApiCtx) -> Result { + let namespace_scope = match ctx.authenticate().await?.as_ref() { + rivet_auth::AuthenticatedCredential::AdminToken => AccessNamespaceScope::Any, + rivet_auth::AuthenticatedCredential::Jwt(jwt) => { + AccessNamespaceScope::Id(jwt.verified().token.namespace_id) + } + }; ctx.auth( - AccessNamespaceScope::Any, + namespace_scope, ResourceKind::Datacenter, TargetScope::Any, OperationKind::List, diff --git a/engine/packages/api-public/src/errors.rs b/engine/packages/api-public/src/errors.rs index 3a4bc5f7cc..4f2d7f0e48 100644 --- a/engine/packages/api-public/src/errors.rs +++ b/engine/packages/api-public/src/errors.rs @@ -21,3 +21,24 @@ pub enum Validation { )] RaceCondition, } + +#[derive(RivetError, Debug, Deserialize, Serialize)] +#[error("signing")] +pub enum Signing { + #[error("invalid_input", "Invalid signing request.")] + InvalidInput, + #[error( + "payload_too_large", + "The signing payload or token exceeds its size limit." + )] + PayloadTooLarge, + #[error("unavailable", "The signing service is temporarily unavailable.")] + Unavailable, + #[error( + "conflict", + "The signing key generation or request ID conflicts with current state." + )] + Conflict, + #[error("not_found", "The namespace signing keys have not been provisioned.")] + NotFound, +} diff --git a/engine/packages/api-public/src/namespaces.rs b/engine/packages/api-public/src/namespaces.rs index 27e59daae0..3208a5547f 100644 --- a/engine/packages/api-public/src/namespaces.rs +++ b/engine/packages/api-public/src/namespaces.rs @@ -30,8 +30,13 @@ pub async fn list(Extension(ctx): Extension, Query(query): Query Result { + let namespace_scope = if let Some(name) = &query.name { + AccessNamespaceScope::Name(name.clone()) + } else { + AccessNamespaceScope::Any + }; ctx.auth( - AccessNamespaceScope::Any, + namespace_scope, ResourceKind::Namespace, TargetScope::Any, if query.namespace_ids.is_none() && query.namespace_id.is_empty() && query.name.is_none() { diff --git a/engine/packages/api-public/src/router.rs b/engine/packages/api-public/src/router.rs index d53f3d0970..5e8aa202a1 100644 --- a/engine/packages/api-public/src/router.rs +++ b/engine/packages/api-public/src/router.rs @@ -40,6 +40,15 @@ use crate::{ metadata::get, auth::tokens::create, auth::tokens::inspect, + auth::jwks::get, + auth::application_tokens::create, + auth::webhook_keys::ensure, + auth::webhook_keys::get, + auth::webhook_keys::rotate, + auth::webhook_keys::revoke, + auth::webhook_signing::sign, + auth::webhook_discovery::jwks, + auth::webhook_discovery::metadata, ), components( schemas(rivet_types::keys::namespace::runner_config::RunnerConfigVariant) @@ -70,6 +79,10 @@ pub async fn router( ) // MARK: Metadata .route("/metadata", axum::routing::get(metadata::get)) + .route( + "/.well-known/jwks.json", + axum::routing::get(auth::jwks::get), + ) // MARK: Namespaces .route("/namespaces", axum::routing::get(namespaces::list)) .route("/namespaces", axum::routing::post(namespaces::create)) @@ -82,6 +95,38 @@ pub async fn router( "/auth/tokens/inspect", axum::routing::get(auth::tokens::inspect), ) + .route( + "/auth/application-tokens", + axum::routing::post(auth::application_tokens::create), + ) + .route( + "/auth/webhooks/ensure", + axum::routing::post(auth::webhook_keys::ensure), + ) + .route( + "/auth/webhooks/sign", + axum::routing::post(auth::webhook_signing::sign), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys", + axum::routing::get(auth::webhook_keys::get), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys/rotate", + axum::routing::post(auth::webhook_keys::rotate), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys/revoke", + axum::routing::post(auth::webhook_keys::revoke), + ) + .route( + "/namespaces/{namespace}/auth/webhooks/jwks.json", + axum::routing::get(auth::webhook_discovery::jwks), + ) + .route( + "/namespaces/{namespace}/auth/webhooks", + axum::routing::get(auth::webhook_discovery::metadata), + ) .route("/runner-configs", axum::routing::get(runner_configs::list)) .route( "/runner-configs/serverless-health-check", diff --git a/engine/packages/api-public/tests/unit/signing_json.rs b/engine/packages/api-public/tests/unit/signing_json.rs new file mode 100644 index 0000000000..e1e28663fd --- /dev/null +++ b/engine/packages/api-public/tests/unit/signing_json.rs @@ -0,0 +1,18 @@ +use super::*; +#[test] +fn duplicate_and_unknown_fields_are_rejected_without_echoing_payloads() { + for bytes in [ + br#"{"audience":"one","audience":"SECRET_SENTINEL"}"#.as_slice(), + br#"{"claims":{"ds":{"key":1,"key":"SECRET_SENTINEL"}}}"#, + ] { + let error = parse::(bytes).unwrap_err(); + assert!(!format!("{error:?}").contains("SECRET_SENTINEL")); + } + assert!(parse::(br#"{"claims":{"ds":{"key":1}}}"#).is_ok()); + assert!( + parse::( + br#"{"audience":"ds","purpose":"callback","claims":{},"iss":"injected"}"# + ) + .is_err() + ); +} diff --git a/engine/packages/api-types/src/auth/application_tokens.rs b/engine/packages/api-types/src/auth/application_tokens.rs new file mode 100644 index 0000000000..690b842d01 --- /dev/null +++ b/engine/packages/api-types/src/auth/application_tokens.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use utoipa::ToSchema; + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthApplicationTokenCreateRequest)] +pub struct CreateRequest { + pub audience: String, + pub purpose: String, + pub duration: Option, + pub subject: Option, + pub claims: Map, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[schema(as = AuthApplicationTokenCreateResponse)] +pub struct CreateResponse { + pub token: String, + pub issued_ts: i64, + pub expires_ts: i64, +} diff --git a/engine/packages/api-types/src/auth/mod.rs b/engine/packages/api-types/src/auth/mod.rs index 5c76635567..ed00f46000 100644 --- a/engine/packages/api-types/src/auth/mod.rs +++ b/engine/packages/api-types/src/auth/mod.rs @@ -1 +1,3 @@ +pub mod application_tokens; pub mod tokens; +pub mod webhooks; diff --git a/engine/packages/api-types/src/auth/tokens.rs b/engine/packages/api-types/src/auth/tokens.rs index 8945605be4..9e4ea361a9 100644 --- a/engine/packages/api-types/src/auth/tokens.rs +++ b/engine/packages/api-types/src/auth/tokens.rs @@ -13,6 +13,10 @@ pub enum Resource { Datacenter, ActorGateway, ActorKv, + ActorStream, + DurableStream, + ApplicationToken, + WebhookSigning, } impl From for ResourceKind { @@ -25,6 +29,10 @@ impl From for ResourceKind { Resource::Datacenter => Self::Datacenter, Resource::ActorGateway => Self::ActorGateway, Resource::ActorKv => Self::ActorKv, + Resource::ActorStream => Self::ActorStream, + Resource::DurableStream => Self::DurableStream, + Resource::ApplicationToken => Self::ApplicationToken, + Resource::WebhookSigning => Self::WebhookSigning, } } } @@ -41,6 +49,10 @@ impl TryFrom for Resource { ResourceKind::Datacenter => Ok(Self::Datacenter), ResourceKind::ActorGateway => Ok(Self::ActorGateway), ResourceKind::ActorKv => Ok(Self::ActorKv), + ResourceKind::ActorStream => Ok(Self::ActorStream), + ResourceKind::DurableStream => Ok(Self::DurableStream), + ResourceKind::ApplicationToken => Ok(Self::ApplicationToken), + ResourceKind::WebhookSigning => Ok(Self::WebhookSigning), _ => Err(()), } } diff --git a/engine/packages/api-types/src/auth/webhooks.rs b/engine/packages/api-types/src/auth/webhooks.rs new file mode 100644 index 0000000000..1558fb6d0b --- /dev/null +++ b/engine/packages/api-types/src/auth/webhooks.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookKeyStatus)] +pub struct KeyStatus { + pub generation: u64, + pub active_kid: String, + pub pending_kid: Option, + pub last_emergency_request_id: Option, + pub last_emergency_generation: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthWebhookRotateRequest)] +pub struct RotateRequest { + pub expected_generation: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthWebhookRevokeRequest)] +pub struct RevokeRequest { + pub request_id: String, + pub expected_generation: u64, + pub kids: Vec, +} + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookSignResponse)] +pub struct SignResponse { + /// Canonical decimal Unix seconds, generated by the control plane. + pub timestamp: String, + pub kid: String, + /// Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookPublicKey)] +pub struct PublicKey { + pub kty: String, + pub crv: String, + pub kid: String, + #[serde(rename = "use")] + pub public_key_use: String, + pub alg: String, + pub x: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookJwks)] +pub struct Jwks { + pub keys: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookMetadata)] +pub struct Metadata { + pub namespace_id: rivet_util::Id, + pub alg: String, + pub kid: String, + pub jwks_url: String, + pub generation: u64, +} diff --git a/engine/packages/auth-jwt/Cargo.toml b/engine/packages/auth-jwt/Cargo.toml index 01a200b595..57010dd9de 100644 --- a/engine/packages/auth-jwt/Cargo.toml +++ b/engine/packages/auth-jwt/Cargo.toml @@ -11,6 +11,7 @@ service = [ "dep:epoxy", "dep:epoxy-protocol", "dep:gas", + "dep:namespace", "dep:futures-util", "dep:lazy_static", "dep:rivet-config", @@ -30,6 +31,7 @@ epoxy-protocol = { workspace = true, optional = true } gas = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } jsonwebtoken.workspace = true +namespace = { workspace = true, optional = true } lazy_static = { workspace = true, optional = true } rand.workspace = true rivet-auth-policy.workspace = true @@ -51,3 +53,4 @@ zeroize.workspace = true [dev-dependencies] hex.workspace = true +url.workspace = true diff --git a/engine/packages/auth-jwt/src/application.rs b/engine/packages/auth-jwt/src/application.rs new file mode 100644 index 0000000000..d6f78a6c9d --- /dev/null +++ b/engine/packages/auth-jwt/src/application.rs @@ -0,0 +1,184 @@ +//! The downstream-only JWT profile. Application data never becomes Engine authority. +use jsonwebtoken::{Algorithm, Header}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::{MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, PROTOCOL_MAX_TTL, SigningKey, TokenError}; + +pub const TOKEN_TYPE: &str = "rivet-app+jwt"; +pub const MAX_APPLICATION_CLAIMS_BYTES: usize = 3_072; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Claims { + pub rivet_ver: u16, + pub rivet_purpose: String, + pub iss: String, + pub aud: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + pub iat: u64, + pub exp: u64, + pub jti: String, + pub rivet_ns: String, + pub app: Map, +} + +impl std::fmt::Debug for Claims { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApplicationClaims { [redacted] }") + } +} + +pub fn validate_application_data( + audience: &str, + purpose: &str, + subject: Option<&str>, + app: &Map, + engine_audience: &str, +) -> Result<(), TokenError> { + if audience.trim().is_empty() + || audience.len() > 256 + || audience == "rivet-api" + || audience == engine_audience + || purpose.trim().is_empty() + || purpose.len() > 128 + || reserved_prefix(purpose) + || subject.is_some_and(|s| s.len() > MAX_SUBJECT_BYTES) + || app.len() > 8 + || app.iter().any(|(name, value)| { + !valid_namespace(name) || !value.is_object() || !within_depth(value, 2) + }) { + return Err(TokenError::InvalidClaims); + } + if serde_json::to_vec(app) + .map_err(|_| TokenError::InvalidClaims)? + .len() > MAX_APPLICATION_CLAIMS_BYTES + { + return Err(TokenError::TokenTooLarge); + } + Ok(()) +} + +fn reserved_prefix(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("rivet") || lower.starts_with("engine") +} + +fn valid_namespace(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && !reserved_prefix(name) + && ![ + "iss", "sub", "aud", "exp", "nbf", "iat", "jti", "app", "typ", "alg", "kid", + ] + .contains(&name.to_ascii_lowercase().as_str()) + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)) +} + +fn within_depth(value: &Value, depth: usize) -> bool { + match value { + Value::Object(values) => depth <= 8 && values.values().all(|v| within_depth(v, depth + 1)), + Value::Array(values) => depth <= 8 && values.iter().all(|v| within_depth(v, depth + 1)), + _ => true, + } +} + +pub fn encode( + key: &SigningKey, + claims: &Claims, + engine_audience: &str, +) -> Result { + validate_application_data( + &claims.aud, + &claims.rivet_purpose, + claims.sub.as_deref(), + &claims.app, + engine_audience, + )?; + if claims.rivet_ver != 1 + || claims.iss.is_empty() + || claims.iss.len() > 512 + || claims.rivet_ns.parse::().is_err() + || claims.jti.parse::().is_err() + || claims.exp <= claims.iat + || claims.exp - claims.iat > PROTOCOL_MAX_TTL + { + return Err(TokenError::InvalidClaims); + } + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some(TOKEN_TYPE.into()); + header.kid = Some(key.kid().to_string()); + let token = jsonwebtoken::encode(&header, claims, &key.encoding_key()?) + .map_err(|_| TokenError::InvalidKey)?; + if token.len() > MAX_TOKEN_BYTES { + return Err(TokenError::TokenTooLarge); + } + Ok(token) +} + +#[cfg(feature = "service")] +pub mod service { + use super::*; + use anyhow::{Result, ensure}; + use rand::rngs::OsRng; + + pub struct IssueRequest { + pub namespace_id: crate::Id, + pub audience: String, + pub purpose: String, + pub subject: Option, + pub app: Map, + pub expires_no_later_than_ts: i64, + } + + #[derive(Serialize)] + pub struct IssueResponse { + pub token: String, + pub issued_ts: i64, + pub expires_ts: i64, + } + + impl crate::key_ring_cache::KeyRingCache { + pub async fn issue_application( + &self, + request: &IssueRequest, + max_duration_secs: u64, + ) -> Result { + ensure!( + (1..=PROTOCOL_MAX_TTL).contains(&max_duration_secs), + "invalid application token maximum duration" + ); + self.with_active_signer(|signer| { + let iat = u64::try_from(signer.issued_ts / 1000)?; + let exp = u64::try_from(request.expires_no_later_than_ts / 1000)? + .min(iat + max_duration_secs); + let claims = Claims { + rivet_ver: 1, + rivet_purpose: request.purpose.clone(), + iss: signer.issuer.to_owned(), + aud: request.audience.clone(), + sub: request.subject.clone(), + iat, + exp, + jti: crate::TokenId::generate(&mut OsRng).to_string(), + rivet_ns: request.namespace_id.to_string(), + app: request.app.clone(), + }; + let token = encode(signer.key, &claims, signer.audience)?; + Ok(IssueResponse { + token, + issued_ts: signer.issued_ts, + expires_ts: i64::try_from(exp)? * 1000, + }) + }) + .await + } + } +} + +#[cfg(test)] +#[path = "../tests/unit/application.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/codec.rs b/engine/packages/auth-jwt/src/codec.rs index fcc9a00133..4c6ac56b4c 100644 --- a/engine/packages/auth-jwt/src/codec.rs +++ b/engine/packages/auth-jwt/src/codec.rs @@ -112,6 +112,16 @@ pub fn peek_header(token: &str) -> Result { } pub fn is_reserved_token(token: &str) -> bool { + has_token_type(token, |typ| { + matches!(typ, TOKEN_TYPE | crate::application::TOKEN_TYPE) + }) +} + +pub fn is_application_token(token: &str) -> bool { + has_token_type(token, |typ| typ == crate::application::TOKEN_TYPE) +} + +fn has_token_type(token: &str, matches_type: fn(&str) -> bool) -> bool { let bytes = token.as_bytes(); let Some(first_dot) = bytes .iter() @@ -149,11 +159,12 @@ pub fn is_reserved_token(token: &str) -> bool { let reserved = Cell::new(false); let mut deserializer = serde_json::Deserializer::from_slice(&header); - let _ = de::Deserializer::deserialize_map(&mut deserializer, TypVisitor(&reserved)); + let _ = + de::Deserializer::deserialize_map(&mut deserializer, TypVisitor(&reserved, matches_type)); reserved.get() } -struct TypVisitor<'a>(&'a Cell); +struct TypVisitor<'a>(&'a Cell, fn(&str) -> bool); impl<'de> de::Visitor<'de> for TypVisitor<'_> { type Value = (); @@ -169,7 +180,7 @@ impl<'de> de::Visitor<'de> for TypVisitor<'_> { while let Some(key) = map.next_key::()? { if key == "typ" { let value = map.next_value::()?; - if value.as_str() == Some(TOKEN_TYPE) { + if value.as_str().is_some_and(self.1) { self.0.set(true); } } else { diff --git a/engine/packages/auth-jwt/src/grants.rs b/engine/packages/auth-jwt/src/grants.rs index 4ca922ede9..4220cc65eb 100644 --- a/engine/packages/auth-jwt/src/grants.rs +++ b/engine/packages/auth-jwt/src/grants.rs @@ -2,13 +2,13 @@ use std::cmp::Ordering; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use rivet_auth_policy::{OperationKind, OwnedGrant, ResourceKind, Scope}; -use rivet_data::{AUTH_GRANTS_VERSION, generated::auth_grants_v1, versioned::AuthGrantsData}; +use rivet_data::{AUTH_GRANTS_VERSION, generated::auth_grants_v2, versioned::AuthGrantsData}; use vbare::OwnedVersionedData; use crate::{Id, TokenError}; pub const MAX_GRANTS: usize = 32; -pub const MAX_OPERATIONS_PER_GRANT: usize = 5; +pub const MAX_OPERATIONS_PER_GRANT: usize = 7; pub const MAX_GRANTS_BYTES: usize = 2_048; const MAX_ENCODED_GRANTS_BYTES: usize = 2_731; @@ -58,16 +58,36 @@ impl ValidatedGrantSet { } pub fn encode_grants(grants: &ValidatedGrantSet) -> Result { - let data = auth_grants_v1::Data { + let version = if grants.grants.iter().any(|g| { + matches!( + g.resource, + ResourceKind::ActorStream + | ResourceKind::DurableStream + | ResourceKind::ApplicationToken + | ResourceKind::WebhookSigning + ) || g + .operations + .iter() + .any(|o| matches!(o, OperationKind::Append | OperationKind::Close)) + }) { + AUTH_GRANTS_VERSION + } else { + 1 + }; + encode_grants_version(grants, version) +} + +fn encode_grants_version(grants: &ValidatedGrantSet, version: u16) -> Result { + let data = auth_grants_v2::Data { grants: grants .grants .iter() .cloned() - .map(|grant| auth_grants_v1::Grant { + .map(|grant| auth_grants_v2::Grant { resource: encode_resource(grant.resource), target: match grant.target { - Scope::Any => auth_grants_v1::TargetScope::Any, - Scope::Id(id) => auth_grants_v1::TargetScope::Id(id.as_bytes()), + Scope::Any => auth_grants_v2::TargetScope::Any, + Scope::Id(id) => auth_grants_v2::TargetScope::Id(id.as_bytes()), }, operations: grant.operations.into_iter().map(encode_operation).collect(), }) @@ -75,7 +95,7 @@ pub fn encode_grants(grants: &ValidatedGrantSet) -> Result { }; let encoded = AuthGrantsData::wrap_latest(data) - .serialize_with_embedded_version(AUTH_GRANTS_VERSION) + .serialize_with_embedded_version(version) .map_err(|_| TokenError::InvalidGrants)?; if encoded.len() > MAX_GRANTS_BYTES { return Err(TokenError::InvalidGrants); @@ -154,8 +174,8 @@ pub fn decode_grants( namespace: Scope::Id(namespace_id), resource: decode_resource(grant.resource)?, target: match grant.target { - auth_grants_v1::TargetScope::Any => Scope::Any, - auth_grants_v1::TargetScope::Id(id) => { + auth_grants_v2::TargetScope::Any => Scope::Any, + auth_grants_v2::TargetScope::Id(id) => { Scope::Id(Id::from_slice(&id).map_err(|_| TokenError::InvalidGrants)?) } }, @@ -165,57 +185,69 @@ pub fn decode_grants( let validated = ValidatedGrantSet::new(namespace_id, grants)?; let version = u16::from_le_bytes([decoded[0], decoded[1]]); - if version == AUTH_GRANTS_VERSION && encode_grants(&validated).as_deref() != Ok(encoded) { + if encode_grants_version(&validated, version).as_deref() != Ok(encoded) { return Err(TokenError::InvalidGrants); } Ok(validated.into_grants()) } -fn encode_resource(resource: ResourceKind) -> auth_grants_v1::ResourceKind { +fn encode_resource(resource: ResourceKind) -> auth_grants_v2::ResourceKind { match resource { - ResourceKind::Namespace => auth_grants_v1::ResourceKind::Namespace, - ResourceKind::Actor => auth_grants_v1::ResourceKind::Actor, - ResourceKind::Runner => auth_grants_v1::ResourceKind::Runner, - ResourceKind::RunnerConfig => auth_grants_v1::ResourceKind::RunnerConfig, - ResourceKind::Token => auth_grants_v1::ResourceKind::Token, - ResourceKind::Datacenter => auth_grants_v1::ResourceKind::Datacenter, - ResourceKind::ActorGateway => auth_grants_v1::ResourceKind::ActorGateway, - ResourceKind::ActorKv => auth_grants_v1::ResourceKind::ActorKv, + ResourceKind::Namespace => auth_grants_v2::ResourceKind::Namespace, + ResourceKind::Actor => auth_grants_v2::ResourceKind::Actor, + ResourceKind::Runner => auth_grants_v2::ResourceKind::Runner, + ResourceKind::RunnerConfig => auth_grants_v2::ResourceKind::RunnerConfig, + ResourceKind::Token => auth_grants_v2::ResourceKind::Token, + ResourceKind::Datacenter => auth_grants_v2::ResourceKind::Datacenter, + ResourceKind::ActorGateway => auth_grants_v2::ResourceKind::ActorGateway, + ResourceKind::ActorKv => auth_grants_v2::ResourceKind::ActorKv, + ResourceKind::ActorStream => auth_grants_v2::ResourceKind::ActorStream, + ResourceKind::DurableStream => auth_grants_v2::ResourceKind::DurableStream, + ResourceKind::ApplicationToken => auth_grants_v2::ResourceKind::ApplicationToken, + ResourceKind::WebhookSigning => auth_grants_v2::ResourceKind::WebhookSigning, ResourceKind::Jwt => unreachable!("control-plane grants are rejected during validation"), } } -fn decode_resource(resource: auth_grants_v1::ResourceKind) -> Result { +fn decode_resource(resource: auth_grants_v2::ResourceKind) -> Result { Ok(match resource { - auth_grants_v1::ResourceKind::Namespace => ResourceKind::Namespace, - auth_grants_v1::ResourceKind::Actor => ResourceKind::Actor, - auth_grants_v1::ResourceKind::Runner => ResourceKind::Runner, - auth_grants_v1::ResourceKind::RunnerConfig => ResourceKind::RunnerConfig, - auth_grants_v1::ResourceKind::Token => ResourceKind::Token, - auth_grants_v1::ResourceKind::Reserved => return Err(TokenError::InvalidGrants), - auth_grants_v1::ResourceKind::Datacenter => ResourceKind::Datacenter, - auth_grants_v1::ResourceKind::ActorGateway => ResourceKind::ActorGateway, - auth_grants_v1::ResourceKind::ActorKv => ResourceKind::ActorKv, + auth_grants_v2::ResourceKind::Namespace => ResourceKind::Namespace, + auth_grants_v2::ResourceKind::Actor => ResourceKind::Actor, + auth_grants_v2::ResourceKind::Runner => ResourceKind::Runner, + auth_grants_v2::ResourceKind::RunnerConfig => ResourceKind::RunnerConfig, + auth_grants_v2::ResourceKind::Token => ResourceKind::Token, + auth_grants_v2::ResourceKind::Reserved => return Err(TokenError::InvalidGrants), + auth_grants_v2::ResourceKind::Datacenter => ResourceKind::Datacenter, + auth_grants_v2::ResourceKind::ActorGateway => ResourceKind::ActorGateway, + auth_grants_v2::ResourceKind::ActorKv => ResourceKind::ActorKv, + auth_grants_v2::ResourceKind::ActorStream => ResourceKind::ActorStream, + auth_grants_v2::ResourceKind::DurableStream => ResourceKind::DurableStream, + auth_grants_v2::ResourceKind::ApplicationToken => ResourceKind::ApplicationToken, + auth_grants_v2::ResourceKind::WebhookSigning => ResourceKind::WebhookSigning, }) } -fn encode_operation(operation: OperationKind) -> auth_grants_v1::OperationKind { +fn encode_operation(operation: OperationKind) -> auth_grants_v2::OperationKind { match operation { - OperationKind::Read => auth_grants_v1::OperationKind::Read, - OperationKind::Update => auth_grants_v1::OperationKind::Update, - OperationKind::List => auth_grants_v1::OperationKind::List, - OperationKind::Create => auth_grants_v1::OperationKind::Create, - OperationKind::Delete => auth_grants_v1::OperationKind::Delete, + OperationKind::Read => auth_grants_v2::OperationKind::Read, + OperationKind::Update => auth_grants_v2::OperationKind::Update, + OperationKind::List => auth_grants_v2::OperationKind::List, + OperationKind::Create => auth_grants_v2::OperationKind::Create, + OperationKind::Delete => auth_grants_v2::OperationKind::Delete, + OperationKind::Append => auth_grants_v2::OperationKind::Append, + OperationKind::Close => auth_grants_v2::OperationKind::Close, } } -fn decode_operation(operation: auth_grants_v1::OperationKind) -> OperationKind { +fn decode_operation(operation: auth_grants_v2::OperationKind) -> OperationKind { match operation { - auth_grants_v1::OperationKind::Read => OperationKind::Read, - auth_grants_v1::OperationKind::Update => OperationKind::Update, - auth_grants_v1::OperationKind::List => OperationKind::List, - auth_grants_v1::OperationKind::Create => OperationKind::Create, - auth_grants_v1::OperationKind::Delete => OperationKind::Delete, + auth_grants_v2::OperationKind::Read => OperationKind::Read, + auth_grants_v2::OperationKind::Update => OperationKind::Update, + auth_grants_v2::OperationKind::List => OperationKind::List, + auth_grants_v2::OperationKind::Create => OperationKind::Create, + auth_grants_v2::OperationKind::Delete => OperationKind::Delete, + auth_grants_v2::OperationKind::Append => OperationKind::Append, + auth_grants_v2::OperationKind::Close => OperationKind::Close, } } diff --git a/engine/packages/auth-jwt/src/jwks.rs b/engine/packages/auth-jwt/src/jwks.rs new file mode 100644 index 0000000000..05df87b71f --- /dev/null +++ b/engine/packages/auth-jwt/src/jwks.rs @@ -0,0 +1,53 @@ +//! Standards-shaped, public-only JWKS representation of Rivet-issued token verification keys. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Serialize; + +use crate::key_ring_cache::{VerificationKeyEntry, VerificationKeyLifecycle}; + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct JwkSet { + pub keys: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct Jwk { + pub kty: &'static str, + pub crv: &'static str, + #[serde(rename = "use")] + pub public_key_use: &'static str, + pub alg: &'static str, + pub kid: String, + pub x: String, +} + +impl JwkSet { + /// Includes keys needed for verification, never private signing material. A revoked key is + /// removed from the authoritative ring, and a retired key disappears at its verification + /// deadline; pending keys are published before they start signing. + pub fn from_verification_keys(keys: &[VerificationKeyEntry], now_ms: i64) -> Self { + let keys = keys + .iter() + .filter(|key| match &key.lifecycle { + VerificationKeyLifecycle::Retiring { + verify_until_ts, .. + } => now_ms < *verify_until_ts, + VerificationKeyLifecycle::Pending { .. } + | VerificationKeyLifecycle::Active { .. } => true, + }) + .map(|key| Jwk { + kty: "OKP", + crv: "Ed25519", + public_key_use: "sig", + alg: "EdDSA", + kid: key.kid.clone(), + x: URL_SAFE_NO_PAD.encode(&key.public_key), + }) + .collect(); + Self { keys } + } +} + +#[cfg(test)] +#[path = "../tests/unit/jwks.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/key_ring_cache/mod.rs b/engine/packages/auth-jwt/src/key_ring_cache/mod.rs index 4124d63732..74f1fb65f7 100644 --- a/engine/packages/auth-jwt/src/key_ring_cache/mod.rs +++ b/engine/packages/auth-jwt/src/key_ring_cache/mod.rs @@ -147,6 +147,13 @@ impl KeyRingCache { result } + /// Publish the verification cache's public keys without extending its freshness lease. + pub async fn public_snapshot( + self: &Arc, + ) -> Result { + Ok(self.usable_snapshot().await?.verification.clone()) + } + async fn verify_inner( self: &Arc, token: &str, diff --git a/engine/packages/auth-jwt/src/lib.rs b/engine/packages/auth-jwt/src/lib.rs index 337110f733..a812c55ec5 100644 --- a/engine/packages/auth-jwt/src/lib.rs +++ b/engine/packages/auth-jwt/src/lib.rs @@ -1,3 +1,4 @@ +pub mod application; mod claims; mod codec; mod error; @@ -8,10 +9,13 @@ mod persistence; mod planner; mod token_id; mod verified_jwt; +pub mod webhook; #[cfg(feature = "service")] pub mod issuer; #[cfg(feature = "service")] +pub mod jwks; +#[cfg(feature = "service")] pub mod key_ring_cache; #[cfg(feature = "service")] pub mod metrics; @@ -25,7 +29,7 @@ pub mod workflows; pub use claims::{CLAIMS_VERSION, Claims, PROTOCOL_CLOCK_SKEW, PROTOCOL_MAX_TTL}; pub use codec::{ DecodeOptions, DecodedToken, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, TokenHeader, decode, encode, - is_reserved_token, peek_header, + is_application_token, is_reserved_token, peek_header, }; pub use error::TokenError; pub use grants::{ diff --git a/engine/packages/auth-jwt/src/ops/mod.rs b/engine/packages/auth-jwt/src/ops/mod.rs index 81d250c378..bcca503020 100644 --- a/engine/packages/auth-jwt/src/ops/mod.rs +++ b/engine/packages/auth-jwt/src/ops/mod.rs @@ -1,3 +1,4 @@ pub mod issue; pub mod key_ring; pub mod rotation; +pub mod webhook; diff --git a/engine/packages/auth-jwt/src/ops/webhook.rs b/engine/packages/auth-jwt/src/ops/webhook.rs new file mode 100644 index 0000000000..4268f82689 --- /dev/null +++ b/engine/packages/auth-jwt/src/ops/webhook.rs @@ -0,0 +1,245 @@ +//! Leader-owned, linearizable namespace key-ring reads and compare-and-set mutations. +use crate::webhook::{Error, Ring}; +use anyhow::{Context, Result, ensure}; +use gas::prelude::*; + +pub(crate) fn key(namespace_id: Id) -> Vec { + crate::storage_keys::subspace().pack(&("webhook", namespace_id)) +} + +#[derive(Clone)] +pub struct Snapshot { + pub ring: Ring, + pub encoded: Vec, + pub read_started: std::time::Instant, +} +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebhookSnapshot") + .field("ring", &self.ring) + .finish_non_exhaustive() + } +} + +pub mod get { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + } + #[operation] + pub async fn auth_webhook_get(ctx: &OperationCtx, input: &Input) -> Result> { + ensure!( + ctx.config().is_leader(), + "webhook key reads are leader-only" + ); + let read_started = std::time::Instant::now(); + let value = ctx + .op(epoxy::ops::kv::get::Input { + key: key(input.namespace_id), + mode: epoxy::ops::kv::get::ReadMode::Linearizable { + target_replicas: None, + }, + }) + .await? + .value; + ensure!( + read_started.elapsed() < crate::SIGNING_CACHE_LEASE, + "webhook key read expired" + ); + value + .map(|value| { + ensure!(value.mutable, "webhook key record must be mutable"); + let encoded = value.value.context("webhook key record was deleted")?; + let ring = Ring::decode(input.namespace_id, &encoded)?; + Ok(Snapshot { + ring, + encoded, + read_started, + }) + }) + .transpose() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum Change { + Ensure, + Reconcile, + ReconcileTopology, + Rotate { + expected_generation: u64, + }, + Revoke { + request_id: [u8; 16], + expected_generation: u64, + kids: Vec, + }, +} + +pub mod mutate { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + pub change: Change, + } + + #[operation] + pub async fn auth_webhook_mutate(ctx: &OperationCtx, input: &Input) -> Result { + ensure!( + ctx.config().is_leader(), + "webhook key mutations are leader-only" + ); + ensure!( + ctx.config().auth_required()?.jwt.issuance_enabled() + || matches!( + input.change, + Change::Revoke { .. } | Change::Reconcile | Change::ReconcileTopology + ), + "signing disabled" + ); + if !ctx + .op(namespace::ops::exists_authoritative::Input { + namespace_id: input.namespace_id, + }) + .await? + { + return Err(Error::NotFound.into()); + } + let dc = ctx.config().dc_label(); + let config_generation = ctx.config().auth_required()?.jwt.issuer_generation(); + for _ in 0..16 { + let snapshot = ctx + .op(get::Input { + namespace_id: input.namespace_id, + }) + .await?; + let now = rivet_util::timestamp::now(); + let successor = match snapshot.as_ref() { + None if matches!(input.change, Change::Ensure) => { + Ring::bootstrap(input.namespace_id, dc, config_generation, now)? + } + None => return Err(Error::NotFound.into()), + Some(snapshot) => { + let ring = &snapshot.ring; + match &input.change { + Change::Ensure | Change::Reconcile | Change::ReconcileTopology => { + match if matches!(input.change, Change::ReconcileTopology) { + ring.reconcile_topology(dc, config_generation, now)? + } else { + ring.reconcile(dc, config_generation, now)? + } { + Some(successor) => successor, + None => return Ok(ring.clone()), + } + } + Change::Rotate { + expected_generation, + } => { + ensure!( + ring.keys.leader_datacenter_id == dc + && ring.keys.leader_config_generation == config_generation, + "webhook topology is not reconciled" + ); + ring.rotate(*expected_generation, now)? + } + Change::Revoke { + request_id, + expected_generation, + kids, + } => { + ensure!( + ring.keys.leader_datacenter_id == dc + && ring.keys.leader_config_generation == config_generation, + "webhook topology is not reconciled" + ); + ring.revoke(*request_id, *expected_generation, kids, now)? + } + } + } + }; + if snapshot.as_ref().is_some_and(|s| s.ring == successor) { + return Ok(successor); + } + if let Some(previous) = &snapshot { + previous.ring.keys.validate_successor(&successor.keys)?; + } + let encoded = successor.encode()?; + let result = ctx + .op(epoxy::ops::propose::Input { + proposal: epoxy::ops::propose::Proposal { + commands: vec![epoxy::ops::propose::Command { + kind: epoxy::ops::propose::CommandKind::CheckAndSetCommand( + epoxy::ops::propose::CheckAndSetCommand { + key: key(input.namespace_id), + expect_one_of: vec![ + snapshot.as_ref().map(|s| s.encoded.clone()), + ], + new_value: Some(encoded), + }, + ), + }], + }, + mutable: true, + purge_cache: true, + target_replicas: None, + }) + .await?; + match result { + epoxy::ops::propose::ProposalResult::Committed => { + if !matches!( + input.change, + Change::Ensure | Change::Reconcile | Change::ReconcileTopology + ) { + return Ok(successor); + } + } + epoxy::ops::propose::ProposalResult::ConsensusFailed { + reason: + epoxy::ops::propose::ConsensusFailedReason::ExpectedValueDoesNotMatch { .. }, + } => {} + _ if matches!( + input.change, + Change::Ensure | Change::Reconcile | Change::ReconcileTopology + ) => + { + // The rotation worker may preempt first-use admission. A fresh linearizable + // read resolves accepted state before this idempotent reconciliation retries. + // Explicit operator mutations preserve uncertain-commit errors instead. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + _ => return Err(Error::Unavailable.into()), + } + } + Err(Error::Unavailable.into()) + } +} + +/// Dispatch first so an interrupted successful provision never lacks an idle rotation worker. +pub async fn ensure_workflow(ctx: &StandaloneCtx, namespace_id: Id) -> Result<()> { + ctx.op(ensure_rotation::Input { namespace_id }).await +} +pub mod ensure_rotation { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + } + #[operation] + pub async fn auth_webhook_ensure_rotation(ctx: &OperationCtx, input: &Input) -> Result<()> { + ensure!( + ctx.config().is_leader(), + "webhook workflows are leader-owned" + ); + ctx.workflow(crate::workflows::webhook_rotation::Input { + namespace_id: input.namespace_id, + }) + .tag("namespace_id", input.namespace_id) + .unique() + .dispatch() + .await?; + Ok(()) + } +} diff --git a/engine/packages/auth-jwt/src/webhook.rs b/engine/packages/auth-jwt/src/webhook.rs new file mode 100644 index 0000000000..c5013033f3 --- /dev/null +++ b/engine/packages/auth-jwt/src/webhook.rs @@ -0,0 +1,411 @@ +//! Namespace-separated webhook keys. Internal random IDs remain private to key-ring bookkeeping; +//! public IDs are the DS-compatible thumbprints and never enter authentication-key discovery. +use crate::{Id, KeyId, PublicKeyRecord, RotationPolicy, SigningKeyRecord, SigningKeyRing}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use rivet_data::{ + AUTH_WEBHOOK_KEY_RING_VERSION, generated::auth_webhook_key_ring_v1 as data, + versioned::AuthWebhookKeyRingData, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use vbare::OwnedVersionedData; + +pub const FRESHNESS_SECONDS: u64 = 300; +// Stable internal planner identity, not a JWT issuer or a public discovery endpoint. +const PLANNER_ISSUER: &str = "https://rivet.dev/webhook-signing"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EmergencyReceipt { + pub request_id: [u8; 16], + pub expected_generation: u64, + pub committed_generation: u64, + pub requested_kids: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ring { + pub namespace_id: Id, + pub keys: SigningKeyRing, + pub last_emergency: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid webhook key request")] + InvalidInput, + #[error("webhook key generation or request ID conflict")] + Conflict, + #[error("webhook signing keys are not provisioned")] + NotFound, + #[error("webhook signing keys are unavailable")] + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Jwk { + pub kty: String, + pub crv: String, + pub kid: String, + #[serde(rename = "use")] + pub public_key_use: String, + pub alg: String, + pub x: String, +} + +pub fn public_kid(public: &[u8; 32]) -> String { + let x = URL_SAFE_NO_PAD.encode(public); + let canonical = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#); + format!( + "ds_{}", + URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())) + ) +} + +pub fn policy() -> RotationPolicy { + RotationPolicy { + rotation_interval_ms: 7 * 86400 * 1000, + publish_lead_ms: 600 * 1000, + max_signing_lifetime_ms: 14 * 86400 * 1000, + max_token_ttl_ms: FRESHNESS_SECONDS as i64 * 1000, + clock_skew_ms: 30 * 1000, + } +} + +impl Ring { + pub fn bootstrap(namespace_id: Id, dc: u16, config_generation: u64, now: i64) -> Result { + Ok(Self { + namespace_id, + keys: crate::bootstrap( + PLANNER_ISSUER.into(), + config_generation, + "rivet-webhooks".into(), + dc, + 1, + &SigningKeyRecord::generate(now), + now, + policy(), + )? + .successor, + last_emergency: None, + }) + } + + pub fn encode(&self) -> Result> { + self.validate()?; + AuthWebhookKeyRingData::wrap_latest(data::Data { + namespace_id: self.namespace_id.as_bytes(), + ring: crate::encode_signing_key_ring(&self.keys)?, + last_emergency: self + .last_emergency + .as_ref() + .map(|r| data::EmergencyReceipt { + request_id: r.request_id.to_vec(), + expected_generation: r.expected_generation, + committed_generation: r.committed_generation, + requested_kids: r.requested_kids.clone(), + }), + }) + .serialize_with_embedded_version(AUTH_WEBHOOK_KEY_RING_VERSION) + } + + pub fn decode(namespace_id: Id, bytes: &[u8]) -> Result { + ensure!( + bytes.len() <= 64 * 1024, + "webhook key ring exceeds storage bound" + ); + let data = AuthWebhookKeyRingData::deserialize_with_embedded_version(bytes)?; + ensure!( + Id::from_slice(&data.namespace_id)? == namespace_id, + "webhook key namespace mismatch" + ); + let value = Self { + namespace_id, + keys: crate::decode_signing_key_ring(&data.ring)?, + last_emergency: data + .last_emergency + .map(|r| -> Result<_> { + Ok(EmergencyReceipt { + request_id: r.request_id.try_into().map_err(|_| Error::InvalidInput)?, + expected_generation: r.expected_generation, + committed_generation: r.committed_generation, + requested_kids: r.requested_kids, + }) + }) + .transpose()?, + }; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<()> { + self.keys.validate()?; + ensure!( + self.keys.issuer_state.active.issuer == PLANNER_ISSUER + && self.keys.audience == "rivet-webhooks", + "invalid webhook ring identity" + ); + if let Some(r) = &self.last_emergency { + ensure!( + !r.requested_kids.is_empty() + && r.requested_kids.len() <= crate::ABSOLUTE_KEY_LIMIT + && r.requested_kids + .iter() + .all(|id| id.len() == 46 && id.starts_with("ds_")) + && r.requested_kids.windows(2).all(|pair| pair[0] < pair[1]) + && r.expected_generation.checked_add(1) == Some(r.committed_generation) + && r.committed_generation <= self.keys.generation, + "invalid webhook emergency receipt" + ); + } + Ok(()) + } + + pub fn public_keys(&self, now: i64) -> Vec { + self.public_records(now) + .into_iter() + .map(|k| Jwk { + kty: "OKP".into(), + crv: "Ed25519".into(), + kid: public_kid(&k.public_key), + public_key_use: "sig".into(), + alg: "EdDSA".into(), + x: URL_SAFE_NO_PAD.encode(k.public_key), + }) + .collect() + } + + fn public_records(&self, now: i64) -> Vec<&PublicKeyRecord> { + let mut keys = vec![&self.keys.active.key.public]; + if let Some(pending) = &self.keys.pending { + keys.push(&pending.key.public); + } + keys.extend( + self.keys + .retiring + .iter() + .filter(|key| now < key.verify_until_ts) + .map(|key| &key.key), + ); + keys + } + + /// One planner transition per CAS. Topology generation fences the old leader. + pub fn reconcile_topology( + &self, + dc: u16, + config_generation: u64, + now: i64, + ) -> Result> { + let transition = match crate::reconcile_issuer( + &self.keys, + PLANNER_ISSUER, + dc, + config_generation, + &[], + now, + policy(), + )? { + crate::IssuerReconcile::StaleConfiguration => return Err(Error::Unavailable.into()), + crate::IssuerReconcile::Transition(t) => Some(t), + crate::IssuerReconcile::Stable if self.keys.leader_datacenter_id != dc => { + Some(crate::recover_leader( + &self.keys, + dc, + self.keys + .leader_epoch + .checked_add(1) + .context("leader epoch overflow")?, + config_generation, + )?) + } + crate::IssuerReconcile::Stable => None, + }; + Ok(transition.map(|t| Self { + keys: t.successor, + ..self.clone() + })) + } + pub fn reconcile(&self, dc: u16, config_generation: u64, now: i64) -> Result> { + let transition = match crate::reconcile_issuer( + &self.keys, + PLANNER_ISSUER, + dc, + config_generation, + &[], + now, + policy(), + )? { + crate::IssuerReconcile::StaleConfiguration => return Err(Error::Unavailable.into()), + crate::IssuerReconcile::Transition(t) => Some(t), + crate::IssuerReconcile::Stable if self.keys.leader_datacenter_id != dc => { + Some(crate::recover_leader( + &self.keys, + dc, + self.keys + .leader_epoch + .checked_add(1) + .context("leader epoch overflow")?, + config_generation, + )?) + } + crate::IssuerReconcile::Stable => { + if let Some(t) = crate::prune_retiring(&self.keys, now)? { + Some(t) + } else if self + .keys + .pending + .as_ref() + .is_some_and(|key| now >= key.activate_after_ts) + { + Some(crate::activate_pending(&self.keys, now, policy())?) + } else if self.keys.pending.is_none() + && now + >= self.keys.active.activated_ts + policy().rotation_interval_ms + - policy().publish_lead_ms + { + Some(crate::stage_pending( + &self.keys, + &SigningKeyRecord::generate(now), + now, + policy(), + )?) + } else { + None + } + } + }; + Ok(transition.map(|t| Self { + keys: t.successor, + ..self.clone() + })) + } + + pub fn rotate(&self, expected_generation: u64, now: i64) -> Result { + if self.keys.generation != expected_generation { + return Err(Error::Conflict.into()); + } + if self.keys.pending.is_some() { + return Ok(self.clone()); + } + Ok(Self { + keys: crate::stage_pending_forced( + &self.keys, + &SigningKeyRecord::generate(now), + now, + policy(), + )? + .successor, + ..self.clone() + }) + } + + pub fn revoke( + &self, + request_id: [u8; 16], + expected_generation: u64, + requested_kids: &[String], + now: i64, + ) -> Result { + let mut requested = requested_kids.to_vec(); + requested.sort(); + if requested.is_empty() + || requested.len() > crate::ABSOLUTE_KEY_LIMIT + || requested.windows(2).any(|w| w[0] == w[1]) + { + return Err(Error::InvalidInput.into()); + } + // Compare original public arguments before resolving IDs removed by the first commit. + if let Some(receipt) = &self.last_emergency { + if receipt.request_id == request_id { + return if receipt.expected_generation == expected_generation + && receipt.requested_kids == requested + { + Ok(self.clone()) + } else { + Err(Error::Conflict.into()) + }; + } + } + if self.keys.generation != expected_generation { + return Err(Error::Conflict.into()); + } + let keys: Vec = requested + .iter() + .map(|id| { + self.public_records(now) + .into_iter() + .find(|key| public_kid(&key.public_key) == *id) + .map(|key| key.kid) + .ok_or(Error::InvalidInput) + }) + .collect::>()?; + let successor = crate::emergency_rotate( + &self.keys, + &SigningKeyRecord::generate(now), + request_id, + expected_generation, + &keys, + now, + policy(), + )? + .successor; + Ok(Self { + namespace_id: self.namespace_id, + last_emergency: Some(EmergencyReceipt { + request_id, + expected_generation, + committed_generation: successor.generation, + requested_kids: requested, + }), + keys: successor, + }) + } +} + +#[cfg(test)] +#[path = "../tests/unit/webhook.rs"] +mod tests; + +pub const MAX_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Signature { + pub timestamp: String, + pub kid: String, + pub signature: String, +} +impl std::fmt::Debug for Signature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebhookSignature") + .field("timestamp", &self.timestamp) + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +/// Plain Ed25519 over decimal Unix seconds, '.', and the unchanged raw body. +/// Production callers supply Engine time and enforce their authoritative signing lease. +pub fn sign(ring: &Ring, body: &[u8], now: i64) -> Result { + use ed25519_dalek::Signer; + ensure!( + body.len() <= MAX_PAYLOAD_BYTES, + "webhook payload exceeds maximum" + ); + ensure!( + now >= ring.keys.active.activated_ts && now < ring.keys.active.sign_until_ts, + "webhook signer is outside its signing lifetime" + ); + let timestamp = u64::try_from(now / 1000)?.to_string(); + let mut input = Vec::with_capacity(timestamp.len() + 1 + body.len()); + input.extend_from_slice(timestamp.as_bytes()); + input.push(b'.'); + input.extend_from_slice(body); + let key = ed25519_dalek::SigningKey::from_bytes(ring.keys.active.key.signing_key.seed()); + let signature = key.sign(&input); + Ok(Signature { + timestamp, + kid: public_kid(&key.verifying_key().to_bytes()), + signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()), + }) +} diff --git a/engine/packages/auth-jwt/src/workflows/mod.rs b/engine/packages/auth-jwt/src/workflows/mod.rs index 83956bada9..790d2ccd9c 100644 --- a/engine/packages/auth-jwt/src/workflows/mod.rs +++ b/engine/packages/auth-jwt/src/workflows/mod.rs @@ -1,9 +1,13 @@ pub mod key_rotation; +pub mod webhook_recovery; +pub mod webhook_rotation; use gas::prelude::*; pub fn registry() -> WorkflowResult { let mut registry = Registry::new(); registry.register_workflow::()?; + registry.register_workflow::()?; + registry.register_workflow::()?; Ok(registry) } diff --git a/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs b/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs new file mode 100644 index 0000000000..34a1116922 --- /dev/null +++ b/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs @@ -0,0 +1,89 @@ +//! Redispatch provisioned idle rings on the current authority, independent of the former DC. +use futures_util::FutureExt; +use gas::prelude::*; +#[derive(Debug, Serialize, Deserialize)] +pub struct Input; +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +struct Page { + after: Option>, +} +#[workflow] +pub async fn auth_webhook_recovery(ctx: &mut WorkflowCtx, _input: &Input) -> Result<()> { + ctx.loope(None::>, |ctx, after| { + async move { + let next = ctx + .activity(Page { + after: after.clone(), + }) + .await?; + if next.is_none() { + ctx.sleep(std::time::Duration::from_secs(60)).await?; + } + *after = next; + Ok(Loop::<()>::Continue) + } + .boxed() + }) + .await?; + Ok(()) +} +#[activity(WebhookRecoveryPage)] +#[max_retries = usize::MAX] +async fn page(ctx: &ActivityCtx, input: &Page) -> Result>> { + if !ctx.config().is_leader() { + return Ok(None); + } + let page = ctx + .op(namespace::ops::signing_recovery_page::Input { + after: input.after.clone(), + }) + .await?; + Ok(recover_page( + page, + |namespace_id| async move { + Ok(ctx + .op(crate::ops::webhook::get::Input { namespace_id }) + .await? + .is_some()) + }, + |namespace_id| async move { + ctx.op(crate::ops::webhook::ensure_rotation::Input { namespace_id }) + .await + }, + ) + .await) +} + +/// A broken ring must not prevent unrelated namespaces from recovering. Advance past failures +/// and retry them on the next complete sweep; never replace or provision their key material. +async fn recover_page( + page: namespace::ops::signing_recovery_page::Output, + mut provisioned: Read, + mut dispatch: Dispatch, +) -> Option> +where + Read: FnMut(Id) -> ReadFuture, + ReadFuture: std::future::Future>, + Dispatch: FnMut(Id) -> DispatchFuture, + DispatchFuture: std::future::Future>, +{ + for namespace_id in page.namespace_ids { + match provisioned(namespace_id).await { + Ok(false) => {} + Ok(true) => { + if dispatch(namespace_id).await.is_err() { + tracing::warn!(namespace_id = %namespace_id, phase = "dispatch", "webhook recovery will retry namespace on the next sweep"); + } + } + Err(_) => { + // Decode/storage errors may contain private persisted data: never log them. + tracing::warn!(namespace_id = %namespace_id, phase = "read", "webhook recovery will retry namespace on the next sweep"); + } + } + } + page.next +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_recovery.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs b/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs new file mode 100644 index 0000000000..a217791d5a --- /dev/null +++ b/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs @@ -0,0 +1,65 @@ +use futures_util::FutureExt; +use gas::prelude::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Input { + pub namespace_id: Id, +} + +#[workflow] +pub async fn auth_webhook_rotation(ctx: &mut WorkflowCtx, input: &Input) -> Result<()> { + let namespace_id = input.namespace_id; + ctx.loope((), |ctx, _| { + async move { + let next = ctx.activity(ReconcileInput { namespace_id }).await?; + match next { + Some(next) => { + ctx.sleep_until(next).await?; + Ok(Loop::<()>::Continue) + } + None => Ok(Loop::Break(())), + } + } + .boxed() + }) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +struct ReconcileInput { + namespace_id: Id, +} + +#[activity(WebhookReconcile)] +#[max_retries = usize::MAX] +async fn reconcile(ctx: &ActivityCtx, input: &ReconcileInput) -> Result> { + if !ctx.config().is_leader() { + return Ok(Some(rivet_util::timestamp::now() + 60_000)); + } + if !ctx + .op(namespace::ops::exists_authoritative::Input { + namespace_id: input.namespace_id, + }) + .await? + { + return Ok(None); + } + match ctx + .op(crate::ops::webhook::mutate::Input { + namespace_id: input.namespace_id, + change: crate::ops::webhook::Change::Reconcile, + }) + .await + { + Ok(ring) => Ok(Some( + crate::next_wake_ts(&ring.keys, crate::webhook::policy())? + .max(rivet_util::timestamp::now() + 1_000) + .min(rivet_util::timestamp::now() + 60_000), + )), + Err(_) => { + tracing::warn!(namespace_id = %input.namespace_id, "webhook key lifecycle reconciliation will retry"); + Ok(Some(rivet_util::timestamp::now() + 5_000)) + } + } +} diff --git a/engine/packages/auth-jwt/tests/unit/application.rs b/engine/packages/auth-jwt/tests/unit/application.rs new file mode 100644 index 0000000000..f537a56064 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/application.rs @@ -0,0 +1,110 @@ +use super::*; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::json; + +fn claims() -> Claims { + Claims { + rivet_ver: 1, rivet_purpose: "durable-streams.callback".into(), iss: "https://api.rivet.dev".into(), + aud: "durable-streams".into(), sub: None, iat: 1000, exp: 87400, + jti: crate::TokenId::generate(&mut rand::rngs::OsRng).to_string(), + rivet_ns: crate::Id::new_v1(1).to_string(), + app: json!({"durable_streams":{"root":"/streams", "subscription_id":"sub-1","wake_id":"wake-1","generation":7,"operations":["callback"]}}).as_object().unwrap().clone(), + } +} + +#[test] +fn application_tokens_are_signed_but_never_engine_credentials() { + let key = SigningKey::generate(); + let claims = claims(); + let token = encode(&key, &claims, "rivet-api").unwrap(); + assert!(crate::is_reserved_token(&token)); + assert!(crate::peek_header(&token).is_err()); + assert!( + crate::decode( + &token, + &key.verification_key(), + crate::DecodeOptions { + issuer: &claims.iss, + audience: &claims.aud, + now: 1001 + } + ) + .is_err() + ); + let mut validation = jsonwebtoken::Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; + validation.set_audience(&["durable-streams"]); + validation.set_issuer(&["https://api.rivet.dev"]); + let decoded = + jsonwebtoken::decode::(&token, &key.verification_key().decoding_key(), &validation) + .unwrap(); + assert_eq!(decoded.header.typ.as_deref(), Some(TOKEN_TYPE)); + assert_eq!(decoded.claims.app, claims.app); + assert!( + !String::from_utf8( + URL_SAFE_NO_PAD + .decode(token.split('.').nth(1).unwrap()) + .unwrap() + ) + .unwrap() + .contains("rivet_grants") + ); +} + +#[test] +fn application_profile_is_reserved_even_with_ambiguous_or_padded_headers() { + for header in [ + r#"{"typ":"rivet-app+jwt"}"#, + r#"{"typ":"JWT","typ":"rivet-app+jwt"}"#, + r#"{"typ":"rivet-app+jwt","typ":"JWT"}"#, + ] { + for encoded in [ + URL_SAFE_NO_PAD.encode(header), + base64::engine::general_purpose::URL_SAFE.encode(header), + ] { + assert!(crate::is_reserved_token(&format!( + "{encoded}.e30.signature" + ))); + } + } +} + +#[test] +fn bounds_and_reserved_fields_cannot_be_overridden() { + let key = SigningKey::generate(); + for audience in ["rivet-api", "custom-engine"] { + let mut c = claims(); + c.aud = audience.into(); + assert!(encode(&key, &c, "custom-engine").is_err()); + } + for purpose in ["", "RiVeT.auth", "engine-access"] { + let mut c = claims(); + c.rivet_purpose = purpose.into(); + assert!(encode(&key, &c, "rivet-api").is_err()); + } + for name in ["rivet_grants", "EXP", "engine", "invalid/name"] { + let mut c = claims(); + c.app.insert(name.into(), json!({})); + assert!(encode(&key, &c, "rivet-api").is_err()); + } + let mut c = claims(); + c.app + .insert("appdata".into(), json!({"data":"a".repeat(3072)})); + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut deep = json!({}); + for _ in 0..8 { + deep = json!({"nested":deep}); + } + let mut c = claims(); + c.app.insert("nested".into(), deep); + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut c = claims(); + c.exp = c.iat + PROTOCOL_MAX_TTL + 1; + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut c = claims(); + c.app.insert( + "durable_streams".into(), + json!({"exp":0,"rivet_grants":"inert"}), + ); + assert!(encode(&key, &c, "rivet-api").is_ok()); +} diff --git a/engine/packages/auth-jwt/tests/unit/jwks.rs b/engine/packages/auth-jwt/tests/unit/jwks.rs new file mode 100644 index 0000000000..282ad05676 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/jwks.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn publishes_only_live_public_keys() { + let key = |kid: &str, lifecycle| VerificationKeyEntry { + kid: kid.to_owned(), + public_key: vec![7; 32], + lifecycle, + }; + let keys = vec![ + key( + "pending", + VerificationKeyLifecycle::Pending { + activate_after_ts: 20, + }, + ), + key( + "active", + VerificationKeyLifecycle::Active { + activated_ts: 1, + sign_until_ts: 20, + }, + ), + key( + "expired", + VerificationKeyLifecycle::Retiring { + retired_ts: 1, + max_token_exp_ts: 8, + verify_until_ts: 10, + }, + ), + ]; + let jwks = JwkSet::from_verification_keys(&keys, 10); + assert_eq!(jwks.keys.len(), 2); + assert_eq!(jwks.keys[0].kid, "pending"); + assert_eq!(jwks.keys[1].kid, "active"); + let json = serde_json::to_value(jwks).unwrap(); + let published_key = &json["keys"][0]; + assert_eq!(published_key["use"], "sig"); + assert_eq!(published_key["kty"], "OKP"); + assert!(published_key.get("d").is_none()); + assert_eq!(published_key["x"], URL_SAFE_NO_PAD.encode([7; 32])); +} diff --git a/engine/packages/auth-jwt/tests/unit/webhook.rs b/engine/packages/auth-jwt/tests/unit/webhook.rs new file mode 100644 index 0000000000..44b27ca26c --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/webhook.rs @@ -0,0 +1,159 @@ +use super::*; + +#[test] +fn namespace_rings_persist_without_sharing_authentication_keys() { + let ns = Id::new_v1(1); + let ring = Ring::bootstrap(ns, 1, 1, 1000).unwrap(); + let bytes = ring.encode().unwrap(); + assert_eq!(Ring::decode(ns, &bytes).unwrap(), ring); + assert!(Ring::decode(Id::new_v1(1), &bytes).is_err()); + let other = Ring::bootstrap(Id::new_v1(1), 1, 1, 1000).unwrap(); + assert_ne!( + ring.keys.active.key.public_key, + other.keys.active.key.public_key + ); + assert!(public_kid(&ring.keys.active.key.public_key).starts_with("ds_")); + let jwks = serde_json::to_value(ring.public_keys(1000)).unwrap(); + assert_eq!(jwks[0].as_object().unwrap().len(), 6); + assert!(!jwks.to_string().contains("seed")); +} + +#[test] +fn normal_rotation_publishes_before_use_and_retires_after_full_window() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let old = public_kid(&ring.keys.active.key.public_key); + let stage = policy().rotation_interval_ms - policy().publish_lead_ms; + let pending = ring.reconcile(1, 1, stage).unwrap().unwrap(); + assert_eq!(pending.public_keys(stage).len(), 2); + assert_eq!(public_kid(&pending.keys.active.key.public_key), old); + assert!(pending.reconcile(1, 1, stage + 1).unwrap().is_none()); + let active = pending + .reconcile(1, 1, policy().rotation_interval_ms) + .unwrap() + .unwrap(); + assert_ne!(public_kid(&active.keys.active.key.public_key), old); + let deadline = policy().rotation_interval_ms + 365_000; + assert_eq!(active.keys.retiring[0].verify_until_ts, deadline); + assert!( + active + .public_keys(deadline - 1) + .iter() + .any(|key| key.kid == old) + ); + assert!( + !active + .public_keys(deadline) + .iter() + .any(|key| key.kid == old) + ); +} + +#[test] +fn emergency_receipt_retries_compare_original_external_ids_before_resolving_removed_keys() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let old = public_kid(&ring.keys.active.key.public_key); + let revoked = ring.revoke([1; 16], 1, &[old.clone()], 10).unwrap(); + assert!(!revoked.public_keys(10).iter().any(|key| key.kid == old)); + let restored = Ring::decode(ring.namespace_id, &revoked.encode().unwrap()).unwrap(); + assert_eq!(restored.revoke([1; 16], 1, &[old], 20).unwrap(), revoked); + assert!( + restored + .revoke( + [1; 16], + 1, + &[public_kid(&restored.keys.active.key.public_key)], + 20 + ) + .is_err() + ); + assert!(restored.rotate(1, 20).is_err()); + assert!( + restored + .revoke([2; 16], 2, &["unknown".into()], 20) + .is_err() + ); +} + +#[test] +fn namespace_lifecycle_fences_obsolete_topology() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 2, 0).unwrap(); + assert!(ring.reconcile(1, 1, 1).is_err()); + assert!(ring.reconcile(2, 2, 1).is_err()); + let config = ring.reconcile(2, 3, 1).unwrap().unwrap(); + let moved = config.reconcile(2, 3, 2).unwrap().unwrap(); + assert_eq!(moved.keys.leader_datacenter_id, 2); + assert!(moved.reconcile(1, 2, 3).is_err()); +} + +#[test] +fn signature_covers_the_timestamp_and_exact_binary_body() { + use ed25519_dalek::{Signature as DalekSignature, Verifier, VerifyingKey}; + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 1_699_999_999_000).unwrap(); + let body = [0, 255, b'\r', b'\n', b'{', b'}']; + let signature = sign(&ring, &body, 1_700_000_000_999).unwrap(); + assert_eq!(signature.timestamp, "1700000000"); + assert!(!signature.signature.contains('=')); + let decoded = URL_SAFE_NO_PAD.decode(&signature.signature).unwrap(); + assert_eq!(decoded.len(), 64); + let signature = DalekSignature::from_slice(&decoded).unwrap(); + let key = VerifyingKey::from_bytes(&ring.keys.active.key.public_key).unwrap(); + let mut message = b"1700000000.".to_vec(); + message.extend_from_slice(&body); + key.verify(&message, &signature).unwrap(); + message[0] = b'2'; + assert!(key.verify(&message, &signature).is_err()); + message[0] = b'1'; + message.pop(); + assert!(key.verify(&message, &signature).is_err()); + assert!(sign(&ring, b"", 1_700_000_000_000).is_ok()); + assert!(sign(&ring, b"", ring.keys.active.sign_until_ts).is_err()); +} + +#[test] +fn ds_signature_and_thumbprint_match_independent_node_crypto_vector() { + let mut ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 1_699_999_999_000).unwrap(); + let seed = std::array::from_fn(|i| i as u8); + let signing_key = crate::SigningKey::from_seed(KeyId::from_bytes([9; 16]), seed); + ring.keys.active.key.public.kid = signing_key.kid(); + ring.keys.active.key.public.public_key = signing_key.public_key(); + ring.keys.active.key.signing_key = signing_key; + let signature = sign(&ring, &[0, 255, 13, 10, 123, 125], 1_700_000_000_000).unwrap(); + assert_eq!( + signature.kid, + "ds_1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y" + ); + assert_eq!( + signature.signature, + "bSaq685LYN-Z-YQr_ek2LGPvIUveIhxOAb1Fayki66s_MV_yoMjVEQEFl542ZTRr2-BCf_6S4QhC4RmoTwWKBg" + ); +} + +#[test] +fn payload_limit_accepts_4096_canonicalized_maximum_path_snapshots() { + use ed25519_dalek::{Signature as DalekSignature, Verifier, VerifyingKey}; + let streams: Vec<_> = (0..4096).map(|i| { + let raw = format!("{i:04}/{}", "é".repeat(1020)); + assert!(raw.len() <= 2048); + let canonical = url::Url::parse(&format!("https://streams.gameinc.io/{raw}")).unwrap().path().trim_start_matches('/').to_owned(); + serde_json::json!({"path":canonical,"link_type":"explicit","acked_offset":"0000000000000001_0000000000000042","tail_offset":"0000000000000002_0000000000000084","has_pending":true}) + }).collect(); + let body = serde_json::to_vec(&serde_json::json!({"subscription_id":"sub-1","wake_id":"wake-1","generation":7,"streams":streams,"callback_url":"https://streams.gameinc.io/__ds/subscriptions/sub-1/callback","callback_token":"a".repeat(crate::MAX_TOKEN_BYTES)})).unwrap(); + assert!(body.len() > 24 * 1024 * 1024); + assert!( + body.len() < 32 * 1024 * 1024, + "4096-stream fixture must fit the default cap" + ); + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let signature = sign(&ring, &body, 1000).unwrap(); + let mut message = b"1.".to_vec(); + message.extend_from_slice(&body); + VerifyingKey::from_bytes(&ring.keys.active.key.public_key) + .unwrap() + .verify( + &message, + &DalekSignature::from_slice(&URL_SAFE_NO_PAD.decode(signature.signature).unwrap()) + .unwrap(), + ) + .unwrap(); + assert!(sign(&ring, &vec![0; MAX_PAYLOAD_BYTES + 1], 1000).is_err()); +} diff --git a/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs b/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs new file mode 100644 index 0000000000..557f8f00cf --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::webhook::Ring; +use std::{cell::RefCell, collections::BTreeMap}; + +fn recovery_page( + namespace_ids: Vec, + next: Option>, +) -> namespace::ops::signing_recovery_page::Output { + namespace::ops::signing_recovery_page::Output { + namespace_ids, + next, + } +} + +#[tokio::test] +async fn corrupt_ring_does_not_block_healthy_ring_or_advance_into_provisioning() { + let corrupt = Id::new_v1(1); + let healthy = Id::new_v1(1); + let absent = Id::new_v1(1); + let records = BTreeMap::from([ + (corrupt, vec![255, 255]), + ( + healthy, + Ring::bootstrap(healthy, 1, 1, 0).unwrap().encode().unwrap(), + ), + ]); + let original = records.clone(); + let dispatched = RefCell::new(Vec::new()); + let next = vec![1, 2, 3]; + let cursor = recover_page( + recovery_page(vec![corrupt, healthy, absent], Some(next.clone())), + |namespace_id| { + let records = &records; + async move { + records + .get(&namespace_id) + .map(|bytes| Ring::decode(namespace_id, bytes).map(|_| true)) + .unwrap_or(Ok(false)) + } + }, + |namespace_id| { + dispatched.borrow_mut().push(namespace_id); + async { Ok(()) } + }, + ) + .await; + assert_eq!(cursor, Some(next)); + assert_eq!(*dispatched.borrow(), vec![healthy]); + assert_eq!( + records, original, + "recovery must never overwrite corrupt rings or provision absent ones" + ); +} + +#[tokio::test] +async fn dispatch_failure_does_not_block_later_namespaces_and_is_retried_next_sweep() { + let first = Id::new_v1(1); + let second = Id::new_v1(1); + let dispatched = RefCell::new(Vec::new()); + let attempts = RefCell::new(0); + for _ in 0..2 { + let cursor = recover_page( + recovery_page(vec![first, second], None), + |_| async { Ok(true) }, + |namespace_id| { + let fail = namespace_id == first && *attempts.borrow() == 0; + if namespace_id == first { + *attempts.borrow_mut() += 1; + } + if !fail { + dispatched.borrow_mut().push(namespace_id); + } + async move { + if fail { + anyhow::bail!("simulated dispatch unavailable"); + } + Ok(()) + } + }, + ) + .await; + assert_eq!( + cursor, None, + "failed dispatch must not prevent completing the sweep" + ); + } + assert_eq!(*dispatched.borrow(), vec![second, first, second]); + assert_eq!(*attempts.borrow(), 2); +} + +#[tokio::test] +async fn idle_ring_is_selected_for_recovery_after_authority_changes() { + let namespace_id = Id::new_v1(1); + let persisted = Ring::bootstrap(namespace_id, 1, 1, 0) + .unwrap() + .encode() + .unwrap(); + let recovered = RefCell::new(None); + let now = crate::webhook::policy().rotation_interval_ms; + recover_page( + recovery_page(vec![namespace_id], None), + |id| std::future::ready(Ring::decode(id, &persisted).map(|_| true)), + |id| { + // Run the same planner used by the redispatched lifecycle worker, with no API traffic. + let mut ring = Ring::decode(id, &persisted).unwrap(); + while let Some(next) = ring.reconcile(2, 2, now).unwrap() { + ring = next; + } + recovered.replace(Some(ring)); + async { Ok(()) } + }, + ) + .await; + let recovered = recovered.into_inner().unwrap(); + assert_eq!(recovered.keys.leader_datacenter_id, 2); + assert_eq!(recovered.keys.leader_config_generation, 2); + assert!( + recovered.keys.pending.is_some(), + "idle rotation must publish a pending key after authority recovery" + ); + assert!( + recovered.reconcile(1, 1, now).is_err(), + "old authority must remain fenced" + ); +} diff --git a/engine/packages/auth-policy/src/errors.rs b/engine/packages/auth-policy/src/errors.rs index 776263b89a..2ce4bb6955 100644 --- a/engine/packages/auth-policy/src/errors.rs +++ b/engine/packages/auth-policy/src/errors.rs @@ -35,3 +35,29 @@ pub enum Auth { )] IssuanceDisabled, } + +#[derive(RivetError, Debug, Deserialize, Serialize)] +#[error("signing")] +pub enum Signing { + #[error("invalid_input", "Invalid signing request.")] + InvalidInput, + #[error( + "payload_too_large", + "The signing payload or token exceeds its size limit." + )] + PayloadTooLarge, + #[error("unavailable", "The signing service is temporarily unavailable.")] + Unavailable, + #[error( + "throttled", + "The namespace signing quota or service capacity is exhausted." + )] + Throttled, + #[error( + "conflict", + "The signing key generation or request ID conflicts with current state." + )] + Conflict, + #[error("not_found", "The namespace signing keys have not been provisioned.")] + NotFound, +} diff --git a/engine/packages/auth-policy/src/types.rs b/engine/packages/auth-policy/src/types.rs index 25b92a7e24..a6f281b657 100644 --- a/engine/packages/auth-policy/src/types.rs +++ b/engine/packages/auth-policy/src/types.rs @@ -31,6 +31,10 @@ pub enum ResourceKind { Datacenter, ActorGateway, ActorKv, + ActorStream, + DurableStream, + ApplicationToken, + WebhookSigning, Jwt, } @@ -40,6 +44,10 @@ impl fmt::Display for ResourceKind { ResourceKind::Actor => "actors", ResourceKind::ActorGateway => "actor gateways", ResourceKind::ActorKv => "actor KVs", + ResourceKind::ActorStream => "actor primary streams", + ResourceKind::DurableStream => "durable streams", + ResourceKind::ApplicationToken => "application tokens", + ResourceKind::WebhookSigning => "webhook signing", ResourceKind::Jwt => "JWTs", ResourceKind::Runner => "runners", ResourceKind::RunnerConfig => "runner configs", @@ -56,6 +64,10 @@ impl ResourceKind { ResourceKind::Actor => "actor", ResourceKind::ActorGateway => "actor-gateway", ResourceKind::ActorKv => "actor-kv", + ResourceKind::ActorStream => "actor-stream", + ResourceKind::DurableStream => "durable-stream", + ResourceKind::ApplicationToken => "application-token", + ResourceKind::WebhookSigning => "webhook-signing", ResourceKind::Jwt => "jwt", ResourceKind::Runner => "runner", ResourceKind::RunnerConfig => "runner-config", @@ -88,6 +100,8 @@ pub enum OperationKind { List, Create, Delete, + Append, + Close, } impl fmt::Display for OperationKind { @@ -98,6 +112,8 @@ impl fmt::Display for OperationKind { OperationKind::Update => "update", OperationKind::Delete => "delete", OperationKind::List => "list", + OperationKind::Append => "append", + OperationKind::Close => "close", }) } } diff --git a/engine/packages/bootstrap/src/lib.rs b/engine/packages/bootstrap/src/lib.rs index 58719d8b78..b4f54b415b 100644 --- a/engine/packages/bootstrap/src/lib.rs +++ b/engine/packages/bootstrap/src/lib.rs @@ -29,6 +29,13 @@ pub async fn start(config: rivet_config::Config, pools: rivet_pools::Pools) -> R // Must happen after coordinator is reconfigured setup_auth_jwt_key_rotation(&ctx).await?; + if ctx.config().is_leader() { + ctx.workflow(rivet_auth_jwt::workflows::webhook_recovery::Input) + .tag("auth", "webhook-recovery") + .unique() + .dispatch() + .await?; + } Ok(()) } diff --git a/engine/packages/config/src/config/auth.rs b/engine/packages/config/src/config/auth.rs index 02ecef8596..a724d7b329 100644 --- a/engine/packages/config/src/config/auth.rs +++ b/engine/packages/config/src/config/auth.rs @@ -283,11 +283,13 @@ mod tests { assert!(serde_json::from_str::(r#"{"disable_admin_token":true}"#).is_err()); let auth: Auth = serde_json::from_str(r#"{"admin_token":""}"#).unwrap(); assert!(auth.validate(ISSUER).is_err()); - let auth: Auth = serde_json::from_str( - r#"{"admin_token":"","insecure_allow_unauthenticated":true}"#, - ) - .unwrap(); - assert_eq!(auth.validate(ISSUER).unwrap_err().to_string(), "auth.admin_token cannot be empty"); + let auth: Auth = + serde_json::from_str(r#"{"admin_token":"","insecure_allow_unauthenticated":true}"#) + .unwrap(); + assert_eq!( + auth.validate(ISSUER).unwrap_err().to_string(), + "auth.admin_token cannot be empty" + ); } #[test] diff --git a/engine/packages/guard/src/routing/api_public.rs b/engine/packages/guard/src/routing/api_public.rs index 4cf2b0784b..04ad9c7e37 100644 --- a/engine/packages/guard/src/routing/api_public.rs +++ b/engine/packages/guard/src/routing/api_public.rs @@ -15,10 +15,35 @@ use crate::{errors, metrics}; struct ApiPublicService { router: axum::Router, + stream_signing: bool, } #[async_trait] impl CustomServeTrait for ApiPublicService { + fn streams_request_body(&self) -> bool { + self.stream_signing + } + + async fn handle_streaming_request( + &self, + req: Request, + _ctx: &mut RequestContext, + ) -> Result> { + // Signing handlers authenticate and reserve capacity before polling the original body. + // Guard must neither prebuffer these bytes nor automatically replay a signing operation. + let response = self + .router + .clone() + .call(req.map(axum::body::Body::new)) + .await?; + let (parts, body) = response.into_parts(); + let bytes = body.collect().await?.to_bytes(); + Ok(Response::from_parts( + parts, + ResponseBody::Full(Full::new(bytes)), + )) + } + #[tracing::instrument(skip_all)] async fn handle_request( &self, @@ -54,6 +79,7 @@ pub async fn route_request( ctx: &StandaloneCtx, shared_state: &crate::shared_state::SharedState, target: &str, + request_path: &str, ) -> Result> { // Check target if target != "api-public" { @@ -79,7 +105,14 @@ pub async fn route_request( ) .await?; - let service = Arc::new(ApiPublicService { router }); + let path = request_path.split('?').next().unwrap_or(request_path); + let stream_signing = path == "/auth/application-tokens" + || path.starts_with("/auth/webhooks/") + || (path.starts_with("/namespaces/") && path.contains("/auth/webhook-keys")); + let service = Arc::new(ApiPublicService { + router, + stream_signing, + }); return Ok(Some(RoutingOutput::CustomServe(service))); } diff --git a/engine/packages/guard/src/routing/mod.rs b/engine/packages/guard/src/routing/mod.rs index a8d580312d..43e6441e82 100644 --- a/engine/packages/guard/src/routing/mod.rs +++ b/engine/packages/guard/src/routing/mod.rs @@ -326,7 +326,7 @@ pub fn create_routing_function(ctx: &StandaloneCtx, shared_state: SharedState) - if let Some(routing_output) = phase_timeout( route_dispatch_phase("api_public_header"), ctx.config().guard().route_dispatch_timeout(), - api_public::route_request(&ctx, &shared_state, &target), + api_public::route_request(&ctx, &shared_state, &target, req_ctx.path()), |elapsed, timeout| { route_dispatch_timeout("api_public_header", elapsed, timeout) }, @@ -342,7 +342,12 @@ pub fn create_routing_function(ctx: &StandaloneCtx, shared_state: SharedState) - if let Some(routing_output) = phase_timeout( route_dispatch_phase("api_public_default"), ctx.config().guard().route_dispatch_timeout(), - api_public::route_request(&ctx, &shared_state, "api-public"), + api_public::route_request( + &ctx, + &shared_state, + "api-public", + req_ctx.path(), + ), |elapsed, timeout| { route_dispatch_timeout("api_public_default", elapsed, timeout) }, diff --git a/engine/packages/namespace/src/ops/exists_authoritative.rs b/engine/packages/namespace/src/ops/exists_authoritative.rs new file mode 100644 index 0000000000..5832f41489 --- /dev/null +++ b/engine/packages/namespace/src/ops/exists_authoritative.rs @@ -0,0 +1,29 @@ +use gas::prelude::*; +use universaldb::utils::IsolationLevel::Serializable; + +#[derive(Debug)] +pub struct Input { + pub namespace_id: Id, +} + +/// Bypasses metadata caches for signing admission. Only the namespace authority reads it. +#[operation] +pub async fn namespace_exists_authoritative(ctx: &OperationCtx, input: &Input) -> Result { + anyhow::ensure!( + ctx.config().is_leader(), + "namespace existence reads are leader-only" + ); + let key = crate::keys::NameKey::new(input.namespace_id); + ctx.udb()? + .txn("namespace_exists_authoritative", |tx| { + let key = &key; + async move { + Ok(tx + .with_subspace(crate::keys::subspace()) + .read_opt(key, Serializable) + .await? + .is_some()) + } + }) + .await +} diff --git a/engine/packages/namespace/src/ops/mod.rs b/engine/packages/namespace/src/ops/mod.rs index 74fc79b4a9..f9d5a36284 100644 --- a/engine/packages/namespace/src/ops/mod.rs +++ b/engine/packages/namespace/src/ops/mod.rs @@ -1,5 +1,7 @@ +pub mod exists_authoritative; pub mod get_global; pub mod get_local; pub mod list; pub mod resolve_for_name_global; pub mod resolve_for_name_local; +pub mod signing_recovery_page; diff --git a/engine/packages/namespace/src/ops/signing_recovery_page.rs b/engine/packages/namespace/src/ops/signing_recovery_page.rs new file mode 100644 index 0000000000..0bbc192362 --- /dev/null +++ b/engine/packages/namespace/src/ops/signing_recovery_page.rs @@ -0,0 +1,48 @@ +//! Bounded namespace enumeration for recovering idle signing workflows after leader changes. +use futures_util::TryStreamExt; +use gas::prelude::*; +use universaldb::{KeySelector, utils::IsolationLevel::Snapshot}; +#[derive(Debug)] +pub struct Input { + pub after: Option>, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct Output { + pub namespace_ids: Vec, + pub next: Option>, +} +#[operation] +pub async fn namespace_signing_recovery_page(ctx: &OperationCtx, input: &Input) -> Result { + anyhow::ensure!( + ctx.config().is_leader(), + "namespace enumeration is leader-only" + ); + ctx.udb()? + .txn("namespace_signing_recovery_page", |tx| async move { + let subspace = crate::keys::subspace(); + let mut range: universaldb::RangeOption = (&subspace).into(); + if let Some(after) = &input.after { + range.begin = KeySelector::first_greater_than(after.clone()); + } + range.limit = Some(512); + let mut stream = tx.get_ranges_keyvalues(range, Snapshot); + let mut namespace_ids = Vec::new(); + let mut last = None; + let mut count = 0; + while let Some(entry) = stream.try_next().await? { + count += 1; + last = Some(entry.key().to_vec()); + if let Ok(key) = subspace.unpack::(entry.key()) { + // TupleUnpack alone accepts other three-field namespace keys. + if subspace.pack(&key) == entry.key() { + namespace_ids.push(key.namespace_id()); + } + } + } + Ok(Output { + namespace_ids, + next: if count == 512 { last } else { None }, + }) + }) + .await +} diff --git a/engine/packages/pegboard-gateway3/src/http_stream/handler.rs b/engine/packages/pegboard-gateway3/src/http_stream/handler.rs index 1489d54cfd..1de334fe39 100644 --- a/engine/packages/pegboard-gateway3/src/http_stream/handler.rs +++ b/engine/packages/pegboard-gateway3/src/http_stream/handler.rs @@ -368,7 +368,7 @@ impl PegboardGateway3 { let Ok(bytes) = u64::try_from(bytes) else { return; }; - let Ok(previous) = response_consumed_bytes.fetch_update( + let Ok(previous) = response_consumed_bytes.try_update( Ordering::AcqRel, Ordering::Acquire, |current| current.checked_add(bytes), diff --git a/engine/packages/pegboard-gateway3/src/metrics.rs b/engine/packages/pegboard-gateway3/src/metrics.rs index fd2bb07730..7892fb5081 100644 --- a/engine/packages/pegboard-gateway3/src/metrics.rs +++ b/engine/packages/pegboard-gateway3/src/metrics.rs @@ -23,7 +23,7 @@ impl HttpResponseFlowMetrics { } pub(crate) fn consume(&self, bytes: usize) { - let result = self.buffered_bytes.fetch_update( + let result = self.buffered_bytes.try_update( std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire, |buffered| buffered.checked_sub(bytes as u64), diff --git a/engine/sdks/rust/data/src/lib.rs b/engine/sdks/rust/data/src/lib.rs index ff76224db8..0fdf4e82bd 100644 --- a/engine/sdks/rust/data/src/lib.rs +++ b/engine/sdks/rust/data/src/lib.rs @@ -3,8 +3,8 @@ pub mod generated; pub mod versioned; pub use generated::{ - AUTH_GRANTS_VERSION, AUTH_JWT_KEY_RING_VERSION, PEGBOARD_NAMESPACE_ACTOR_BY_KEY_VERSION, - PEGBOARD_NAMESPACE_ACTOR_NAME_VERSION, PEGBOARD_NAMESPACE_RUNNER_ALLOC_IDX_VERSION, - PEGBOARD_NAMESPACE_RUNNER_BY_KEY_VERSION, PEGBOARD_NAMESPACE_RUNNER_CONFIG_VERSION, - PEGBOARD_RUNNER_METADATA_VERSION, + AUTH_GRANTS_VERSION, AUTH_JWT_KEY_RING_VERSION, AUTH_WEBHOOK_KEY_RING_VERSION, + PEGBOARD_NAMESPACE_ACTOR_BY_KEY_VERSION, PEGBOARD_NAMESPACE_ACTOR_NAME_VERSION, + PEGBOARD_NAMESPACE_RUNNER_ALLOC_IDX_VERSION, PEGBOARD_NAMESPACE_RUNNER_BY_KEY_VERSION, + PEGBOARD_NAMESPACE_RUNNER_CONFIG_VERSION, PEGBOARD_RUNNER_METADATA_VERSION, }; diff --git a/engine/sdks/rust/data/src/versioned/auth_grants.rs b/engine/sdks/rust/data/src/versioned/auth_grants.rs index 2b84912042..753868e8db 100644 --- a/engine/sdks/rust/data/src/versioned/auth_grants.rs +++ b/engine/sdks/rust/data/src/versioned/auth_grants.rs @@ -1,22 +1,23 @@ use anyhow::{Result, bail}; use vbare::OwnedVersionedData; -use crate::generated::auth_grants_v1; +use crate::generated::{auth_grants_v1, auth_grants_v2}; pub enum AuthGrantsData { V1(auth_grants_v1::Data), + V2(auth_grants_v2::Data), } impl OwnedVersionedData for AuthGrantsData { - type Latest = auth_grants_v1::Data; + type Latest = auth_grants_v2::Data; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V1(latest) + Self::V2(latest) } fn unwrap_latest(self) -> Result { #[allow(irrefutable_let_patterns)] - if let Self::V1(data) = self { + if let Self::V2(data) = self { Ok(data) } else { bail!("version not latest") @@ -26,6 +27,7 @@ impl OwnedVersionedData for AuthGrantsData { fn deserialize_version(payload: &[u8], version: u16) -> Result { match version { 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)), + 2 => Ok(Self::V2(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -33,6 +35,163 @@ impl OwnedVersionedData for AuthGrantsData { fn serialize_version(self, _version: u16) -> Result> { match self { Self::V1(data) => serde_bare::to_vec(&data).map_err(Into::into), + Self::V2(data) => serde_bare::to_vec(&data).map_err(Into::into), } } + fn deserialize_converters() -> Vec Result> { + vec![Self::v1_to_v2] + } + fn serialize_converters() -> Vec Result> { + vec![Self::v2_to_v1] + } +} +impl AuthGrantsData { + fn v1_to_v2(self) -> Result { + let Self::V1(x) = self else { + bail!("unexpected version") + }; + Ok(Self::V2(auth_grants_v2::Data { + grants: x + .grants + .into_iter() + .map(|g| { + Ok(auth_grants_v2::Grant { + resource: match g.resource { + auth_grants_v1::ResourceKind::Namespace => { + auth_grants_v2::ResourceKind::Namespace + } + auth_grants_v1::ResourceKind::Actor => { + auth_grants_v2::ResourceKind::Actor + } + auth_grants_v1::ResourceKind::Runner => { + auth_grants_v2::ResourceKind::Runner + } + auth_grants_v1::ResourceKind::RunnerConfig => { + auth_grants_v2::ResourceKind::RunnerConfig + } + auth_grants_v1::ResourceKind::Token => { + auth_grants_v2::ResourceKind::Token + } + auth_grants_v1::ResourceKind::Datacenter => { + auth_grants_v2::ResourceKind::Datacenter + } + auth_grants_v1::ResourceKind::ActorGateway => { + auth_grants_v2::ResourceKind::ActorGateway + } + auth_grants_v1::ResourceKind::ActorKv => { + auth_grants_v2::ResourceKind::ActorKv + } + auth_grants_v1::ResourceKind::Reserved => { + auth_grants_v2::ResourceKind::Reserved + } + }, + target: match g.target { + auth_grants_v1::TargetScope::Any => auth_grants_v2::TargetScope::Any, + auth_grants_v1::TargetScope::Id(id) => { + auth_grants_v2::TargetScope::Id(id) + } + }, + operations: g + .operations + .into_iter() + .map(|o| { + Ok(match o { + auth_grants_v1::OperationKind::Read => { + auth_grants_v2::OperationKind::Read + } + auth_grants_v1::OperationKind::Update => { + auth_grants_v2::OperationKind::Update + } + auth_grants_v1::OperationKind::List => { + auth_grants_v2::OperationKind::List + } + auth_grants_v1::OperationKind::Create => { + auth_grants_v2::OperationKind::Create + } + auth_grants_v1::OperationKind::Delete => { + auth_grants_v2::OperationKind::Delete + } + }) + }) + .collect::>>()?, + }) + }) + .collect::>>()?, + })) + } + fn v2_to_v1(self) -> Result { + let Self::V2(x) = self else { + bail!("unexpected version") + }; + Ok(Self::V1(auth_grants_v1::Data { + grants: x + .grants + .into_iter() + .map(|g| { + Ok(auth_grants_v1::Grant { + resource: match g.resource { + auth_grants_v2::ResourceKind::Namespace => { + auth_grants_v1::ResourceKind::Namespace + } + auth_grants_v2::ResourceKind::Actor => { + auth_grants_v1::ResourceKind::Actor + } + auth_grants_v2::ResourceKind::Runner => { + auth_grants_v1::ResourceKind::Runner + } + auth_grants_v2::ResourceKind::RunnerConfig => { + auth_grants_v1::ResourceKind::RunnerConfig + } + auth_grants_v2::ResourceKind::Token => { + auth_grants_v1::ResourceKind::Token + } + auth_grants_v2::ResourceKind::Datacenter => { + auth_grants_v1::ResourceKind::Datacenter + } + auth_grants_v2::ResourceKind::ActorGateway => { + auth_grants_v1::ResourceKind::ActorGateway + } + auth_grants_v2::ResourceKind::ActorKv => { + auth_grants_v1::ResourceKind::ActorKv + } + auth_grants_v2::ResourceKind::Reserved => { + auth_grants_v1::ResourceKind::Reserved + } + _ => bail!("grant requires the newer schema"), + }, + target: match g.target { + auth_grants_v2::TargetScope::Any => auth_grants_v1::TargetScope::Any, + auth_grants_v2::TargetScope::Id(id) => { + auth_grants_v1::TargetScope::Id(id) + } + }, + operations: g + .operations + .into_iter() + .map(|o| { + Ok(match o { + auth_grants_v2::OperationKind::Read => { + auth_grants_v1::OperationKind::Read + } + auth_grants_v2::OperationKind::Update => { + auth_grants_v1::OperationKind::Update + } + auth_grants_v2::OperationKind::List => { + auth_grants_v1::OperationKind::List + } + auth_grants_v2::OperationKind::Create => { + auth_grants_v1::OperationKind::Create + } + auth_grants_v2::OperationKind::Delete => { + auth_grants_v1::OperationKind::Delete + } + _ => bail!("grant requires the newer schema"), + }) + }) + .collect::>>()?, + }) + }) + .collect::>>()?, + })) + } } diff --git a/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs b/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs new file mode 100644 index 0000000000..7ef27916bf --- /dev/null +++ b/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs @@ -0,0 +1,35 @@ +use anyhow::{Result, bail}; +use vbare::OwnedVersionedData; + +use crate::generated::auth_webhook_key_ring_v1; + +pub enum AuthWebhookKeyRingData { + V1(auth_webhook_key_ring_v1::Data), +} + +impl OwnedVersionedData for AuthWebhookKeyRingData { + type Latest = auth_webhook_key_ring_v1::Data; + + fn wrap_latest(latest: Self::Latest) -> Self { + Self::V1(latest) + } + + fn unwrap_latest(self) -> Result { + match self { + Self::V1(value) => Ok(value), + } + } + + fn deserialize_version(payload: &[u8], version: u16) -> Result { + match version { + 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)), + _ => bail!("unsupported webhook key ring version: {version}"), + } + } + + fn serialize_version(self, _version: u16) -> Result> { + match self { + Self::V1(value) => Ok(serde_bare::to_vec(&value)?), + } + } +} diff --git a/engine/sdks/rust/data/src/versioned/mod.rs b/engine/sdks/rust/data/src/versioned/mod.rs index c1f1c7b8d5..bb7aa473ec 100644 --- a/engine/sdks/rust/data/src/versioned/mod.rs +++ b/engine/sdks/rust/data/src/versioned/mod.rs @@ -7,10 +7,12 @@ use crate::generated::*; mod auth_grants; mod auth_jwt_key_ring; +mod auth_webhook_key_ring; mod namespace_runner_config; pub use auth_grants::*; pub use auth_jwt_key_ring::*; +pub use auth_webhook_key_ring::*; pub use namespace_runner_config::*; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/engine/sdks/rust/envoy-client/src/actor/http.rs b/engine/sdks/rust/envoy-client/src/actor/http.rs index 566b04e780..5fba46d10e 100644 --- a/engine/sdks/rust/envoy-client/src/actor/http.rs +++ b/engine/sdks/rust/envoy-client/src/actor/http.rs @@ -171,7 +171,7 @@ impl RequestBodyReceiveWindow { "HTTP request body frame exceeds maximum size" ); self.received_bytes - .fetch_update( + .try_update( std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire, |received| { @@ -190,7 +190,7 @@ impl RequestBodyReceiveWindow { fn consume(&self, bytes: u64) -> anyhow::Result { let previous = self .consumed_bytes - .fetch_update( + .try_update( std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire, |consumed| { diff --git a/engine/sdks/schemas/data/auth.grants.v2.bare b/engine/sdks/schemas/data/auth.grants.v2.bare new file mode 100644 index 0000000000..d05ed788d7 --- /dev/null +++ b/engine/sdks/schemas/data/auth.grants.v2.bare @@ -0,0 +1,44 @@ +type Any void + +type Id data + +type TargetScope union { + Any | + Id +} + +type ResourceKind enum { + NAMESPACE + ACTOR + RUNNER + RUNNER_CONFIG + TOKEN + RESERVED + DATACENTER + ACTOR_GATEWAY + ACTOR_KV + ACTOR_STREAM + DURABLE_STREAM + APPLICATION_TOKEN + WEBHOOK_SIGNING +} + +type OperationKind enum { + READ + UPDATE + LIST + CREATE + DELETE + APPEND + CLOSE +} + +type Grant struct { + resource: ResourceKind + target: TargetScope + operations: list +} + +type Data struct { + grants: list +} diff --git a/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare b/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare new file mode 100644 index 0000000000..1d35b87bd4 --- /dev/null +++ b/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare @@ -0,0 +1,13 @@ +type EmergencyReceipt struct { + requestId: data + expectedGeneration: u64 + committedGeneration: u64 + requestedKids: list +} + +type Data struct { + namespaceId: data + # A complete versioned AuthJwtKeyRingData value; the shared data library owns that schema. + ring: data + lastEmergency: optional +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index 72d6583923..12794c9935 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -2113,11 +2113,9 @@ impl Drop for InspectorAttachGuard { let Some(attach_count) = self.ctx.inspector_attach_count_arc() else { return; }; - let Ok(previous) = - attach_count.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { - current.checked_sub(1) - }) - else { + let Ok(previous) = attach_count.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + current.checked_sub(1) + }) else { return; }; let current = previous.saturating_sub(1); diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs index 97892f8734..dce15ae744 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/metrics.rs @@ -2066,7 +2066,7 @@ impl ActorMetrics { fn reserve_sqlite_series(&self, cost: usize) -> bool { SQLITE_PROFILE_ADMISSION .series - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |current| { (current.saturating_add(cost) <= self.inner.sqlite_profiling.max_prometheus_series) .then_some(current + cost) }) @@ -2207,7 +2207,7 @@ impl ActorMetrics { } } if count - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |current| { (current < cap).then_some(current + 1) }) .is_err() diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index 125e87864d..86560d5f19 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -903,7 +903,7 @@ impl ActorContext { if self .0 .schedule_sync_alarm_failures - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { remaining.checked_sub(1) }) .is_ok() diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/sleep.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/sleep.rs index e224e5127d..37a3aec5be 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/sleep.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/sleep.rs @@ -220,7 +220,7 @@ impl ActorContext { #[doc(hidden)] pub fn end_run_handler(&self) { - match self.0.sleep.run_handler_active_count.fetch_update( + match self.0.sleep.run_handler_active_count.try_update( Ordering::SeqCst, Ordering::SeqCst, |count| count.checked_sub(1), diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs index 7fe33380d9..4cbf3e950c 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs @@ -860,7 +860,7 @@ impl ActorContext { } pub fn on_state_change_finished(&self) { - let previous = self.0.on_state_change_in_flight.fetch_update( + let previous = self.0.on_state_change_in_flight.try_update( Ordering::SeqCst, Ordering::SeqCst, |count| count.checked_sub(1),