Files
OpenShell/crates/openshell-core/build.rs
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

125 lines
4.6 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use std::env;
use std::path::{Path, PathBuf};
const PROTO_REL: &str = "../../proto";
fn main() -> Result<(), Box<dyn std::error::Error>> {
// --- Git-derived version ---
// Compute a version from `git describe` for local builds. In Docker/CI
// builds where .git is absent, this silently does nothing and the binary
// falls back to CARGO_PKG_VERSION (which is already sed-patched by the
// build pipeline).
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/tags");
if let Some(version) = git_version() {
println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}");
}
// --- Protobuf compilation ---
// Re-run when anything under proto/ changes (including newly added .proto files).
println!("cargo:rerun-if-changed={PROTO_REL}");
// Use bundled protoc from protobuf-src. The system protoc (from apt-get)
// does not bundle the well-known type includes (google/protobuf/struct.proto
// etc.), so we must use protobuf-src which ships both the binary and the
// include tree.
// SAFETY: This is run at build time in a single-threaded build script context.
// No other threads are reading environment variables concurrently.
#[allow(unsafe_code)]
unsafe {
env::set_var("PROTOC", protobuf_src::protoc());
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
let proto_root = manifest_dir.join(PROTO_REL);
let mut proto_files = Vec::new();
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(())
}
fn collect_proto_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_proto_files(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "proto") {
out.push(path);
}
}
Ok(())
}
/// Derive a version string from `git describe --tags`.
///
/// Implements the "guess-next-dev" convention used by the release pipeline
/// (`setuptools-scm`): when there are commits past the last tag, the patch
/// version is bumped and `-dev.<N>+g<sha>` is appended.
///
/// Examples:
/// on tag v0.0.3 → "0.0.3"
/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969"
///
/// Returns `None` when git is unavailable or the repo has no matching tags.
fn git_version() -> Option<String> {
// Match numeric release tags only (e.g. `v0.0.29`). The bare glob `v*`
// also matches non-release tags like `vm-dev` or `vm-prod`; when one of
// those lands on the same commit as a release tag, `git describe` picks
// it and the resulting version string collapses to `m-dev` after the
// leading `v` is stripped below. Requiring a digit after `v` excludes
// those development tags without losing any release tag.
let output = std::process::Command::new("git")
.args(["describe", "--tags", "--long", "--match", "v[0-9]*"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let desc = String::from_utf8(output.stdout).ok()?;
let desc = desc.trim();
let desc = desc.strip_prefix('v').unwrap_or(desc);
// `git describe --long` format: <tag>-<N>-g<sha>
// Split from the right to handle tags that contain hyphens.
let (rest, sha) = desc.rsplit_once('-')?;
let (tag, commits_str) = rest.rsplit_once('-')?;
let commits: u32 = commits_str.parse().ok()?;
if commits == 0 {
// Exactly on a tag — use the tag version as-is.
return Some(tag.to_string());
}
// Bump patch version (guess-next-dev scheme).
let mut parts = tag.splitn(3, '.');
let major = parts.next()?;
let minor = parts.next()?;
let patch: u32 = parts.next()?.parse().ok()?;
Some(format!("{major}.{minor}.{}-dev.{commits}+{sha}", patch + 1))
}