Skip to content

Commit b3147e9

Browse files
authored
Merge pull request #19 from kagenti/feat/per-user-provider-ownership-1995
feat(auth): per-user provider ownership enforcement
2 parents 3ade5e5 + 1ceb0f3 commit b3147e9

5 files changed

Lines changed: 803 additions & 49 deletions

File tree

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

Lines changed: 24 additions & 27 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,28 @@ mod tests {
417417
.is_ok()
418418
);
419419

420-
let writer_without_admin =
421-
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"));
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 = identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]);
423+
for method in [
424+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
425+
"/openshell.v1.OpenShell/RotateProviderCredential",
426+
"/openshell.v1.OpenShell/DeleteProviderRefresh",
427+
] {
428+
assert!(policy.check(&user_writer, method).is_ok(), "{method}");
429+
}
430430

431-
let admin_without_scope =
432-
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]);
431+
// Without the write scope, user is still denied.
432+
let user_without_scope =
433+
identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]);
433434
let err = policy
434435
.check(
435-
&admin_without_scope,
436-
"/openshell.v1.OpenShell/RotateProviderCredential",
436+
&user_without_scope,
437+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
437438
)
438439
.unwrap_err();
439440
assert_eq!(err.code(), tonic::Code::PermissionDenied);
440441
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-
}
451442
}
452443

453444
#[test]
@@ -493,10 +484,16 @@ mod tests {
493484
.check(&id, "/openshell.v1.OpenShell/GetProvider")
494485
.is_ok()
495486
);
496-
// admin methods still denied by role check
487+
// User can now create providers (ownership enforced in handler).
497488
assert!(
498489
policy
499490
.check(&id, "/openshell.v1.OpenShell/CreateProvider")
491+
.is_ok()
492+
);
493+
// Admin methods still denied by role check.
494+
assert!(
495+
policy
496+
.check(&id, "/openshell.v1.OpenShell/ImportProviderProfiles")
500497
.is_err()
501498
);
502499
}

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)