diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29a752f..9614ad6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Added - 2026-06-10
+- Service-coms and gateway extension (issues #12–#15), shipped as new 0.1.0 crate families:
+ - `ras-authorization-token`: shared RAS token claims (`ras_web_session`, `ras_internal_access`, `ras_gateway_access` families), ES256/EdDSA/HS256 signing, `kid`-based key rotation with JWKS publication, and a strict validator (asymmetric-only algorithm allowlist by default, issuer/audience/token-type pinning, key-type-confusion guard, clock-skew-aware expiry).
+ - `ras-integration-core` (#12): outbound token framework — pluggable `TokenSource`s with token families, bounds-checked caching `TokenManager` (family/subject/audience/scopes/config-version cache keys, early refresh, concurrent-refresh dedup), `GrantStore` trait, `SecretString` redaction, and capability-scoped `AuthorizedHttpClient`s over `ras-transport-core` with exact-host outbound validation and no automatic replay.
+ - `ras-integration-oauth2`: OAuth2/OIDC token source (refresh-token flow with grant scope subset checks and transactional rotation persistence, client credentials with audience forwarding, typed `ConsentRequired` errors) plus a PKCE S256 `ConsentFlow` with single-use, expiring, fully-bound state.
+ - `ras-authorization-core` (#13, embedded mode): RAS-native authorization control plane — service registry, audience-scoped grants and roles, permission-manifest import with unknown-permission rejection, pluggable `ServiceIdentityVerifier` (constant-time static-secret dev verifier included), fail-closed internal token issuer with topology policy enforcement, JWKS/key rotation, append-only audit events, embedded axum authority routes, and `RasTokenAuthProvider` for downstream validation through existing generated services.
+ - `ras-integration-ras`: `RasInternalTokenSource` bridging the two — obtains internal service tokens from the authority (in-process `EmbeddedAuthority` or HTTP `HttpAuthority`), never minting locally.
+ - `ras-authorization-gateway` (#14): optional token-narrowing reverse proxy — local web-session validation via JWKS, deterministic longest-prefix routing, single-audience derived tokens that never outlive the session, header hygiene, streaming bodies, generated-profile consumption, and fail-closed WebSocket upgrades (v1).
+ - `ras-topology-core` + `ras-topology-macro` (#15): `ras_topology!` compile-checked service graphs with build-time validation (audience uniqueness, route conflicts, exposure rules, manifest-checked edge permissions) emitting deterministic authorization-policy, gateway-profile, and Mermaid artifacts consumed by the authority and gateway.
+ - `examples/authorization-demo`: end-to-end demo wiring topology, embedded authority, internal service calls through generated clients, and the gateway in front of two generated REST services, with a full in-process integration test suite.
+- New mdBook chapters: Service-To-Service Auth, Outbound Integrations, The Auth Gateway, and Topology.
+
### Changed - 2026-06-06
- REST, JSON-RPC, and file generated-client APIs are now consistent: builders take the URL at construction, auth state is cloned, `build_with_transport(...)` is always available for generated clients, public timeout variants take `Duration`, and default reqwest-backed `build()` is emitted only when the macro crate's `reqwest` feature is enabled.
- Macro client features now distinguish transport-injected clients from default reqwest clients: `client` emits generated clients using `ras-transport-core`, while `reqwest` enables the default `ReqwestTransport` constructor.
diff --git a/Cargo.lock b/Cargo.lock
index f97130b..b7a8e0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -91,6 +91,37 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "authorization-demo"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "axum",
+ "axum-extra",
+ "axum-test",
+ "bytes",
+ "chrono",
+ "futures",
+ "ras-auth-core",
+ "ras-authorization-core",
+ "ras-authorization-gateway",
+ "ras-authorization-token",
+ "ras-integration-core",
+ "ras-integration-ras",
+ "ras-permission-manifest",
+ "ras-rest-core",
+ "ras-rest-macro",
+ "ras-topology-core",
+ "ras-topology-macro",
+ "ras-transport-core",
+ "schemars",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -207,6 +238,12 @@ dependencies = [
"url",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -564,6 +601,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "const-random"
version = "0.1.18"
@@ -733,6 +776,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -766,6 +821,33 @@ dependencies = [
"syn",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "darling"
version = "0.23.0"
@@ -820,6 +902,17 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -842,6 +935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
"subtle",
]
@@ -985,12 +1079,71 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core 0.6.4",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "email_address"
version = "0.2.9"
@@ -1071,6 +1224,22 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "file-service-api"
version = "0.1.0"
@@ -1280,6 +1449,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -1344,6 +1514,17 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -2173,6 +2354,18 @@ dependencies = [
"hashbrown 0.14.5",
]
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2219,6 +2412,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2338,6 +2540,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
[[package]]
name = "playwright-jsonrpc-fixture"
version = "0.0.0"
@@ -2458,6 +2670,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -2649,6 +2870,64 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "ras-authorization-core"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "axum",
+ "axum-test",
+ "chrono",
+ "ras-auth-core",
+ "ras-authorization-token",
+ "ras-permission-manifest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "ras-authorization-gateway"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "axum",
+ "axum-test",
+ "bytes",
+ "chrono",
+ "cookie",
+ "futures",
+ "futures-util",
+ "ras-authorization-token",
+ "ras-transport-core",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "toml",
+ "tracing",
+]
+
+[[package]]
+name = "ras-authorization-token"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "ed25519-dalek",
+ "hmac",
+ "p256",
+ "rand_core 0.6.4",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "tokio",
+ "uuid",
+]
+
[[package]]
name = "ras-file-core"
version = "0.1.0"
@@ -2753,6 +3032,62 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "ras-integration-core"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "chrono",
+ "futures",
+ "http",
+ "ras-transport-core",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "ras-integration-oauth2"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "futures",
+ "http",
+ "rand 0.8.6",
+ "ras-integration-core",
+ "ras-transport-core",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sha2",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "ras-integration-ras"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "chrono",
+ "futures",
+ "ras-authorization-core",
+ "ras-authorization-token",
+ "ras-integration-core",
+ "ras-permission-manifest",
+ "ras-transport-core",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
[[package]]
name = "ras-jsonrpc-bidirectional-client"
version = "0.1.0"
@@ -2987,6 +3322,30 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "ras-topology-core"
+version = "0.1.0"
+dependencies = [
+ "ras-authorization-core",
+ "ras-authorization-gateway",
+ "ras-permission-manifest",
+ "ras-topology-macro",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "toml",
+]
+
+[[package]]
+name = "ras-topology-macro"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "ras-transport-core"
version = "0.1.0"
@@ -3209,6 +3568,16 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -3266,6 +3635,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
[[package]]
name = "rustix"
version = "0.38.44"
@@ -3379,6 +3757,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "semver"
version = "1.0.28"
@@ -3563,6 +3955,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -3597,6 +3999,16 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
diff --git a/Cargo.toml b/Cargo.toml
index 8180207..806b505 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,9 @@
[workspace]
members = [
+ "crates/authorization/*",
"crates/core/*",
"crates/identity/*",
+ "crates/integration/*",
"crates/observability/*",
"crates/rest/*",
"crates/rpc/bidirectional/*",
@@ -9,6 +11,8 @@ members = [
"crates/rpc/ras-jsonrpc-macro",
"crates/rpc/ras-jsonrpc-types",
"crates/specs/*",
+ "crates/topology/*",
+ "examples/authorization-demo",
"examples/basic-jsonrpc/*",
"examples/bidirectional-chat/api",
"examples/bidirectional-chat/server",
@@ -40,6 +44,7 @@ dominator = "0.5"
dotenvy = "0.15"
dwind = "0.3.2"
dwind-macros = "0.2.2"
+ed25519-dalek = { version = "2.1", features = ["rand_core", "pkcs8", "pem"] }
futures = "0.3"
futures-core = "0.3"
futures-util = "0.3"
@@ -50,6 +55,7 @@ js-sys = "0.3"
mime_guess = "2.0"
once_cell = "1.20"
opentelemetry = "0.32"
+p256 = { version = "0.13", features = ["pkcs8"] }
opentelemetry-prometheus = "0.32"
proc-macro2 = "1.0"
prometheus = "0.14"
@@ -62,6 +68,7 @@ serde_urlencoded = "0.7"
sha2 = "0.10"
tempfile = "3.13"
thiserror = "2.0"
+toml = "0.8"
tokio-tungstenite = "0.26"
tower-http = "0.6"
tracing = "0.1"
diff --git a/README.md b/README.md
index 3417029..94f9c9e 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Published documentation:
The Rust Agent Stack provides reusable building blocks for distributed agent systems:
- **Pluggable Authentication** - JWT sessions, OAuth2, local username/password auth, secure cookie transport, and reusable authorization traits
+- **Multi-Service Authorization** - RAS-native authorization control plane with audience-scoped grants, internal service-to-service tokens (JWKS-validated), an outbound token framework for third-party and internal APIs, an optional token-narrowing auth gateway, and compile-checked service topologies with generated policy artifacts
- **Type-Safe APIs** - Procedural macros for JSON-RPC, REST, and file services
- **WebSocket Support** - Bidirectional real-time communication
- **File Services** - Type-safe file upload/download with streaming support
diff --git a/crates/authorization/ras-authorization-core/Cargo.toml b/crates/authorization/ras-authorization-core/Cargo.toml
new file mode 100644
index 0000000..5c68db4
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "ras-authorization-core"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.88"
+description = "RAS-native authorization control plane: service registry, audience-scoped grants, pluggable service identity, and the internal service token issuer"
+keywords = ["api", "auth", "authorization", "rbac", "tokens"]
+categories = ["authentication", "web-programming"]
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/JedimEmO/rust-api-stack"
+homepage = "https://github.com/JedimEmO/rust-api-stack"
+readme = "README.md"
+
+[dependencies]
+ras-authorization-token = { path = "../ras-authorization-token", version = "0.1.0" }
+ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" }
+ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" }
+
+async-trait = { workspace = true }
+axum = { workspace = true }
+chrono = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+axum-test = { workspace = true }
diff --git a/crates/authorization/ras-authorization-core/README.md b/crates/authorization/ras-authorization-core/README.md
new file mode 100644
index 0000000..cba113a
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/README.md
@@ -0,0 +1,37 @@
+# ras-authorization-core
+
+RAS-native authorization control plane (issue #13, embedded mode): external
+identity providers authenticate humans, RAS owns application authorization.
+
+- **Audience-scoped grants**: "principal X may use permission P at audience
+ A" — identical permission strings on different services never satisfy
+ each other. Roles bundle audience-scoped permissions; principals are
+ users, services, service accounts, or applications.
+- **Manifest-driven vocabulary**: import generated permission manifests per
+ audience; grants of unknown permissions are rejected unless made through
+ the explicit `grant_custom` path.
+- **Pluggable service identity**: `ServiceIdentityVerifier` trait with a
+ constant-time static-secret verifier for dev/simple deployments;
+ production should adapt workload identity (Kubernetes SA JWTs,
+ SPIFFE/SPIRE, mTLS) behind the same trait.
+- **Fail-closed token issuance**: identity → registration → audience
+ existence → audience-scoped grants → (optional) topology
+ `ServiceGraphPolicy` edges with permission ceilings, minting short-lived
+ single-audience `ras_internal_access` JWTs stamped with `authz_version`.
+- **JWKS + rotation**: downstream services validate offline; emergency
+ removal of retired keys immediately invalidates their tokens.
+- **Append-only audit**: registrations, grants, issuance outcomes, and key
+ changes — never containing secrets or token values.
+- **Embedded authority routes**: `authority_router` mounts
+ `POST /auth/token` and `GET /auth/jwks.json` into any axum app (the
+ default deployment preset); central-authority deployments serve the same
+ router standalone.
+- **Downstream validation**: `RasTokenAuthProvider` implements
+ `ras-auth-core`'s `AuthProvider`, so existing generated services accept
+ RAS internal and gateway tokens with their `WITH_PERMISSIONS`
+ enforcement unchanged.
+
+Storage is trait-based (`AuthorizationStore`); the in-memory implementation
+serves embedded mode, tests, and examples. See the
+`examples/authorization-demo` crate and the Service-To-Service Auth book
+chapter for full wiring.
diff --git a/crates/authorization/ras-authorization-core/src/audit.rs b/crates/authorization/ras-authorization-core/src/audit.rs
new file mode 100644
index 0000000..34c65f9
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/src/audit.rs
@@ -0,0 +1,102 @@
+//! Audit events for authorization decisions.
+//!
+//! Events carry ids, audiences, permissions, and outcomes — never secrets,
+//! proofs, or token values. The sink API is append-only.
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use tokio::sync::Mutex;
+
+/// What happened.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuditEventKind {
+ ServiceRegistered,
+ ServiceDisabled,
+ ManifestImported,
+ RoleDefined,
+ RoleBound,
+ GrantAdded,
+ GrantRevoked,
+ TokenIssued,
+ TokenIssuanceDenied,
+ IdentityVerificationFailed,
+ SigningKeyRotated,
+ SigningKeyRemoved,
+ PolicyLoaded,
+}
+
+/// One append-only audit record.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AuditEvent {
+ pub timestamp: DateTime,
+ pub kind: AuditEventKind,
+ /// The acting principal/service id, where applicable.
+ pub actor: Option,
+ /// The target (audience, service id, role id, key id), where applicable.
+ pub target: Option,
+ /// Human-readable detail. Must never contain secret or token material.
+ pub detail: String,
+}
+
+impl AuditEvent {
+ pub fn new(kind: AuditEventKind, detail: impl Into) -> Self {
+ Self {
+ timestamp: Utc::now(),
+ kind,
+ actor: None,
+ target: None,
+ detail: detail.into(),
+ }
+ }
+
+ pub fn with_actor(mut self, actor: impl Into) -> Self {
+ self.actor = Some(actor.into());
+ self
+ }
+
+ pub fn with_target(mut self, target: impl Into) -> Self {
+ self.target = Some(target.into());
+ self
+ }
+}
+
+/// Receives audit events. Implementations must treat the stream as
+/// append-only; there is deliberately no removal API.
+#[async_trait]
+pub trait AuditSink: Send + Sync {
+ async fn record(&self, event: AuditEvent);
+}
+
+/// In-memory append-only audit sink for embedded mode, tests, and examples.
+#[derive(Default)]
+pub struct InMemoryAuditSink {
+ events: Mutex>,
+}
+
+impl InMemoryAuditSink {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Snapshot of all recorded events, oldest first.
+ pub async fn events(&self) -> Vec {
+ self.events.lock().await.clone()
+ }
+}
+
+#[async_trait]
+impl AuditSink for InMemoryAuditSink {
+ async fn record(&self, event: AuditEvent) {
+ self.events.lock().await.push(event);
+ }
+}
+
+/// A sink that drops everything (for callers that do not want auditing).
+pub struct NoopAuditSink;
+
+#[async_trait]
+impl AuditSink for NoopAuditSink {
+ async fn record(&self, _event: AuditEvent) {}
+}
diff --git a/crates/authorization/ras-authorization-core/src/auth_provider.rs b/crates/authorization/ras-authorization-core/src/auth_provider.rs
new file mode 100644
index 0000000..dbfe63f
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/src/auth_provider.rs
@@ -0,0 +1,149 @@
+//! Downstream validation: accept RAS-issued tokens in existing RAS services.
+//!
+//! [`RasTokenAuthProvider`] implements `ras-auth-core`'s [`AuthProvider`],
+//! so generated REST/JSON-RPC/file services accept internal service tokens
+//! (and gateway-derived tokens) with their existing permission enforcement —
+//! no macro changes required.
+
+use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser};
+use ras_authorization_token::{KeyResolver, RasClaims, TokenError, TokenValidator};
+
+/// An [`AuthProvider`] validating RAS tokens (internal service tokens,
+/// gateway-derived tokens) against a [`TokenValidator`].
+///
+/// The validator's options pin the expected issuer, audience, token types,
+/// and algorithm allowlist; the resolver is typically the authority's JWKS.
+/// Validated claims map to an [`AuthenticatedUser`] whose permissions are
+/// the token's single-audience permission set, so generated services enforce
+/// their `WITH_PERMISSIONS` requirements unchanged.
+pub struct RasTokenAuthProvider {
+ validator: TokenValidator,
+}
+
+impl RasTokenAuthProvider {
+ pub fn new(validator: TokenValidator) -> Self {
+ Self { validator }
+ }
+
+ fn to_user(claims: RasClaims) -> AuthenticatedUser {
+ AuthenticatedUser {
+ user_id: claims.sub.clone(),
+ permissions: claims.permissions.iter().cloned().collect(),
+ metadata: claims.metadata,
+ }
+ }
+}
+
+impl AuthProvider for RasTokenAuthProvider {
+ fn authenticate(&self, token: String) -> AuthFuture<'_> {
+ Box::pin(async move {
+ let claims = self.validator.validate(&token).map_err(|err| match err {
+ TokenError::Expired => AuthError::TokenExpired,
+ _ => AuthError::InvalidToken,
+ })?;
+ Ok(Self::to_user(claims))
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use chrono::Duration;
+ use ras_authorization_token::{
+ AudiencePolicy, KeyRing, PrincipalKind, SigningKey, TokenType, ValidationOptions,
+ };
+
+ use super::*;
+
+ #[tokio::test]
+ async fn internal_token_maps_to_authenticated_user_with_permissions() {
+ let ring = KeyRing::new(SigningKey::generate_es256("k1"));
+ let claims = RasClaims::internal_service(
+ "https://auth.internal",
+ "billing-service",
+ PrincipalKind::Service,
+ "invoice-service",
+ vec!["invoice:read".to_string()],
+ Duration::minutes(5),
+ );
+ let token = ring.sign(&claims).unwrap();
+
+ let provider = RasTokenAuthProvider::new(TokenValidator::new(
+ ring.jwks(),
+ ValidationOptions::new(
+ "https://auth.internal",
+ AudiencePolicy::Exact("invoice-service".to_string()),
+ vec![TokenType::InternalService],
+ ),
+ ));
+
+ let user = provider.authenticate(token).await.unwrap();
+ assert_eq!(user.user_id, "billing-service");
+ assert!(user.permissions.contains("invoice:read"));
+ }
+
+ #[tokio::test]
+ async fn wrong_audience_and_garbage_tokens_are_invalid() {
+ let ring = KeyRing::new(SigningKey::generate_es256("k1"));
+ let claims = RasClaims::internal_service(
+ "https://auth.internal",
+ "billing-service",
+ PrincipalKind::Service,
+ "other-service",
+ vec![],
+ Duration::minutes(5),
+ );
+ let token = ring.sign(&claims).unwrap();
+
+ let provider = RasTokenAuthProvider::new(TokenValidator::new(
+ ring.jwks(),
+ ValidationOptions::new(
+ "https://auth.internal",
+ AudiencePolicy::Exact("invoice-service".to_string()),
+ vec![TokenType::InternalService],
+ ),
+ ));
+
+ assert!(matches!(
+ provider.authenticate(token).await.unwrap_err(),
+ AuthError::InvalidToken
+ ));
+ assert!(matches!(
+ provider
+ .authenticate("garbage".to_string())
+ .await
+ .unwrap_err(),
+ AuthError::InvalidToken
+ ));
+ }
+
+ #[tokio::test]
+ async fn expired_token_maps_to_token_expired() {
+ let ring = KeyRing::new(SigningKey::generate_es256("k1"));
+ let mut claims = RasClaims::internal_service(
+ "https://auth.internal",
+ "billing-service",
+ PrincipalKind::Service,
+ "invoice-service",
+ vec![],
+ Duration::minutes(5),
+ );
+ claims.iat -= 7200;
+ claims.exp = claims.iat + 60;
+ let token = ring.sign(&claims).unwrap();
+
+ let provider = RasTokenAuthProvider::new(TokenValidator::new(
+ ring.jwks(),
+ ValidationOptions::new(
+ "https://auth.internal",
+ AudiencePolicy::Exact("invoice-service".to_string()),
+ vec![TokenType::InternalService],
+ ),
+ ));
+
+ assert!(matches!(
+ provider.authenticate(token).await.unwrap_err(),
+ AuthError::TokenExpired
+ ));
+ }
+}
diff --git a/crates/authorization/ras-authorization-core/src/error.rs b/crates/authorization/ras-authorization-core/src/error.rs
new file mode 100644
index 0000000..3d2922f
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/src/error.rs
@@ -0,0 +1,58 @@
+//! Authorization control-plane errors.
+
+use thiserror::Error;
+
+/// Errors from the authorization store, identity verification, and token
+/// issuance. All issuance paths fail closed; variants never carry secret or
+/// token material.
+#[derive(Debug, Error)]
+pub enum AuthzError {
+ /// Service identity proof did not verify. Deliberately carries no
+ /// detail about why.
+ #[error("service identity verification failed for {service_id:?}")]
+ IdentityVerificationFailed { service_id: String },
+
+ /// No service is registered under this id.
+ #[error("unknown service {service_id:?}")]
+ UnknownService { service_id: String },
+
+ /// The service exists but is disabled.
+ #[error("service {service_id:?} is disabled")]
+ ServiceDisabled { service_id: String },
+
+ /// No registered service owns the requested audience.
+ #[error("unknown audience {audience:?}")]
+ UnknownAudience { audience: String },
+
+ /// The principal lacks one or more requested permissions for the
+ /// target audience.
+ #[error("permissions not granted for audience {audience:?}: {missing:?}")]
+ PermissionsNotGranted {
+ audience: String,
+ missing: Vec,
+ },
+
+ /// A loaded topology policy does not declare this caller→audience edge.
+ #[error("service graph policy does not allow {caller:?} -> {audience:?}")]
+ EdgeNotAllowed { caller: String, audience: String },
+
+ /// A grant referenced a permission unknown to the target audience's
+ /// imported manifests (and was not explicitly marked custom).
+ #[error("permission {permission:?} is not a known permission of audience {audience:?}")]
+ UnknownPermission {
+ audience: String,
+ permission: String,
+ },
+
+ /// Underlying token signing/validation failure.
+ #[error("token error: {0}")]
+ Token(#[from] ras_authorization_token::TokenError),
+
+ /// Storage failure.
+ #[error("authorization store error: {0}")]
+ Store(String),
+
+ /// Invalid configuration or registration input.
+ #[error("invalid authorization configuration: {0}")]
+ InvalidConfig(String),
+}
diff --git a/crates/authorization/ras-authorization-core/src/issuer.rs b/crates/authorization/ras-authorization-core/src/issuer.rs
new file mode 100644
index 0000000..e779e86
--- /dev/null
+++ b/crates/authorization/ras-authorization-core/src/issuer.rs
@@ -0,0 +1,309 @@
+//! The internal service token issuer.
+//!
+//! Issuance pipeline, every step fail-closed:
+//!
+//! 1. Verify the caller's service identity through the pluggable verifier.
+//! 2. The service must be registered and enabled.
+//! 3. The target audience must belong to a registered service.
+//! 4. Requested permissions must all be granted to the service principal
+//! *for that audience* (audience-scoped grants).
+//! 5. If a service-graph policy is loaded, the caller→audience edge must be
+//! declared and the requested permissions within the edge's ceiling.
+//! 6. Mint a short-lived single-audience `ras_internal_access` JWT stamped
+//! with the current `authz_version`.
+//!
+//! Every issuance, denial, and identity failure emits an audit event.
+
+use std::sync::Arc;
+
+use chrono::{DateTime, Duration, Utc};
+use ras_authorization_token::{JwkSet, KeyRing, RasClaims, SigningKey};
+use tokio::sync::RwLock;
+
+use crate::audit::{AuditEvent, AuditEventKind, AuditSink};
+use crate::error::AuthzError;
+use crate::model::{Principal, ServiceGraphPolicy};
+use crate::store::AuthorizationStore;
+use crate::verifier::{ServiceIdentityProof, ServiceIdentityVerifier};
+
+/// A request for an internal service-to-service token.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct InternalTokenRequest {
+ /// The caller's identity proof.
+ pub proof: ServiceIdentityProof,
+ /// Target service audience.
+ pub audience: String,
+ /// Requested permissions (must be granted for that audience).
+ pub permissions: Vec,
+}
+
+/// A successfully issued token.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct IssuedToken {
+ /// The signed JWT. Bearer credential — handle accordingly.
+ pub token: String,
+ pub expires_at: DateTime,
+ /// The claims that were signed (for logging-safe introspection; contains
+ /// no secret material).
+ #[serde(skip)]
+ pub claims: RasClaims,
+}
+
+/// Builder for [`TokenIssuer`].
+pub struct TokenIssuerBuilder {
+ issuer: String,
+ keys: KeyRing,
+ store: Arc,
+ verifier: Arc,
+ audit: Arc,
+ token_ttl: Duration,
+}
+
+impl TokenIssuerBuilder {
+ /// Override the internal token TTL (default 5 minutes). Internal tokens
+ /// are validated offline, so the TTL bounds revocation latency.
+ pub fn token_ttl(mut self, ttl: Duration) -> Self {
+ self.token_ttl = ttl;
+ self
+ }
+
+ /// Attach an audit sink (default: drop events).
+ pub fn audit(mut self, audit: Arc) -> Self {
+ self.audit = audit;
+ self
+ }
+
+ pub fn build(self) -> TokenIssuer {
+ TokenIssuer {
+ issuer: self.issuer,
+ keys: RwLock::new(self.keys),
+ store: self.store,
+ verifier: self.verifier,
+ audit: self.audit,
+ policy: RwLock::new(None),
+ token_ttl: self.token_ttl,
+ }
+ }
+}
+
+/// The RAS authority's internal token issuer.
+pub struct TokenIssuer {
+ issuer: String,
+ keys: RwLock,
+ store: Arc,
+ verifier: Arc,
+ audit: Arc,
+ policy: RwLock