Files
OpenShell/crates/openshell-server-macros/src/lib.rs
T
Mrunal Patel 3f520dd4e2 feat(server): declare gRPC auth (mode + scope + role) at the handler, enforce at the router (#1596)
* feat(server): per-handler gRPC auth annotations

Move scope, role, and auth-mode metadata to the handler definition site
via #[rpc_authz] + #[rpc_auth] proc macros. The previously hand-maintained
SCOPED_METHODS, ADMIN_METHODS, UNAUTHENTICATED_METHODS, and
ALLOWED_SANDBOX_METHODS tables are now generated from per-method
annotations on the tonic service impls, with canonical gRPC paths
derived from the service name and method name.

Adds a new openshell-server-macros proc-macro crate, an aggregator in
auth/method_authz.rs, and an exhaustiveness test that decodes the
protobuf FileDescriptorSet (now emitted by openshell-core/build.rs) and
verifies every RPC has an annotation.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* refactor(server): rename `sandbox-secret` auth mode to `sandbox`

PR #1404 replaced the shared sandbox secret with per-sandbox
gateway-minted JWTs. A handler marked `sandbox` now authenticates as a
specific `Principal::Sandbox`, not as a holder of a shared credential.

Rename `auth = "sandbox-secret"` to `auth = "sandbox"` and
`AuthMode::SandboxSecret` to `AuthMode::Sandbox` so the name matches
the post-#1404 identity model.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* fix(server): enforce per-handler AuthMode at the router

Addresses review feedback on the per-handler auth-annotation work.

- Router-level enforcement of #[rpc_auth] auth mode (HIGH). The previous
  router only checked is_sandbox_callable() for Principal::Sandbox; user
  principals still flowed into AuthzPolicy::check() and bypassed the
  per-handler declaration. A user with `openshell:all` could therefore
  reach `sandbox`-only handlers like GetSandboxProviderEnvironment,
  ReportPolicyStatus, PushSandboxLogs, and SubmitPolicyAnalysis even
  though their annotations said sandbox-only. Adds an
  is_user_callable() predicate and rejects User principals at the
  router for `sandbox` / `unauthenticated` methods.

- Proc macro now errors on duplicate keys in #[rpc_auth(...)] (LOW). A
  second `auth`, `scope`, or `role` previously silently overwrote the
  first value; now it fails to compile.

- Regression tests: a unit test for is_user_callable() and a router
  test that proves a user with admin role + openshell:all cannot reach
  the nine sandbox-only handlers.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* docs(server): finish renaming sandbox-secret to sandbox in method_authz doc comments

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* refactor(server-macros): drop standalone `rpc_auth` stub

The stub was a safety net that fired only when a method had
`#[rpc_auth(...)]` without an enclosing `#[rpc_authz]`. Triggering it
required `rpc_auth` to be imported, which is why both call sites carried
`#[allow(unused_imports)] use openshell_server_macros::{rpc_auth, rpc_authz};`.

Drop the stub and the unused-import workaround. A missing `#[rpc_authz]`
now surfaces as rustc's standard "cannot find attribute `rpc_auth` in
this scope" — clear enough, and one fewer import + lint exception.

Addresses review comment on PR #1596.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* refactor(server-macros): emit fixed `AUTH_METADATA` const per service

The previous trait-derived const name turned `OpenShell` into
`OPEN_SHELL_AUTH_METADATA`, splitting the project name across an
underscore. Each impl already lives in its own module
(`crate::grpc::`, `crate::inference::`), so the module path is enough
to disambiguate between services — a fixed `AUTH_METADATA` name reads
more naturally.

Aggregator in `auth/method_authz.rs` now references
`crate::grpc::AUTH_METADATA` and `crate::inference::AUTH_METADATA`
directly.

Addresses review comment on PR #1596.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* docs(server-macros): fix typo in AUTH_METADATA_CONST doc comment

OpenShell is one word; reference name in the doc should be
OPENSHELL_AUTH_METADATA, not OPEN_SHELL_AUTH_METADATA.

Addresses review nit on PR #1596.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

---------

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
2026-05-27 14:14:54 -07:00

329 lines
11 KiB
Rust

// 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<Self> {
let key: Ident = input.parse()?;
if key != "service" {
return Err(Error::new(
key.span(),
"expected `service = \"<proto.service.name>\"`",
));
}
let _eq: Token![=] = input.parse()?;
let service: LitStr = input.parse()?;
Ok(Self { service })
}
}
struct RpcAuth {
mode: AuthMode,
scope: Option<LitStr>,
role: Option<RoleLit>,
}
#[derive(Clone, Copy)]
enum AuthMode {
Unauthenticated,
Sandbox,
Bearer,
Dual,
}
#[derive(Clone, Copy)]
enum RoleLit {
Admin,
User,
}
impl RpcAuth {
fn parse(meta: &Meta) -> Result<Self> {
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<AuthMode> = None;
let mut scope: Option<LitStr> = None;
let mut role: Option<RoleLit> = 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<AuthMode> {
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<RoleLit> {
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<Ident> {
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<proc_macro2::TokenStream> {
// `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<proc_macro2::TokenStream> = Vec::new();
let mut seen_paths: Vec<String> = Vec::new();
for impl_item in &mut item.items {
let ImplItem::Fn(method) = impl_item else {
continue;
};
let mut found: Option<Meta> = 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
})
}