@@ -146,8 +146,21 @@ pub async fn create_scoped_policy(
146146 actor : Option < axum:: extract:: Extension < AuthenticatedActor > > ,
147147 Json ( request) : Json < CreateScopedPolicyRequest > ,
148148) -> Result < Json < ScopedPolicy > , ( StatusCode , String ) > {
149+ // RBAC does not currently have a first-class "role" scope type.
150+ // Fail closed: only super-admin users may mutate role-scoped policies.
151+ let actor_ref = actor. as_ref ( ) . map ( |e| & e. 0 ) ;
152+ if request. scope . scope_type == PolicyScopeType :: Role
153+ && matches ! ( actor_ref, Some ( AuthenticatedActor :: User ( _) ) )
154+ && !actor_is_super_admin ( & state, actor_ref)
155+ {
156+ return Err ( (
157+ StatusCode :: FORBIDDEN ,
158+ "role_scoped_policy_requires_super_admin" . to_string ( ) ,
159+ ) ) ;
160+ }
161+
149162 require_api_key_scope_or_user_permission_with_context (
150- actor . as_ref ( ) . map ( |e| & e . 0 ) ,
163+ actor_ref ,
151164 & state. rbac ,
152165 Scope :: Admin ,
153166 ResourceRef {
@@ -244,7 +257,7 @@ pub async fn create_scoped_policy(
244257 "actor" : actor_id,
245258 "policy" : policy. clone( ) ,
246259 } ) ) ;
247- let _ = state. ledger . record ( & audit) ;
260+ state. record_audit_event ( audit) ;
248261
249262 state. broadcast ( crate :: state:: DaemonEvent {
250263 event_type : "scoped_policy_created" . to_string ( ) ,
@@ -304,6 +317,7 @@ pub async fn update_scoped_policy(
304317 Path ( id) : Path < String > ,
305318 Json ( request) : Json < UpdateScopedPolicyRequest > ,
306319) -> Result < Json < ScopedPolicy > , ( StatusCode , String ) > {
320+ let actor_ref = actor. as_ref ( ) . map ( |e| & e. 0 ) ;
307321 let Some ( mut existing) = state
308322 . policy_resolver
309323 . store ( )
@@ -362,8 +376,18 @@ pub async fn update_scoped_policy(
362376 }
363377
364378 // RBAC: apply scope constraints using the updated policy scope.
379+ if existing. scope . scope_type == PolicyScopeType :: Role
380+ && matches ! ( actor_ref, Some ( AuthenticatedActor :: User ( _) ) )
381+ && !actor_is_super_admin ( & state, actor_ref)
382+ {
383+ return Err ( (
384+ StatusCode :: FORBIDDEN ,
385+ "role_scoped_policy_requires_super_admin" . to_string ( ) ,
386+ ) ) ;
387+ }
388+
365389 require_api_key_scope_or_user_permission_with_context (
366- actor . as_ref ( ) . map ( |e| & e . 0 ) ,
390+ actor_ref ,
367391 & state. rbac ,
368392 Scope :: Admin ,
369393 ResourceRef {
@@ -415,7 +439,7 @@ pub async fn update_scoped_policy(
415439 "before" : before,
416440 "after" : after,
417441 } ) ) ;
418- let _ = state. ledger . record ( & audit) ;
442+ state. record_audit_event ( audit) ;
419443
420444 state. broadcast ( crate :: state:: DaemonEvent {
421445 event_type : "scoped_policy_updated" . to_string ( ) ,
@@ -431,15 +455,26 @@ pub async fn delete_scoped_policy(
431455 actor : Option < axum:: extract:: Extension < AuthenticatedActor > > ,
432456 Path ( id) : Path < String > ,
433457) -> Result < Json < serde_json:: Value > , ( StatusCode , String ) > {
458+ let actor_ref = actor. as_ref ( ) . map ( |e| & e. 0 ) ;
434459 let existing = state
435460 . policy_resolver
436461 . store ( )
437462 . get_scoped_policy ( & id)
438463 . map_err ( |e| ( StatusCode :: INTERNAL_SERVER_ERROR , e. to_string ( ) ) ) ?
439464 . ok_or_else ( || ( StatusCode :: NOT_FOUND , "scoped_policy_not_found" . to_string ( ) ) ) ?;
440465
466+ if existing. scope . scope_type == PolicyScopeType :: Role
467+ && matches ! ( actor_ref, Some ( AuthenticatedActor :: User ( _) ) )
468+ && !actor_is_super_admin ( & state, actor_ref)
469+ {
470+ return Err ( (
471+ StatusCode :: FORBIDDEN ,
472+ "role_scoped_policy_requires_super_admin" . to_string ( ) ,
473+ ) ) ;
474+ }
475+
441476 require_api_key_scope_or_user_permission_with_context (
442- actor . as_ref ( ) . map ( |e| & e . 0 ) ,
477+ actor_ref ,
443478 & state. rbac ,
444479 Scope :: Admin ,
445480 ResourceRef {
@@ -491,7 +526,7 @@ pub async fn delete_scoped_policy(
491526 "actor" : actor_string( actor. as_ref( ) . map( |e| & e. 0 ) ) ,
492527 "policy" : existing,
493528 } ) ) ;
494- let _ = state. ledger . record ( & audit) ;
529+ state. record_audit_event ( audit) ;
495530
496531 state. broadcast ( crate :: state:: DaemonEvent {
497532 event_type : "scoped_policy_deleted" . to_string ( ) ,
@@ -963,3 +998,83 @@ pub async fn resolve_policy(
963998 policy_hash,
964999 } ) )
9651000}
1001+
1002+ #[ cfg( test) ]
1003+ mod tests {
1004+ use super :: * ;
1005+
1006+ use axum:: extract:: State ;
1007+ use axum:: Json ;
1008+
1009+ fn test_policy_admin ( org_id : & str ) -> AuthenticatedActor {
1010+ AuthenticatedActor :: User ( clawdstrike:: IdentityPrincipal {
1011+ id : "user-1" . to_string ( ) ,
1012+ provider : clawdstrike:: IdentityProvider :: Oidc ,
1013+ issuer : "https://issuer.example" . to_string ( ) ,
1014+ display_name : None ,
1015+ email : None ,
1016+ email_verified : None ,
1017+ organization_id : Some ( org_id. to_string ( ) ) ,
1018+ teams : Vec :: new ( ) ,
1019+ roles : vec ! [ "policy-admin" . to_string( ) ] ,
1020+ attributes : std:: collections:: HashMap :: new ( ) ,
1021+ authenticated_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
1022+ auth_method : None ,
1023+ expires_at : None ,
1024+ } )
1025+ }
1026+
1027+ #[ tokio:: test]
1028+ async fn role_scoped_policy_mutation_requires_super_admin ( ) {
1029+ let test_dir =
1030+ std:: env:: temp_dir ( ) . join ( format ! ( "hushd-role-scope-test-{}" , uuid:: Uuid :: new_v4( ) ) ) ;
1031+ std:: fs:: create_dir_all ( & test_dir) . expect ( "create temp dir" ) ;
1032+
1033+ let config = crate :: config:: Config {
1034+ cors_enabled : false ,
1035+ audit_db : test_dir. join ( "audit.db" ) ,
1036+ control_db : Some ( test_dir. join ( "control.db" ) ) ,
1037+ ..Default :: default ( )
1038+ } ;
1039+ let state = AppState :: new ( config) . await . expect ( "state" ) ;
1040+
1041+ let role_scope = PolicyScope {
1042+ scope_type : PolicyScopeType :: Role ,
1043+ id : Some ( "role-1" . to_string ( ) ) ,
1044+ name : Some ( "Role 1" . to_string ( ) ) ,
1045+ parent : Some ( Box :: new ( PolicyScope {
1046+ scope_type : PolicyScopeType :: Organization ,
1047+ id : Some ( "org-1" . to_string ( ) ) ,
1048+ name : None ,
1049+ parent : None ,
1050+ conditions : Vec :: new ( ) ,
1051+ } ) ) ,
1052+ conditions : Vec :: new ( ) ,
1053+ } ;
1054+
1055+ let request = CreateScopedPolicyRequest {
1056+ id : None ,
1057+ name : "role-scoped" . to_string ( ) ,
1058+ scope : role_scope,
1059+ priority : 0 ,
1060+ merge_strategy : MergeStrategy :: Merge ,
1061+ policy_yaml : Policy :: new ( ) . to_yaml ( ) . expect ( "serialize default policy" ) ,
1062+ enabled : true ,
1063+ description : None ,
1064+ tags : None ,
1065+ } ;
1066+
1067+ let res = create_scoped_policy (
1068+ State ( state) ,
1069+ Some ( axum:: extract:: Extension ( test_policy_admin ( "org-1" ) ) ) ,
1070+ Json ( request) ,
1071+ )
1072+ . await ;
1073+
1074+ let ( status, msg) = res. expect_err ( "expected forbidden" ) ;
1075+ assert_eq ! ( status, StatusCode :: FORBIDDEN ) ;
1076+ assert_eq ! ( msg, "role_scoped_policy_requires_super_admin" ) ;
1077+
1078+ let _ = std:: fs:: remove_dir_all ( & test_dir) ;
1079+ }
1080+ }
0 commit comments