diff --git a/Cargo.lock b/Cargo.lock index 08e1d052f..92bc18499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3719,6 +3719,7 @@ dependencies = [ "openshell-policy", "openshell-providers", "openshell-router", + "openshell-server-macros", "petname", "pin-project-lite", "prost", @@ -3752,6 +3753,15 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-server-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openshell-tui" version = "0.0.0" diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 7613c8754..12e79a1dc 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -40,12 +40,23 @@ fn main() -> Result<(), Box> { collect_proto_files(&proto_root, &mut proto_files)?; proto_files.sort(); + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + let descriptor_path = out_dir.join("openshell_descriptor.bin"); + // Configure tonic-build tonic_build::configure() .build_server(true) .build_client(true) + // Emit a binary FileDescriptorSet so the server can enumerate every + // RPC at runtime (used by the per-handler auth exhaustiveness test). + .file_descriptor_set_path(&descriptor_path) .compile_protos(&proto_files, &[proto_root.as_path()])?; + println!( + "cargo:rustc-env=OPENSHELL_DESCRIPTOR_PATH={}", + descriptor_path.display() + ); + Ok(()) } diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 2c003f38c..17548ad1a 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -43,3 +43,10 @@ pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { Some(v) => v, None => env!("CARGO_PKG_VERSION"), }; + +/// Encoded protobuf `FileDescriptorSet` for every proto in `proto/`. +/// +/// Emitted by `build.rs` via `tonic_build::configure().file_descriptor_set_path(...)`. +/// Used by tests in `openshell-server` to enumerate every RPC and verify that +/// each one has an `#[rpc_auth(...)]` declaration on its handler. +pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(env!("OPENSHELL_DESCRIPTOR_PATH")); diff --git a/crates/openshell-server-macros/Cargo.toml b/crates/openshell-server-macros/Cargo.toml new file mode 100644 index 000000000..f929d43a6 --- /dev/null +++ b/crates/openshell-server-macros/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-server-macros" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/openshell-server-macros/src/lib.rs b/crates/openshell-server-macros/src/lib.rs new file mode 100644 index 000000000..a698ae662 --- /dev/null +++ b/crates/openshell-server-macros/src/lib.rs @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Proc macros for declaring per-handler gRPC auth metadata. +//! +//! `#[rpc_authz(service = "...")]` is applied to a tonic service `impl` +//! block. Each method inside the impl carries an `#[rpc_auth(...)]` +//! attribute describing its auth mode, optional Bearer scope, and +//! required role. The macro emits a const `&[MethodAuth]` adjacent to +//! the impl and re-emits the impl block with the per-method +//! `#[rpc_auth]` attributes stripped so other macros (notably +//! `#[tonic::async_trait]`) see a clean impl. +//! +//! Generated code references `crate::auth::method_authz::{MethodAuth, +//! AuthMode, Role}`, so the macro is only intended for use inside the +//! `openshell-server` crate. +//! +//! See `architecture/plans/scope-annotations.md` for the design. + +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{ + Error, Ident, ImplItem, ItemImpl, LitStr, Meta, Result, Token, parse_macro_input, + spanned::Spanned, +}; + +struct AuthzArgs { + service: LitStr, +} + +impl Parse for AuthzArgs { + fn parse(input: ParseStream<'_>) -> Result { + let key: Ident = input.parse()?; + if key != "service" { + return Err(Error::new( + key.span(), + "expected `service = \"\"`", + )); + } + let _eq: Token![=] = input.parse()?; + let service: LitStr = input.parse()?; + Ok(Self { service }) + } +} + +struct RpcAuth { + mode: AuthMode, + scope: Option, + role: Option, +} + +#[derive(Clone, Copy)] +enum AuthMode { + Unauthenticated, + Sandbox, + Bearer, + Dual, +} + +#[derive(Clone, Copy)] +enum RoleLit { + Admin, + User, +} + +impl RpcAuth { + fn parse(meta: &Meta) -> Result { + let span = meta.span(); + let list = match meta { + Meta::List(list) => list, + _ => { + return Err(Error::new( + span, + "expected `#[rpc_auth(auth = \"...\", scope = \"...\", role = \"...\")]`", + )); + } + }; + + let mut mode: Option = None; + let mut scope: Option = None; + let mut role: Option = None; + + list.parse_nested_meta(|m| { + let ident = m + .path + .get_ident() + .ok_or_else(|| m.error("expected `auth`, `scope`, or `role`"))?; + + if ident == "auth" { + if mode.is_some() { + return Err(m.error("`auth` specified more than once")); + } + let value: LitStr = m.value()?.parse()?; + mode = Some(parse_auth_mode(&value)?); + } else if ident == "scope" { + if scope.is_some() { + return Err(m.error("`scope` specified more than once")); + } + let value: LitStr = m.value()?.parse()?; + scope = Some(value); + } else if ident == "role" { + if role.is_some() { + return Err(m.error("`role` specified more than once")); + } + let value: LitStr = m.value()?.parse()?; + role = Some(parse_role(&value)?); + } else { + return Err(m.error("expected `auth`, `scope`, or `role`")); + } + Ok(()) + })?; + + let Some(mode) = mode else { + return Err(Error::new(span, "`#[rpc_auth]` requires `auth = \"...\"`")); + }; + + match mode { + AuthMode::Unauthenticated | AuthMode::Sandbox => { + if let Some(ref s) = scope { + return Err(Error::new( + s.span(), + "`scope` is only valid for `auth = \"bearer\"` or `auth = \"dual\"` (sandbox principals don't carry scopes)", + )); + } + if role.is_some() { + return Err(Error::new( + span, + "`role` is only valid for `auth = \"bearer\"` or `auth = \"dual\"`", + )); + } + } + AuthMode::Bearer | AuthMode::Dual => { + if scope.is_none() { + return Err(Error::new( + span, + "`auth = \"bearer\"` and `auth = \"dual\"` require `scope = \"...\"`", + )); + } + if role.is_none() { + return Err(Error::new( + span, + "`auth = \"bearer\"` and `auth = \"dual\"` require `role = \"...\"`", + )); + } + } + } + + Ok(Self { mode, scope, role }) + } +} + +fn parse_auth_mode(value: &LitStr) -> Result { + match value.value().as_str() { + "unauthenticated" => Ok(AuthMode::Unauthenticated), + "sandbox" => Ok(AuthMode::Sandbox), + "bearer" => Ok(AuthMode::Bearer), + "dual" => Ok(AuthMode::Dual), + other => Err(Error::new( + value.span(), + format!( + "invalid auth mode `{other}`; expected one of `unauthenticated`, `sandbox`, `bearer`, `dual`" + ), + )), + } +} + +fn parse_role(value: &LitStr) -> Result { + match value.value().as_str() { + "admin" => Ok(RoleLit::Admin), + "user" => Ok(RoleLit::User), + other => Err(Error::new( + value.span(), + format!("invalid role `{other}`; expected `admin` or `user`"), + )), + } +} + +/// Convert a Rust snake_case method identifier to the tonic PascalCase +/// gRPC method name (`list_sandboxes` → `ListSandboxes`). +fn snake_to_pascal(ident: &str) -> String { + let mut out = String::with_capacity(ident.len()); + let mut upper_next = true; + for c in ident.chars() { + if c == '_' { + upper_next = true; + continue; + } + if upper_next { + out.extend(c.to_uppercase()); + upper_next = false; + } else { + out.push(c); + } + } + out +} + +/// Name of the per-service const emitted alongside the impl block. The +/// service module is what disambiguates between services — every impl +/// lives in its own module (`crate::grpc::AUTH_METADATA`, +/// `crate::inference::AUTH_METADATA`), so a fixed name reads more +/// naturally than `OPENSHELL_AUTH_METADATA` / `INFERENCE_AUTH_METADATA`. +const AUTH_METADATA_CONST: &str = "AUTH_METADATA"; + +fn trait_ident(item: &ItemImpl) -> Result { + let (_, path, _) = item.trait_.as_ref().ok_or_else(|| { + Error::new( + item.span(), + "`#[rpc_authz]` must be applied to a trait impl (`impl Trait for Type`)", + ) + })?; + path.segments + .last() + .map(|seg| seg.ident.clone()) + .ok_or_else(|| Error::new(path.span(), "could not determine trait identifier")) +} + +#[proc_macro_attribute] +pub fn rpc_authz(args: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as AuthzArgs); + let mut item = parse_macro_input!(item as ItemImpl); + + match expand(&args, &mut item) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand(args: &AuthzArgs, item: &mut ItemImpl) -> Result { + // `trait_ident` is still called for its validation side effect: the + // macro must be applied to a trait impl (`impl Trait for Type`). + let trait_ident = trait_ident(item)?; + let const_name = Ident::new(AUTH_METADATA_CONST, trait_ident.span()); + let service = args.service.value(); + + let mut entries: Vec = Vec::new(); + let mut seen_paths: Vec = Vec::new(); + + for impl_item in &mut item.items { + let ImplItem::Fn(method) = impl_item else { + continue; + }; + + let mut found: Option = None; + let mut kept = Vec::with_capacity(method.attrs.len()); + for attr in method.attrs.drain(..) { + if attr.path().is_ident("rpc_auth") { + if found.is_some() { + return Err(Error::new( + attr.span(), + "duplicate `#[rpc_auth]` on the same method", + )); + } + found = Some(attr.meta); + } else { + kept.push(attr); + } + } + method.attrs = kept; + + let Some(meta) = found else { + return Err(Error::new( + method.sig.ident.span(), + "method is missing `#[rpc_auth(...)]`; every RPC method must declare its auth metadata", + )); + }; + + let auth = RpcAuth::parse(&meta)?; + let method_path = format!( + "/{}/{}", + service, + snake_to_pascal(&method.sig.ident.to_string()) + ); + + if seen_paths.contains(&method_path) { + return Err(Error::new( + method.sig.ident.span(), + format!("duplicate gRPC method path `{method_path}`"), + )); + } + seen_paths.push(method_path.clone()); + + let mode_tokens = match auth.mode { + AuthMode::Unauthenticated => { + quote! { crate::auth::method_authz::AuthMode::Unauthenticated } + } + AuthMode::Sandbox => { + quote! { crate::auth::method_authz::AuthMode::Sandbox } + } + AuthMode::Bearer => quote! { crate::auth::method_authz::AuthMode::Bearer }, + AuthMode::Dual => quote! { crate::auth::method_authz::AuthMode::Dual }, + }; + + let scope_tokens = match &auth.scope { + Some(s) => quote! { ::core::option::Option::Some(#s) }, + None => quote! { ::core::option::Option::None }, + }; + + let role_tokens = match auth.role { + Some(RoleLit::Admin) => { + quote! { ::core::option::Option::Some(crate::auth::method_authz::Role::Admin) } + } + Some(RoleLit::User) => { + quote! { ::core::option::Option::Some(crate::auth::method_authz::Role::User) } + } + None => quote! { ::core::option::Option::None }, + }; + + entries.push(quote! { + crate::auth::method_authz::MethodAuth { + path: #method_path, + mode: #mode_tokens, + scope: #scope_tokens, + role: #role_tokens, + } + }); + } + + let entries_count = entries.len(); + let entries_array = quote! { [#(#entries),*] }; + + Ok(quote! { + pub const #const_name: &[crate::auth::method_authz::MethodAuth; #entries_count] = &#entries_array; + + #item + }) +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 69319f63a..9f3c1f33b 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -24,6 +24,7 @@ openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } +openshell-server-macros = { path = "../openshell-server-macros" } # Kubernetes client (used by the `generate-certs` subcommand) kube = { workspace = true } diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index b4aa072d6..1c04b0976 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -13,122 +13,10 @@ //! authorization is a gateway concern. use super::identity::Identity; +use super::method_authz::{self, Role}; use tonic::Status; use tracing::debug; -/// gRPC methods that require the admin role. -/// All other authenticated methods require the user role. -const ADMIN_METHODS: &[&str] = &[ - // Provider management - "/openshell.v1.OpenShell/CreateProvider", - "/openshell.v1.OpenShell/UpdateProvider", - "/openshell.v1.OpenShell/DeleteProvider", - "/openshell.v1.OpenShell/ConfigureProviderRefresh", - "/openshell.v1.OpenShell/RotateProviderCredential", - "/openshell.v1.OpenShell/DeleteProviderRefresh", - // Global config and policy - "/openshell.v1.OpenShell/UpdateConfig", - // Draft policy approvals - "/openshell.v1.OpenShell/ApproveDraftChunk", - "/openshell.v1.OpenShell/ApproveAllDraftChunks", - "/openshell.v1.OpenShell/RejectDraftChunk", - "/openshell.v1.OpenShell/EditDraftChunk", - "/openshell.v1.OpenShell/UndoDraftChunk", - "/openshell.v1.OpenShell/ClearDraftChunks", - // Cluster inference write - "/openshell.inference.v1.Inference/SetClusterInference", -]; - -/// Exhaustive mapping of Bearer-authenticated gRPC methods to required scopes. -/// Methods not listed here require `openshell:all` when scope enforcement is enabled. -const SCOPED_METHODS: &[(&str, &str)] = &[ - // sandbox:read - ("/openshell.v1.OpenShell/GetSandbox", "sandbox:read"), - ("/openshell.v1.OpenShell/ListSandboxes", "sandbox:read"), - ( - "/openshell.v1.OpenShell/ListSandboxProviders", - "sandbox:read", - ), - ("/openshell.v1.OpenShell/WatchSandbox", "sandbox:read"), - ("/openshell.v1.OpenShell/GetSandboxLogs", "sandbox:read"), - ("/openshell.v1.OpenShell/GetService", "sandbox:read"), - ("/openshell.v1.OpenShell/ListServices", "sandbox:read"), - ( - "/openshell.v1.OpenShell/GetSandboxPolicyStatus", - "sandbox:read", - ), - ( - "/openshell.v1.OpenShell/ListSandboxPolicies", - "sandbox:read", - ), - // sandbox:write - ("/openshell.v1.OpenShell/CreateSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/DeleteSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/ExecSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/ForwardTcp", "sandbox:write"), - ("/openshell.v1.OpenShell/CreateSshSession", "sandbox:write"), - ("/openshell.v1.OpenShell/RevokeSshSession", "sandbox:write"), - ("/openshell.v1.OpenShell/ExposeService", "sandbox:write"), - ("/openshell.v1.OpenShell/DeleteService", "sandbox:write"), - ( - "/openshell.v1.OpenShell/AttachSandboxProvider", - "sandbox:write", - ), - ( - "/openshell.v1.OpenShell/DetachSandboxProvider", - "sandbox:write", - ), - // provider:read - ("/openshell.v1.OpenShell/GetProvider", "provider:read"), - ("/openshell.v1.OpenShell/ListProviders", "provider:read"), - ( - "/openshell.v1.OpenShell/GetProviderRefreshStatus", - "provider:read", - ), - // provider:write - ("/openshell.v1.OpenShell/CreateProvider", "provider:write"), - ("/openshell.v1.OpenShell/UpdateProvider", "provider:write"), - ("/openshell.v1.OpenShell/DeleteProvider", "provider:write"), - ( - "/openshell.v1.OpenShell/ConfigureProviderRefresh", - "provider:write", - ), - ( - "/openshell.v1.OpenShell/RotateProviderCredential", - "provider:write", - ), - ( - "/openshell.v1.OpenShell/DeleteProviderRefresh", - "provider:write", - ), - // config:read - ("/openshell.v1.OpenShell/GetGatewayConfig", "config:read"), - ("/openshell.v1.OpenShell/GetSandboxConfig", "config:read"), - ("/openshell.v1.OpenShell/GetDraftPolicy", "config:read"), - ("/openshell.v1.OpenShell/GetDraftHistory", "config:read"), - // config:write - ("/openshell.v1.OpenShell/UpdateConfig", "config:write"), - ("/openshell.v1.OpenShell/ApproveDraftChunk", "config:write"), - ( - "/openshell.v1.OpenShell/ApproveAllDraftChunks", - "config:write", - ), - ("/openshell.v1.OpenShell/RejectDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/EditDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/UndoDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/ClearDraftChunks", "config:write"), - // inference:read - ( - "/openshell.inference.v1.Inference/GetClusterInference", - "inference:read", - ), - // inference:write - ( - "/openshell.inference.v1.Inference/SetClusterInference", - "inference:write", - ), -]; - const SCOPE_ALL: &str = "openshell:all"; /// Authorization policy configuration. @@ -176,10 +64,12 @@ impl AuthzPolicy { /// (authentication-only mode for providers like GitHub). #[allow(clippy::result_large_err)] pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { - let required = if ADMIN_METHODS.contains(&method) { - &self.admin_role - } else { - &self.user_role + let required = match method_authz::required_role(method) { + Some(Role::Admin) => &self.admin_role, + // Default to user role for unknown methods, matching the + // pre-annotation behavior. The exhaustiveness test ensures + // every real RPC has an explicit declaration. + Some(Role::User) | None => &self.user_role, }; // Empty role name = skip role check for this level (auth-only mode). @@ -218,10 +108,7 @@ impl AuthzPolicy { return Ok(()); } - let required_scope = SCOPED_METHODS - .iter() - .find(|(m, _)| *m == method) - .map_or(SCOPE_ALL, |(_, s)| *s); + let required_scope = method_authz::required_scope(method).unwrap_or(SCOPE_ALL); if identity.scopes.iter().any(|s| s == required_scope) { return Ok(()); diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs new file mode 100644 index 000000000..ec8dc5bca --- /dev/null +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Aggregated auth metadata for every gRPC method. +//! +//! The per-method tables are generated by `#[rpc_authz]` (see +//! `openshell-server-macros`) and live next to each service's `impl` +//! block. This module merges them and exposes the lookup functions +//! consumed by `authz.rs` (role/scope), `oidc.rs` (unauthenticated +//! check), and `sandbox_methods.rs` (sandbox principal allowlist). + +/// Per-method auth metadata emitted by `#[rpc_authz]`. +/// +/// Built at compile time and looked up at request-dispatch time. +#[derive(Debug, Clone, Copy)] +pub struct MethodAuth { + /// Canonical gRPC path (`/package.Service/Method`). + pub path: &'static str, + /// Authentication mode for the method. + pub mode: AuthMode, + /// Required OIDC scope on the Bearer path. `None` when the method + /// is `unauthenticated` or `sandbox`-only. + pub scope: Option<&'static str>, + /// Required role on the Bearer path. `None` when the method is + /// `unauthenticated` or `sandbox`-only. + pub role: Option, +} + +/// How a gRPC method is authenticated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + /// No authentication required (e.g. health probes). + Unauthenticated, + /// Only callable by a `Principal::Sandbox` (gateway-minted sandbox JWT). + /// See `auth/sandbox_jwt.rs`. + Sandbox, + /// Bearer (OIDC) authentication required. + Bearer, + /// Either sandbox principal or Bearer; scope and role apply on + /// the Bearer path only. + Dual, +} + +/// Coarse role mapping. Maps to the configured `admin_role` / +/// `user_role` names at runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Admin, + User, +} + +/// All per-service auth tables in one flat list. +/// +/// Add a new service by appending its module's `AUTH_METADATA` const here. +/// The constant name is fixed by `#[rpc_authz]`; service disambiguation +/// comes from the module path. +const SERVICES: &[&[MethodAuth]] = &[crate::grpc::AUTH_METADATA, crate::inference::AUTH_METADATA]; + +/// Find the auth metadata for `method`, if any. +#[must_use] +pub fn lookup(method: &str) -> Option<&'static MethodAuth> { + for table in SERVICES { + if let Some(entry) = table.iter().find(|m| m.path == method) { + return Some(entry); + } + } + None +} + +/// All registered RPC paths across every service. Used by tests. +#[cfg(test)] +pub fn all_paths() -> impl Iterator { + SERVICES.iter().flat_map(|s| s.iter()).map(|m| m.path) +} + +/// Required Bearer scope for the method, or `None` if scopes don't +/// apply (`unauthenticated`, `sandbox`). +#[must_use] +pub fn required_scope(method: &str) -> Option<&'static str> { + lookup(method).and_then(|m| m.scope) +} + +/// Required role for the method on the Bearer path. +#[must_use] +pub fn required_role(method: &str) -> Option { + lookup(method).and_then(|m| m.role) +} + +/// `true` if the method bypasses authentication entirely. +/// +/// Note: this checks the per-method tables only. The prefix-based +/// bypass for gRPC reflection and health probes is layered on top by +/// `oidc::is_unauthenticated_method`. +#[must_use] +pub fn is_unauthenticated(method: &str) -> bool { + matches!( + lookup(method).map(|m| m.mode), + Some(AuthMode::Unauthenticated) + ) +} + +/// `true` if the method is callable by a `Principal::Sandbox` +/// (`sandbox` or `dual` auth mode). +#[must_use] +pub fn is_sandbox_callable(method: &str) -> bool { + matches!( + lookup(method).map(|m| m.mode), + Some(AuthMode::Sandbox | AuthMode::Dual) + ) +} + +/// `true` if the method is callable by a `Principal::User` (`bearer` or +/// `dual` auth mode). +/// +/// Unknown methods return `true` so [`AuthzPolicy::check`] still gets a +/// chance to evaluate role/scope and apply the `openshell:all` fallback — +/// the exhaustiveness test prevents this branch from ever firing for real +/// RPCs, but it remains as defense-in-depth. +#[must_use] +pub fn is_user_callable(method: &str) -> bool { + match lookup(method).map(|m| m.mode) { + Some(AuthMode::Sandbox | AuthMode::Unauthenticated) => false, + Some(AuthMode::Bearer | AuthMode::Dual) | None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use prost::Message; + use prost_types::FileDescriptorSet; + + /// Every RPC declared in any proto under `proto/` must have an + /// `#[rpc_auth(...)]` annotation on its handler. This catches: + /// - new RPCs added to a proto but no annotation on the handler + /// - typo'd method names in annotations (path mismatch) + /// - services that were never given an `#[rpc_authz]` impl + #[test] + fn every_proto_rpc_has_an_annotation() { + let set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor set"); + + let mut missing: Vec = Vec::new(); + + for file in &set.file { + let package = file.package.as_deref().unwrap_or(""); + // Only check services the gateway actually serves. Skip the + // compute-driver, sandbox supervisor, and test protos because + // those are not surfaced through the gateway's gRPC server. + if package != "openshell.v1" && package != "openshell.inference.v1" { + continue; + } + for svc in &file.service { + let svc_name = svc.name.as_deref().unwrap_or(""); + for method in &svc.method { + let method_name = method.name.as_deref().unwrap_or(""); + let path = format!("/{package}.{svc_name}/{method_name}"); + if lookup(&path).is_none() { + missing.push(path); + } + } + } + } + + assert!( + missing.is_empty(), + "RPC methods missing #[rpc_auth] annotation: {missing:?}" + ); + } + + /// Every annotated path must exist as a real RPC in some proto. This + /// catches stale annotations after an RPC is removed or renamed. + #[test] + fn every_annotated_path_matches_a_real_rpc() { + let set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor set"); + + let mut proto_paths: Vec = Vec::new(); + for file in &set.file { + let package = file.package.as_deref().unwrap_or(""); + for svc in &file.service { + let svc_name = svc.name.as_deref().unwrap_or(""); + for method in &svc.method { + let method_name = method.name.as_deref().unwrap_or(""); + proto_paths.push(format!("/{package}.{svc_name}/{method_name}")); + } + } + } + + let mut stale: Vec<&'static str> = Vec::new(); + for path in all_paths() { + if !proto_paths.iter().any(|p| p == path) { + stale.push(path); + } + } + + assert!( + stale.is_empty(), + "annotated paths that don't match any real proto RPC: {stale:?}" + ); + } + + /// Sanity check: no path appears in more than one service table. + #[test] + fn no_duplicate_paths_across_services() { + let mut seen: Vec<&'static str> = Vec::new(); + for path in all_paths() { + assert!( + !seen.contains(&path), + "duplicate path across tables: {path}" + ); + seen.push(path); + } + } + + /// User principals must be rejected for `sandbox` and `unauthenticated` + /// methods; the auth-mode check at the router enforces this regardless + /// of role/scope, closing the gap where a token with `openshell:all` + /// could otherwise reach sandbox-only handlers. + #[test] + fn user_callable_matches_auth_mode() { + // Bearer + assert!(is_user_callable("/openshell.v1.OpenShell/ListSandboxes")); + // Dual + assert!(is_user_callable("/openshell.v1.OpenShell/GetSandboxConfig")); + // Sandbox-only + assert!(!is_user_callable( + "/openshell.v1.OpenShell/ReportPolicyStatus" + )); + assert!(!is_user_callable("/openshell.v1.OpenShell/PushSandboxLogs")); + assert!(!is_user_callable( + "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" + )); + assert!(!is_user_callable( + "/openshell.v1.OpenShell/SubmitPolicyAnalysis" + )); + assert!(!is_user_callable( + "/openshell.v1.OpenShell/ConnectSupervisor" + )); + assert!(!is_user_callable("/openshell.v1.OpenShell/RelayStream")); + assert!(!is_user_callable( + "/openshell.inference.v1.Inference/GetInferenceBundle" + )); + // Unauthenticated methods are not "user callable" — they're + // intercepted before principal evaluation. + assert!(!is_user_callable("/openshell.v1.OpenShell/Health")); + // Unknown method falls through to AuthzPolicy::check. + assert!(is_user_callable("/openshell.v1.OpenShell/FutureMethod")); + } +} diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index ca032a006..cbf3b94d9 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -14,6 +14,7 @@ pub mod guard; mod http; pub mod identity; pub mod k8s_sa; +pub mod method_authz; pub mod oidc; pub mod principal; pub mod sandbox_jwt; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index 5e5a23500..bf5490f2a 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -25,18 +25,16 @@ use tokio::sync::RwLock; use tonic::Status; use tracing::{debug, info, warn}; -/// Truly unauthenticated methods — health probes and infrastructure. -const UNAUTHENTICATED_METHODS: &[&str] = &[ - "/openshell.v1.OpenShell/Health", - "/openshell.inference.v1.Inference/Health", -]; - /// Path prefixes that bypass OIDC validation (gRPC reflection, health probes). +/// +/// These are structural bypasses for gRPC infrastructure that doesn't map to a +/// single RPC method. Per-method bypasses (e.g. `Health`) are declared at the +/// handler with `#[rpc_auth(auth = "unauthenticated")]`. const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; /// Returns `true` if the method needs no authentication at all. pub fn is_unauthenticated_method(path: &str) -> bool { - UNAUTHENTICATED_METHODS.contains(&path) + super::method_authz::is_unauthenticated(path) || UNAUTHENTICATED_PREFIXES .iter() .any(|prefix| path.starts_with(prefix)) diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index e03b8eeb6..76d5e1324 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -7,25 +7,13 @@ //! must not authorize user-facing or admin APIs. The router rejects sandbox //! principals for every method outside this supervisor-to-gateway allowlist; //! handlers still perform same-sandbox checks on request bodies. - -/// Methods a `Principal::Sandbox` may invoke. -const ALLOWED_SANDBOX_METHODS: &[&str] = &[ - "/openshell.v1.OpenShell/IssueSandboxToken", - "/openshell.v1.OpenShell/RefreshSandboxToken", - "/openshell.v1.OpenShell/ConnectSupervisor", - "/openshell.v1.OpenShell/RelayStream", - "/openshell.v1.OpenShell/GetSandboxConfig", - "/openshell.v1.OpenShell/GetSandboxProviderEnvironment", - "/openshell.v1.OpenShell/UpdateConfig", - "/openshell.v1.OpenShell/ReportPolicyStatus", - "/openshell.v1.OpenShell/PushSandboxLogs", - "/openshell.v1.OpenShell/SubmitPolicyAnalysis", - "/openshell.v1.OpenShell/GetDraftPolicy", - "/openshell.inference.v1.Inference/GetInferenceBundle", -]; +//! +//! The allowlist is derived from per-handler `#[rpc_auth(...)]` annotations: +//! a method is callable by a sandbox principal when its declared auth mode is +//! `sandbox` or `dual`. pub fn is_sandbox_callable(path: &str) -> bool { - ALLOWED_SANDBOX_METHODS.contains(&path) + super::method_authz::is_sandbox_callable(path) } #[cfg(test)] diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 8538c8658..32885a9a9 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -51,6 +51,7 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use crate::ServerState; +use openshell_server_macros::rpc_authz; // --------------------------------------------------------------------------- // Public re-exports @@ -192,8 +193,10 @@ impl OpenShellService { // Trait impl — thin delegation to submodules // --------------------------------------------------------------------------- +#[rpc_authz(service = "openshell.v1.OpenShell")] #[tonic::async_trait] impl OpenShell for OpenShellService { + #[rpc_auth(auth = "unauthenticated")] async fn health( &self, _request: Request, @@ -206,6 +209,7 @@ impl OpenShell for OpenShellService { // --- Sandbox lifecycle --- + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_sandbox( &self, request: Request, @@ -215,6 +219,7 @@ impl OpenShell for OpenShellService { type WatchSandboxStream = ReceiverStream>; + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn watch_sandbox( &self, request: Request, @@ -222,6 +227,7 @@ impl OpenShell for OpenShellService { sandbox::handle_watch_sandbox(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox( &self, request: Request, @@ -229,6 +235,7 @@ impl OpenShell for OpenShellService { sandbox::handle_get_sandbox(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandboxes( &self, request: Request, @@ -236,6 +243,7 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandboxes(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandbox_providers( &self, request: Request, @@ -243,6 +251,7 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandbox_providers(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn attach_sandbox_provider( &self, request: Request, @@ -250,6 +259,7 @@ impl OpenShell for OpenShellService { sandbox::handle_attach_sandbox_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn detach_sandbox_provider( &self, request: Request, @@ -257,6 +267,7 @@ impl OpenShell for OpenShellService { sandbox::handle_detach_sandbox_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn delete_sandbox( &self, request: Request, @@ -268,6 +279,7 @@ impl OpenShell for OpenShellService { type ExecSandboxStream = ReceiverStream>; + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox( &self, request: Request, @@ -278,6 +290,7 @@ impl OpenShell for OpenShellService { type ForwardTcpStream = Pin> + Send + 'static>>; + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn forward_tcp( &self, request: Request>, @@ -287,6 +300,7 @@ impl OpenShell for OpenShellService { type ExecSandboxInteractiveStream = ReceiverStream>; + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox_interactive( &self, request: Request>, @@ -296,6 +310,7 @@ impl OpenShell for OpenShellService { // --- SSH sessions --- + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_ssh_session( &self, request: Request, @@ -303,6 +318,7 @@ impl OpenShell for OpenShellService { sandbox::handle_create_ssh_session(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn expose_service( &self, request: Request, @@ -310,6 +326,7 @@ impl OpenShell for OpenShellService { service::handle_expose_service(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_service( &self, request: Request, @@ -317,6 +334,7 @@ impl OpenShell for OpenShellService { service::handle_get_service(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_services( &self, request: Request, @@ -324,6 +342,7 @@ impl OpenShell for OpenShellService { service::handle_list_services(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn delete_service( &self, request: Request, @@ -331,6 +350,7 @@ impl OpenShell for OpenShellService { service::handle_delete_service(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn revoke_ssh_session( &self, request: Request, @@ -340,6 +360,7 @@ impl OpenShell for OpenShellService { // --- Providers --- + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn create_provider( &self, request: Request, @@ -347,6 +368,7 @@ impl OpenShell for OpenShellService { provider::handle_create_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider( &self, request: Request, @@ -354,6 +376,7 @@ impl OpenShell for OpenShellService { provider::handle_get_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_providers( &self, request: Request, @@ -361,6 +384,7 @@ impl OpenShell for OpenShellService { provider::handle_list_providers(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_provider_profiles( &self, request: Request, @@ -368,6 +392,7 @@ impl OpenShell for OpenShellService { provider::handle_list_provider_profiles(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider_profile( &self, request: Request, @@ -375,6 +400,7 @@ impl OpenShell for OpenShellService { provider::handle_get_provider_profile(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn import_provider_profiles( &self, request: Request, @@ -382,6 +408,7 @@ impl OpenShell for OpenShellService { provider::handle_import_provider_profiles(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn lint_provider_profiles( &self, request: Request, @@ -389,6 +416,7 @@ impl OpenShell for OpenShellService { provider::handle_lint_provider_profiles(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn update_provider( &self, request: Request, @@ -396,6 +424,7 @@ impl OpenShell for OpenShellService { provider::handle_update_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider_refresh_status( &self, request: Request, @@ -403,6 +432,7 @@ impl OpenShell for OpenShellService { provider::handle_get_provider_refresh_status(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn configure_provider_refresh( &self, request: Request, @@ -410,6 +440,7 @@ impl OpenShell for OpenShellService { provider::handle_configure_provider_refresh(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn rotate_provider_credential( &self, request: Request, @@ -417,6 +448,7 @@ impl OpenShell for OpenShellService { provider::handle_rotate_provider_credential(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider_refresh( &self, request: Request, @@ -424,6 +456,7 @@ impl OpenShell for OpenShellService { provider::handle_delete_provider_refresh(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider( &self, request: Request, @@ -431,6 +464,7 @@ impl OpenShell for OpenShellService { provider::handle_delete_provider(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider_profile( &self, request: Request, @@ -440,6 +474,7 @@ impl OpenShell for OpenShellService { // --- Config / Policy --- + #[rpc_auth(auth = "dual", scope = "config:read", role = "user")] async fn get_sandbox_config( &self, request: Request, @@ -447,6 +482,7 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_config(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:read", role = "user")] async fn get_gateway_config( &self, request: Request, @@ -454,6 +490,7 @@ impl OpenShell for OpenShellService { policy::handle_get_gateway_config(&self.state, request).await } + #[rpc_auth(auth = "sandbox")] async fn get_sandbox_provider_environment( &self, request: Request, @@ -461,6 +498,7 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + #[rpc_auth(auth = "dual", scope = "config:write", role = "admin")] async fn update_config( &self, request: Request, @@ -468,6 +506,7 @@ impl OpenShell for OpenShellService { policy::handle_update_config(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox_policy_status( &self, request: Request, @@ -475,6 +514,7 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_policy_status(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandbox_policies( &self, request: Request, @@ -482,6 +522,7 @@ impl OpenShell for OpenShellService { policy::handle_list_sandbox_policies(&self.state, request).await } + #[rpc_auth(auth = "sandbox")] async fn report_policy_status( &self, request: Request, @@ -491,6 +532,7 @@ impl OpenShell for OpenShellService { // --- Sandbox logs --- + #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox_logs( &self, request: Request, @@ -498,6 +540,7 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_logs(&self.state, request).await } + #[rpc_auth(auth = "sandbox")] async fn push_sandbox_logs( &self, request: Request>, @@ -507,6 +550,7 @@ impl OpenShell for OpenShellService { // --- Draft policy recommendations --- + #[rpc_auth(auth = "sandbox")] async fn submit_policy_analysis( &self, request: Request, @@ -514,6 +558,7 @@ impl OpenShell for OpenShellService { policy::handle_submit_policy_analysis(&self.state, request).await } + #[rpc_auth(auth = "dual", scope = "config:read", role = "user")] async fn get_draft_policy( &self, request: Request, @@ -521,6 +566,7 @@ impl OpenShell for OpenShellService { policy::handle_get_draft_policy(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn approve_draft_chunk( &self, request: Request, @@ -528,6 +574,7 @@ impl OpenShell for OpenShellService { policy::handle_approve_draft_chunk(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn reject_draft_chunk( &self, request: Request, @@ -535,6 +582,7 @@ impl OpenShell for OpenShellService { policy::handle_reject_draft_chunk(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn approve_all_draft_chunks( &self, request: Request, @@ -542,6 +590,7 @@ impl OpenShell for OpenShellService { policy::handle_approve_all_draft_chunks(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn edit_draft_chunk( &self, request: Request, @@ -549,6 +598,7 @@ impl OpenShell for OpenShellService { policy::handle_edit_draft_chunk(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn undo_draft_chunk( &self, request: Request, @@ -556,6 +606,7 @@ impl OpenShell for OpenShellService { policy::handle_undo_draft_chunk(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn clear_draft_chunks( &self, request: Request, @@ -563,6 +614,7 @@ impl OpenShell for OpenShellService { policy::handle_clear_draft_chunks(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:read", role = "user")] async fn get_draft_history( &self, request: Request, @@ -572,6 +624,7 @@ impl OpenShell for OpenShellService { // --- Sandbox identity --- + #[rpc_auth(auth = "sandbox")] async fn issue_sandbox_token( &self, request: Request, @@ -579,6 +632,7 @@ impl OpenShell for OpenShellService { auth_rpc::handle_issue_sandbox_token(&self.state, request).await } + #[rpc_auth(auth = "sandbox")] async fn refresh_sandbox_token( &self, request: Request, @@ -591,6 +645,7 @@ impl OpenShell for OpenShellService { type ConnectSupervisorStream = Pin> + Send + 'static>>; + #[rpc_auth(auth = "sandbox")] async fn connect_supervisor( &self, request: Request>, @@ -601,6 +656,7 @@ impl OpenShell for OpenShellService { type RelayStreamStream = Pin> + Send + 'static>>; + #[rpc_auth(auth = "sandbox")] async fn relay_stream( &self, request: Request>, diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 183a80e74..f393caab3 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -12,6 +12,7 @@ use openshell_core::proto::{ }; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; +use openshell_server_macros::rpc_authz; use prost::Message as _; use std::sync::Arc; use std::time::Duration; @@ -55,8 +56,10 @@ impl ObjectType for InferenceRoute { } } +#[rpc_authz(service = "openshell.inference.v1.Inference")] #[tonic::async_trait] impl Inference for InferenceService { + #[rpc_auth(auth = "sandbox")] async fn get_inference_bundle( &self, request: Request, @@ -71,6 +74,7 @@ impl Inference for InferenceService { .map(Response::new) } + #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] async fn set_cluster_inference( &self, request: Request, @@ -105,6 +109,7 @@ impl Inference for InferenceService { })) } + #[rpc_auth(auth = "bearer", scope = "inference:read", role = "user")] async fn get_cluster_inference( &self, request: Request, diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 4fcb3993a..ca4fdccd0 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -442,6 +442,11 @@ where match principal { Principal::User(ref user) => { + if !crate::auth::method_authz::is_user_callable(&path) { + return Ok(status_response(tonic::Status::permission_denied( + "this method requires a sandbox principal", + ))); + } if let Some(ref policy) = authz_policy && let Err(status) = policy.check(&user.identity, &path) { @@ -1193,6 +1198,54 @@ mod tests { )); } + /// A user principal — even one carrying `openshell:all` and the + /// admin role — must not reach a `sandbox`-annotated method. The + /// router enforces this from the per-handler auth-mode declarations + /// independent of RBAC. + #[tokio::test] + async fn user_principal_is_denied_on_sandbox_only_methods() { + fn admin_user() -> Principal { + Principal::User(UserPrincipal { + identity: Identity { + subject: "admin".to_string(), + display_name: None, + roles: vec!["openshell-admin".to_string()], + scopes: vec!["openshell:all".to_string()], + provider: IdentityProvider::Oidc, + }, + }) + } + + let policy = AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + scopes_enabled: true, + }; + + for path in [ + "/openshell.v1.OpenShell/ReportPolicyStatus", + "/openshell.v1.OpenShell/PushSandboxLogs", + "/openshell.v1.OpenShell/SubmitPolicyAnalysis", + "/openshell.v1.OpenShell/GetSandboxProviderEnvironment", + "/openshell.v1.OpenShell/ConnectSupervisor", + "/openshell.v1.OpenShell/RelayStream", + "/openshell.v1.OpenShell/IssueSandboxToken", + "/openshell.v1.OpenShell/RefreshSandboxToken", + "/openshell.inference.v1.Inference/GetInferenceBundle", + ] { + let mock = Arc::new(MockAuthenticator::returning(Ok(Some(admin_user())))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy.clone())); + + let res = router.call(empty_request(path)).await.unwrap(); + + assert!(seen.lock().unwrap().is_none(), "{path} reached handler"); + // grpc-status=7 (PERMISSION_DENIED). + assert_eq!(grpc_status(&res).as_deref(), Some("7"), "{path}"); + } + } + #[tokio::test] async fn sandbox_principal_is_denied_on_user_and_admin_methods() { for path in [