@@ -46,17 +46,20 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) {
4646 if h .sessionFactory == nil {
4747 return nil , errors .Forbidden ("authorization not available" )
4848 }
49+
50+ validScopes := map [string ]bool {"global" : true , "project" : true , "agent" : true , "session" : true , "credential" : true }
51+ if ! validScopes [roleBinding .Scope ] {
52+ return nil , errors .BadRequest ("invalid scope" )
53+ }
54+
4955 {
5056 g := (* h .sessionFactory ).New (ctx )
5157
52- // a) Look up target role name and reject internal roles
58+ // a) Look up target role name
5359 var targetRoleName string
5460 if err := g .Table ("roles" ).Select ("name" ).Where ("id = ? AND deleted_at IS NULL" , roleBinding .RoleId ).Scan (& targetRoleName ).Error ; err != nil || targetRoleName == "" {
5561 return nil , errors .Forbidden ("target role not found" )
5662 }
57- if pkgrbac .InternalRoles [targetRoleName ] {
58- return nil , errors .Forbidden ("cannot assign internal role" )
59- }
6063
6164 // b) Level hierarchy check — scoped to the target resource
6265 username := auth .GetUsernameFromContext (ctx )
@@ -76,9 +79,12 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) {
7679 scanErr = baseQuery (g ).Scan (& callerRoleNames ).Error
7780 }
7881 if scanErr != nil {
79- return nil , errors .GeneralError ("failed to query caller roles: %v" , scanErr )
82+ return nil , errors .GeneralError ("authorization check failed" )
8083 }
8184 callerLevel := pkgrbac .HighestLevel (callerRoleNames )
85+ if pkgrbac .InternalRoles [targetRoleName ] && callerLevel != 0 {
86+ return nil , errors .Forbidden ("cannot assign internal role" )
87+ }
8288 if ! pkgrbac .CanGrant (callerLevel , targetRoleName ) {
8389 return nil , errors .Forbidden ("insufficient privileges to grant this role" )
8490 }
@@ -95,37 +101,76 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) {
95101 Where ("user_id = ? AND (project_id = ? OR scope = 'global') AND deleted_at IS NULL" ,
96102 username , * roleBinding .ProjectId .Get ()).
97103 Count (& projCount ).Error ; dbErr != nil {
98- return nil , errors .GeneralError ("failed to check project access: %v" , dbErr )
104+ return nil , errors .GeneralError ("authorization check failed" )
99105 }
100106 if projCount == 0 {
101107 return nil , errors .Forbidden ("caller has no access to this project" )
102108 }
103109 }
104110
105- // c) Credential scope: caller must be credential:owner AND project:owner
111+ // c) Credential scope authorization
106112 if roleBinding .Scope == "credential" && roleBinding .CredentialId .IsSet () {
113+ hasProjectID := roleBinding .ProjectId .IsSet () && roleBinding .ProjectId .Get () != nil
114+ hasAgentID := roleBinding .AgentId .IsSet () && roleBinding .AgentId .Get () != nil
115+
116+ if hasProjectID && * roleBinding .ProjectId .Get () == "" {
117+ return nil , errors .BadRequest ("project_id must not be empty" )
118+ }
119+ if hasAgentID && * roleBinding .AgentId .Get () == "" {
120+ return nil , errors .BadRequest ("agent_id must not be empty" )
121+ }
122+
123+ // c1) agent_id requires project_id
124+ if hasAgentID && ! hasProjectID {
125+ return nil , errors .BadRequest ("agent-scoped credential bindings require a project_id" )
126+ }
127+
128+ // c2) Validate agent belongs to the specified project
129+ if hasAgentID && hasProjectID {
130+ var agentProjectID string
131+ if dbErr := g .Table ("agents" ).Select ("project_id" ).
132+ Where ("id = ? AND deleted_at IS NULL" , * roleBinding .AgentId .Get ()).
133+ Scan (& agentProjectID ).Error ; dbErr != nil || agentProjectID == "" {
134+ return nil , errors .BadRequest ("agent not found" )
135+ }
136+ if agentProjectID != * roleBinding .ProjectId .Get () {
137+ return nil , errors .BadRequest ("agent does not belong to the specified project" )
138+ }
139+ }
140+
141+ // c3) Caller must be credential:owner
107142 var credOwnerCount int64
108143 if dbErr := g .Table ("role_bindings" ).
109144 Joins ("JOIN roles ON roles.id = role_bindings.role_id" ).
110145 Where ("role_bindings.user_id = ? AND roles.name = ? AND role_bindings.credential_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL" ,
111146 username , pkgrbac .RoleCredentialOwner , * roleBinding .CredentialId .Get ()).
112147 Count (& credOwnerCount ).Error ; dbErr != nil {
113- return nil , errors .GeneralError ("failed to check credential ownership: %v" , dbErr )
148+ return nil , errors .GeneralError ("authorization check failed" )
114149 }
115150 if credOwnerCount == 0 {
116151 return nil , errors .Forbidden ("caller must be credential owner to grant credential-scoped bindings" )
117152 }
118- if roleBinding .ProjectId .IsSet () {
119- var projOwnerCount int64
153+
154+ // c4) Project-level or agent-level: caller needs project:editor or higher
155+ if hasProjectID && callerLevel != 0 {
156+ var projEditorCount int64
120157 if dbErr := g .Table ("role_bindings" ).
121158 Joins ("JOIN roles ON roles.id = role_bindings.role_id" ).
122- Where ("role_bindings.user_id = ? AND roles.name = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL" ,
123- username , pkgrbac .RoleProjectOwner , * roleBinding .ProjectId .Get ()).
124- Count (& projOwnerCount ).Error ; dbErr != nil {
125- return nil , errors .GeneralError ("failed to check project ownership: %v" , dbErr )
159+ Where ("role_bindings.user_id = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL" ,
160+ username , * roleBinding .ProjectId .Get ()).
161+ Where ("roles.name IN ?" , []string {pkgrbac .RoleProjectOwner , pkgrbac .RoleProjectEditor }).
162+ Count (& projEditorCount ).Error ; dbErr != nil {
163+ return nil , errors .GeneralError ("authorization check failed" )
126164 }
127- if projOwnerCount == 0 {
128- return nil , errors .Forbidden ("caller must be project owner to bind credentials to a project" )
165+ if projEditorCount == 0 {
166+ return nil , errors .Forbidden ("caller must be project editor or higher to bind credentials to a project" )
167+ }
168+ }
169+
170+ // c5) Global credential binding: requires platform:admin
171+ if ! hasProjectID && ! hasAgentID {
172+ if callerLevel != 0 {
173+ return nil , errors .Forbidden ("only platform admins can create global credential bindings" )
129174 }
130175 }
131176 }
@@ -173,7 +218,7 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) {
173218 Joins ("JOIN roles r ON r.id = rb.role_id" ).
174219 Where ("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL" , username ).
175220 Scan (& callerRoleNames ).Error ; dbErr != nil {
176- return nil , errors .GeneralError ("failed to query caller roles: %v" , dbErr )
221+ return nil , errors .GeneralError ("authorization check failed" )
177222 }
178223 callerLevel := pkgrbac .HighestLevel (callerRoleNames )
179224
@@ -198,9 +243,12 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) {
198243 }
199244
200245 // Prevent changing user_id (ownership transfer).
201- if patch .UserId .IsSet () && (found .UserId == nil || * patch .UserId .Get () != * found .UserId ) {
202- if callerLevel != 0 {
203- return nil , errors .Forbidden ("Forbidden" )
246+ if patch .UserId .IsSet () {
247+ patchVal := patch .UserId .Get ()
248+ if found .UserId == nil || patchVal == nil || * patchVal != * found .UserId {
249+ if callerLevel != 0 {
250+ return nil , errors .Forbidden ("Forbidden" )
251+ }
204252 }
205253 }
206254
@@ -209,17 +257,29 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) {
209257 if patch .Scope != nil && * patch .Scope != found .Scope {
210258 return nil , errors .Forbidden ("Forbidden" )
211259 }
212- if patch .ProjectId .IsSet () && (found .ProjectId == nil || * patch .ProjectId .Get () != * found .ProjectId ) {
213- return nil , errors .Forbidden ("Forbidden" )
260+ if patch .ProjectId .IsSet () {
261+ patchVal := patch .ProjectId .Get ()
262+ if found .ProjectId == nil || patchVal == nil || * patchVal != * found .ProjectId {
263+ return nil , errors .Forbidden ("Forbidden" )
264+ }
214265 }
215- if patch .AgentId .IsSet () && (found .AgentId == nil || * patch .AgentId .Get () != * found .AgentId ) {
216- return nil , errors .Forbidden ("Forbidden" )
266+ if patch .AgentId .IsSet () {
267+ patchVal := patch .AgentId .Get ()
268+ if found .AgentId == nil || patchVal == nil || * patchVal != * found .AgentId {
269+ return nil , errors .Forbidden ("Forbidden" )
270+ }
217271 }
218- if patch .SessionId .IsSet () && (found .SessionId == nil || * patch .SessionId .Get () != * found .SessionId ) {
219- return nil , errors .Forbidden ("Forbidden" )
272+ if patch .SessionId .IsSet () {
273+ patchVal := patch .SessionId .Get ()
274+ if found .SessionId == nil || patchVal == nil || * patchVal != * found .SessionId {
275+ return nil , errors .Forbidden ("Forbidden" )
276+ }
220277 }
221- if patch .CredentialId .IsSet () && (found .CredentialId == nil || * patch .CredentialId .Get () != * found .CredentialId ) {
222- return nil , errors .Forbidden ("Forbidden" )
278+ if patch .CredentialId .IsSet () {
279+ patchVal := patch .CredentialId .Get ()
280+ if found .CredentialId == nil || patchVal == nil || * patchVal != * found .CredentialId {
281+ return nil , errors .Forbidden ("Forbidden" )
282+ }
223283 }
224284 }
225285 }
@@ -364,7 +424,7 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) {
364424 var roleName string
365425 g := (* h .sessionFactory ).New (ctx )
366426 if dbErr := g .Table ("roles" ).Select ("name" ).Where ("id = ? AND deleted_at IS NULL" , binding .RoleId ).Scan (& roleName ).Error ; dbErr != nil {
367- return nil , errors .GeneralError ("failed to look up role: %v" , dbErr )
427+ return nil , errors .GeneralError ("authorization check failed" )
368428 }
369429
370430 if roleName == pkgrbac .RoleProjectOwner && binding .ProjectId != nil {
@@ -373,7 +433,7 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) {
373433 Where ("role_id = ? AND project_id = ? AND deleted_at IS NULL" ,
374434 binding .RoleId , * binding .ProjectId ).
375435 Count (& count ).Error ; dbErr != nil {
376- return nil , errors .GeneralError ("failed to count owner bindings: %v" , dbErr )
436+ return nil , errors .GeneralError ("authorization check failed" )
377437 }
378438 if count <= 1 {
379439 return nil , errors .New (errors .ErrorConflict , "cannot delete the last owner binding" )
@@ -385,31 +445,67 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) {
385445 Where ("role_id = ? AND credential_id = ? AND deleted_at IS NULL" ,
386446 binding .RoleId , * binding .CredentialId ).
387447 Count (& count ).Error ; dbErr != nil {
388- return nil , errors .GeneralError ("failed to count owner bindings: %v" , dbErr )
448+ return nil , errors .GeneralError ("authorization check failed" )
389449 }
390450 if count <= 1 {
391451 return nil , errors .New (errors .ErrorConflict , "cannot delete the last owner binding" )
392452 }
393453 }
394454
395- // --- Hierarchy check: caller must outrank the binding's role ---
455+ // --- Authorization check ---
396456 username := auth .GetUsernameFromContext (ctx )
397- var callerRoleNames []string
398- baseQuery := g .Table ("role_bindings rb" ).
399- Select ("r.name" ).
400- Joins ("JOIN roles r ON r.id = rb.role_id" ).
401- Where ("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL" , username )
402- if binding .Scope == "project" && binding .ProjectId != nil {
403- baseQuery = baseQuery .Where ("rb.project_id = ? OR rb.scope = 'global'" , * binding .ProjectId )
404- } else if binding .Scope == "credential" && binding .CredentialId != nil {
405- baseQuery = baseQuery .Where ("rb.credential_id = ? OR rb.scope = 'global'" , * binding .CredentialId )
406- }
407- if dbErr := baseQuery .Scan (& callerRoleNames ).Error ; dbErr != nil {
408- return nil , errors .GeneralError ("failed to query caller roles: %v" , dbErr )
409- }
410- callerLevel := pkgrbac .HighestLevel (callerRoleNames )
411- if ! pkgrbac .CanGrant (callerLevel , roleName ) {
412- return nil , errors .Forbidden ("insufficient privileges to delete this binding" )
457+
458+ if binding .Scope == "credential" {
459+ // Asymmetric unbind: project:editor+ can remove credential bindings
460+ // from their project without needing credential:owner.
461+ // platform:admin can always unbind.
462+ var callerAllRoles []string
463+ if dbErr := g .Table ("role_bindings rb" ).
464+ Select ("r.name" ).
465+ Joins ("JOIN roles r ON r.id = rb.role_id" ).
466+ Where ("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL" , username ).
467+ Scan (& callerAllRoles ).Error ; dbErr != nil {
468+ return nil , errors .GeneralError ("authorization check failed" )
469+ }
470+ callerLevel := pkgrbac .HighestLevel (callerAllRoles )
471+
472+ if callerLevel == 0 {
473+ // platform:admin can always unbind
474+ } else if binding .ProjectId != nil {
475+ var projEditorCount int64
476+ if dbErr := g .Table ("role_bindings" ).
477+ Joins ("JOIN roles ON roles.id = role_bindings.role_id" ).
478+ Where ("role_bindings.user_id = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL" ,
479+ username , * binding .ProjectId ).
480+ Where ("roles.name IN ?" , []string {pkgrbac .RoleProjectOwner , pkgrbac .RoleProjectEditor }).
481+ Count (& projEditorCount ).Error ; dbErr != nil {
482+ return nil , errors .GeneralError ("authorization check failed" )
483+ }
484+ if projEditorCount == 0 {
485+ return nil , errors .Forbidden ("insufficient privileges to delete this binding" )
486+ }
487+ } else {
488+ // Global credential binding: requires platform:admin (already checked above)
489+ return nil , errors .Forbidden ("insufficient privileges to delete this binding" )
490+ }
491+ } else {
492+ // Non-credential scopes: caller must outrank the binding's role
493+ // AND be at least project:owner (level 1)
494+ var callerRoleNames []string
495+ baseQuery := g .Table ("role_bindings rb" ).
496+ Select ("r.name" ).
497+ Joins ("JOIN roles r ON r.id = rb.role_id" ).
498+ Where ("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL" , username )
499+ if binding .Scope == "project" && binding .ProjectId != nil {
500+ baseQuery = baseQuery .Where ("rb.project_id = ? OR rb.scope = 'global'" , * binding .ProjectId )
501+ }
502+ if dbErr := baseQuery .Scan (& callerRoleNames ).Error ; dbErr != nil {
503+ return nil , errors .GeneralError ("authorization check failed" )
504+ }
505+ callerLevel := pkgrbac .HighestLevel (callerRoleNames )
506+ if callerLevel > 1 || ! pkgrbac .CanGrant (callerLevel , roleName ) {
507+ return nil , errors .Forbidden ("insufficient privileges to delete this binding" )
508+ }
413509 }
414510 }
415511
0 commit comments