@@ -349,6 +349,41 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu
349349 Ok ( ( ) )
350350}
351351
352+ async fn resolve_sandbox_by_name_for_principal (
353+ store : & Store ,
354+ principal : & Principal ,
355+ name : & str ,
356+ ) -> Result < Sandbox , Status > {
357+ let sandbox = store
358+ . get_message_by_name :: < Sandbox > ( name)
359+ . await
360+ . map_err ( |e| Status :: internal ( format ! ( "fetch sandbox failed: {e}" ) ) ) ?;
361+
362+ match principal {
363+ Principal :: Sandbox ( _) => {
364+ let Some ( sandbox) = sandbox else {
365+ return Err ( Status :: permission_denied (
366+ "sandbox not found or not owned by caller" ,
367+ ) ) ;
368+ } ;
369+ crate :: auth:: guard:: ensure_sandbox_scope ( principal, sandbox. object_id ( ) ) . map_err (
370+ |status| {
371+ if status. code ( ) == tonic:: Code :: PermissionDenied {
372+ Status :: permission_denied ( "sandbox not found or not owned by caller" )
373+ } else {
374+ status
375+ }
376+ } ,
377+ ) ?;
378+ Ok ( sandbox)
379+ }
380+ Principal :: User ( _) => sandbox. ok_or_else ( || Status :: not_found ( "sandbox not found" ) ) ,
381+ Principal :: Anonymous => Err ( Status :: unauthenticated (
382+ "sandbox-scoped methods require an authenticated caller" ,
383+ ) ) ,
384+ }
385+ }
386+
352387// ---------------------------------------------------------------------------
353388// Config handlers
354389// ---------------------------------------------------------------------------
@@ -672,21 +707,14 @@ pub(super) async fn handle_update_config(
672707 let req = request. into_inner ( ) ;
673708 if sandbox_caller {
674709 validate_sandbox_caller_update ( & req) ?;
675- // Resolve req.name to a sandbox UUID and verify the calling
676- // sandbox principal owns it. User callers (CLI / TUI) bypass
677- // this check because RBAC was their gate.
678- let sandbox = state
679- . store
680- . get_message_by_name :: < Sandbox > ( & req. name )
681- . await
682- . map_err ( |e| Status :: internal ( format ! ( "fetch sandbox failed: {e}" ) ) ) ?
683- . ok_or_else ( || Status :: not_found ( "sandbox not found" ) ) ?;
684- crate :: auth:: guard:: ensure_sandbox_scope (
710+ resolve_sandbox_by_name_for_principal (
711+ state. store . as_ref ( ) ,
685712 principal
686713 . as_ref ( )
687714 . expect ( "sandbox_caller implies principal" ) ,
688- sandbox. object_id ( ) ,
689- ) ?;
715+ & req. name ,
716+ )
717+ . await ?;
690718 }
691719 let key = req. setting_key . trim ( ) ;
692720 let has_policy = req. policy . is_some ( ) ;
@@ -1413,16 +1441,9 @@ pub(super) async fn handle_submit_policy_analysis(
14131441 return Err ( Status :: invalid_argument ( "name is required" ) ) ;
14141442 }
14151443
1416- let sandbox = state
1417- . store
1418- . get_message_by_name :: < Sandbox > ( & req. name )
1419- . await
1420- . map_err ( |e| Status :: internal ( format ! ( "fetch sandbox failed: {e}" ) ) ) ?
1421- . ok_or_else ( || Status :: not_found ( "sandbox not found" ) ) ?;
1444+ let sandbox =
1445+ resolve_sandbox_by_name_for_principal ( state. store . as_ref ( ) , & principal, & req. name ) . await ?;
14221446 let sandbox_id = sandbox. object_id ( ) . to_string ( ) ;
1423- // Name → id resolved; now enforce that a sandbox principal only acts
1424- // on its own sandbox. User principals are unaffected.
1425- crate :: auth:: guard:: ensure_sandbox_scope ( & principal, & sandbox_id) ?;
14261447
14271448 let current_version = state
14281449 . store
@@ -1549,14 +1570,9 @@ pub(super) async fn handle_get_draft_policy(
15491570 return Err ( Status :: invalid_argument ( "name is required" ) ) ;
15501571 }
15511572
1552- let sandbox = state
1553- . store
1554- . get_message_by_name :: < Sandbox > ( & req. name )
1555- . await
1556- . map_err ( |e| Status :: internal ( format ! ( "fetch sandbox failed: {e}" ) ) ) ?
1557- . ok_or_else ( || Status :: not_found ( "sandbox not found" ) ) ?;
1573+ let sandbox =
1574+ resolve_sandbox_by_name_for_principal ( state. store . as_ref ( ) , & principal, & req. name ) . await ?;
15581575 let sandbox_id = sandbox. object_id ( ) . to_string ( ) ;
1559- crate :: auth:: guard:: ensure_sandbox_scope ( & principal, & sandbox_id) ?;
15601576
15611577 let status_filter = if req. status_filter . is_empty ( ) {
15621578 None
@@ -3151,6 +3167,58 @@ mod tests {
31513167 assert_eq ! ( err. code( ) , Code :: PermissionDenied ) ;
31523168 }
31533169
3170+ #[ tokio:: test]
3171+ async fn sandbox_update_config_missing_name_returns_permission_denied ( ) {
3172+ let state = test_server_state ( ) . await ;
3173+ let req = with_sandbox (
3174+ Request :: new ( UpdateConfigRequest {
3175+ name : "missing-sandbox" . to_string ( ) ,
3176+ policy : Some ( ProtoSandboxPolicy :: default ( ) ) ,
3177+ ..Default :: default ( )
3178+ } ) ,
3179+ "sb-a" ,
3180+ ) ;
3181+
3182+ let err = handle_update_config ( & state, req)
3183+ . await
3184+ . expect_err ( "missing name must not leak existence to sandbox callers" ) ;
3185+ assert_eq ! ( err. code( ) , Code :: PermissionDenied ) ;
3186+ }
3187+
3188+ #[ tokio:: test]
3189+ async fn sandbox_submit_policy_analysis_missing_name_returns_permission_denied ( ) {
3190+ let state = test_server_state ( ) . await ;
3191+ let req = with_sandbox (
3192+ Request :: new ( SubmitPolicyAnalysisRequest {
3193+ name : "missing-sandbox" . to_string ( ) ,
3194+ ..Default :: default ( )
3195+ } ) ,
3196+ "sb-a" ,
3197+ ) ;
3198+
3199+ let err = handle_submit_policy_analysis ( & state, req)
3200+ . await
3201+ . expect_err ( "missing name must not leak existence to sandbox callers" ) ;
3202+ assert_eq ! ( err. code( ) , Code :: PermissionDenied ) ;
3203+ }
3204+
3205+ #[ tokio:: test]
3206+ async fn sandbox_get_draft_policy_missing_name_returns_permission_denied ( ) {
3207+ let state = test_server_state ( ) . await ;
3208+ let req = with_sandbox (
3209+ Request :: new ( GetDraftPolicyRequest {
3210+ name : "missing-sandbox" . to_string ( ) ,
3211+ status_filter : String :: new ( ) ,
3212+ } ) ,
3213+ "sb-a" ,
3214+ ) ;
3215+
3216+ let err = handle_get_draft_policy ( & state, req)
3217+ . await
3218+ . expect_err ( "missing name must not leak existence to sandbox callers" ) ;
3219+ assert_eq ! ( err. code( ) , Code :: PermissionDenied ) ;
3220+ }
3221+
31543222 #[ tokio:: test]
31553223 async fn user_principal_can_read_any_sandbox_config ( ) {
31563224 // RBAC was the user gate; the IDOR guard must NOT trip for users.
0 commit comments