Skip to content

Commit 32ae5d4

Browse files
committed
feat(auth): add per-user provider ownership enforcement
Extend the openshell.ai/owner label pattern (from sandbox ownership in PR #18) to providers, enabling OIDC-authenticated users to create and manage their own providers on a shared gateway without admin privileges. Changes: - Relax provider CRUD methods from admin to user role in rpc_auth annotations (ImportProviderProfiles/DeleteProviderProfile remain admin) - Stamp owner label on provider create via stamp_owner() - Add ownership checks (check_provider_owner) to get, update, delete, configure-refresh, rotate-credential, and delete-refresh handlers - Filter list_providers to return own + shared for regular users, all for admins - Make ownership.rs docstring entity-generic (shared with sandboxes) - Update authz tests to reflect relaxed role requirements - Add 12 ownership isolation unit tests covering create/get/list/update/ delete with owner, non-owner, admin, and shared provider scenarios Implements kagenti/kagenti#1995 (partial: Tasks 1-2, 5 complete; Tasks 3-4 deferred for follow-up). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
1 parent 3ade5e5 commit 32ae5d4

4 files changed

Lines changed: 716 additions & 47 deletions

File tree

crates/openshell-server/src/auth/authz.rs

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ mod tests {
185185
let policy = default_policy();
186186
assert!(
187187
policy
188-
.check(&id, "/openshell.v1.OpenShell/CreateProvider")
188+
.check(&id, "/openshell.v1.OpenShell/ImportProviderProfiles")
189189
.is_err()
190190
);
191191
}
@@ -408,7 +408,7 @@ mod tests {
408408
}
409409

410410
#[test]
411-
fn provider_refresh_methods_require_provider_scopes_and_admin_for_writes() {
411+
fn provider_refresh_methods_require_provider_scopes_and_user_role() {
412412
let policy = scoped_policy();
413413
let reader = identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]);
414414
assert!(
@@ -417,37 +417,29 @@ mod tests {
417417
.is_ok()
418418
);
419419

420-
let writer_without_admin =
420+
// User with provider:write scope and user role can call write methods
421+
// (ownership enforcement happens in the handler, not RBAC).
422+
let user_writer =
421423
identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]);
422-
let err = policy
423-
.check(
424-
&writer_without_admin,
425-
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
426-
)
427-
.unwrap_err();
428-
assert_eq!(err.code(), tonic::Code::PermissionDenied);
429-
assert!(err.message().contains("openshell-admin"));
424+
for method in [
425+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
426+
"/openshell.v1.OpenShell/RotateProviderCredential",
427+
"/openshell.v1.OpenShell/DeleteProviderRefresh",
428+
] {
429+
assert!(policy.check(&user_writer, method).is_ok(), "{method}");
430+
}
430431

431-
let admin_without_scope =
432-
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]);
432+
// Without the write scope, user is still denied.
433+
let user_without_scope =
434+
identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]);
433435
let err = policy
434436
.check(
435-
&admin_without_scope,
436-
"/openshell.v1.OpenShell/RotateProviderCredential",
437+
&user_without_scope,
438+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
437439
)
438440
.unwrap_err();
439441
assert_eq!(err.code(), tonic::Code::PermissionDenied);
440442
assert!(err.message().contains("provider:write"));
441-
442-
let admin_writer =
443-
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:write"]);
444-
for method in [
445-
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
446-
"/openshell.v1.OpenShell/RotateProviderCredential",
447-
"/openshell.v1.OpenShell/DeleteProviderRefresh",
448-
] {
449-
assert!(policy.check(&admin_writer, method).is_ok(), "{method}");
450-
}
451443
}
452444

453445
#[test]
@@ -493,10 +485,16 @@ mod tests {
493485
.check(&id, "/openshell.v1.OpenShell/GetProvider")
494486
.is_ok()
495487
);
496-
// admin methods still denied by role check
488+
// User can now create providers (ownership enforced in handler).
497489
assert!(
498490
policy
499491
.check(&id, "/openshell.v1.OpenShell/CreateProvider")
492+
.is_ok()
493+
);
494+
// Admin methods still denied by role check.
495+
assert!(
496+
policy
497+
.check(&id, "/openshell.v1.OpenShell/ImportProviderProfiles")
500498
.is_err()
501499
);
502500
}

crates/openshell-server/src/auth/ownership.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
//! Per-user sandbox ownership enforcement.
4+
//! Per-user object ownership enforcement.
55
//!
6-
//! Stamps an `openshell.ai/owner` label on sandboxes at creation time (from the
7-
//! verified caller identity) and gates access on subsequent operations. Admin
8-
//! callers bypass the ownership check.
6+
//! Stamps an `openshell.ai/owner` label on objects (sandboxes, providers) at
7+
//! creation time (from the verified caller identity) and gates access on
8+
//! subsequent operations. Admin callers bypass the ownership check.
99
1010
#![allow(clippy::result_large_err)]
1111

@@ -18,7 +18,7 @@ use tonic::Status;
1818
use super::identity::Identity;
1919
use super::principal::Principal;
2020

21-
/// Reserved label key for sandbox ownership. Server-set, never client-controlled.
21+
/// Reserved label key for object ownership. Server-set, never client-controlled.
2222
pub const OWNER_LABEL: &str = "openshell.ai/owner";
2323

2424
/// Reserved label key prefix. All keys starting with this are stripped from
@@ -84,13 +84,13 @@ pub fn check_owner(
8484
admin_role: &str,
8585
) -> Result<(), Status> {
8686
let Some(owner_value) = sandbox_labels.get(OWNER_LABEL) else {
87-
// No owner label → legacy sandbox, allow access.
87+
// No owner label → legacy/shared object, allow access.
8888
return Ok(());
8989
};
9090

9191
let Some(identity) = principal_identity(principal) else {
9292
return Err(Status::permission_denied(
93-
"sandbox is owned; authenticated identity required",
93+
"object is owned; authenticated identity required",
9494
));
9595
};
9696

@@ -101,7 +101,7 @@ pub fn check_owner(
101101

102102
let caller_value = sanitize_subject(&identity.subject)?;
103103
if caller_value != *owner_value {
104-
return Err(Status::permission_denied("you do not own this sandbox"));
104+
return Err(Status::permission_denied("you do not own this resource"));
105105
}
106106

107107
Ok(())
@@ -140,7 +140,7 @@ fn require_identity(principal: Option<&Principal>) -> Result<&Identity, Status>
140140
match principal {
141141
Some(Principal::User(user)) => Ok(&user.identity),
142142
Some(Principal::Sandbox(_) | Principal::Anonymous) | None => Err(Status::unauthenticated(
143-
"authenticated user identity required for sandbox operations",
143+
"authenticated user identity required for ownership operations",
144144
)),
145145
}
146146
}

crates/openshell-server/src/grpc/mod.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ impl OpenShell for OpenShellService {
356356

357357
// --- Providers ---
358358

359-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
359+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
360360
async fn create_provider(
361361
&self,
362362
request: Request<CreateProviderRequest>,
@@ -412,7 +412,7 @@ impl OpenShell for OpenShellService {
412412
provider::handle_lint_provider_profiles(&self.state, request).await
413413
}
414414

415-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
415+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
416416
async fn update_provider(
417417
&self,
418418
request: Request<UpdateProviderRequest>,
@@ -428,31 +428,31 @@ impl OpenShell for OpenShellService {
428428
provider::handle_get_provider_refresh_status(&self.state, request).await
429429
}
430430

431-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
431+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
432432
async fn configure_provider_refresh(
433433
&self,
434434
request: Request<ConfigureProviderRefreshRequest>,
435435
) -> Result<Response<ConfigureProviderRefreshResponse>, Status> {
436436
provider::handle_configure_provider_refresh(&self.state, request).await
437437
}
438438

439-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
439+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
440440
async fn rotate_provider_credential(
441441
&self,
442442
request: Request<RotateProviderCredentialRequest>,
443443
) -> Result<Response<RotateProviderCredentialResponse>, Status> {
444444
provider::handle_rotate_provider_credential(&self.state, request).await
445445
}
446446

447-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
447+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
448448
async fn delete_provider_refresh(
449449
&self,
450450
request: Request<DeleteProviderRefreshRequest>,
451451
) -> Result<Response<DeleteProviderRefreshResponse>, Status> {
452452
provider::handle_delete_provider_refresh(&self.state, request).await
453453
}
454454

455-
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")]
455+
#[rpc_auth(auth = "bearer", scope = "provider:write", role = "user")]
456456
async fn delete_provider(
457457
&self,
458458
request: Request<DeleteProviderRequest>,

0 commit comments

Comments
 (0)