Skip to content

Commit 2f5d1a4

Browse files
authored
Merge branch 'main' into dependabot/npm_and_yarn/docs/npm_and_yarn-e4e91a86d1
2 parents cc6c320 + 5ea8bef commit 2f5d1a4

12 files changed

Lines changed: 1460 additions & 68 deletions

File tree

components/ambient-api-server/pkg/rbac/hierarchy.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ var RoleLevel = map[string]int{
66
RoleProjectOwner: 1,
77
RoleCredentialOwner: 1,
88

9-
RolePlatformViewer: 2,
10-
RoleProjectEditor: 2,
11-
RoleAgentOperator: 2,
12-
"agent:editor": 2,
13-
RoleCredentialReader: 2,
14-
"credential:viewer": 2,
9+
RolePlatformViewer: 2,
10+
RoleProjectEditor: 2,
11+
RoleAgentOperator: 2,
12+
"agent:editor": 2,
13+
RoleCredentialReader: 2,
14+
RoleCredentialTokenReader: 2,
15+
"credential:viewer": 2,
1516

1617
RoleProjectViewer: 3,
1718
RoleAgentObserver: 3,

components/ambient-api-server/plugins/roleBindings/handler.go

Lines changed: 144 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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

components/ambient-api-server/plugins/roles/migration.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,33 @@ func seedBuiltInRoles(tx *gorm.DB) error {
111111
}
112112
return nil
113113
}
114+
115+
func editorCredentialUnbindMigration() *gormigrate.Migration {
116+
return &gormigrate.Migration{
117+
ID: "202606091900",
118+
Migrate: func(tx *gorm.DB) error {
119+
var perms string
120+
if err := tx.Raw(`SELECT permissions FROM roles WHERE name = 'project:editor' AND deleted_at IS NULL`).Scan(&perms).Error; err != nil {
121+
return err
122+
}
123+
var permList []string
124+
if err := json.Unmarshal([]byte(perms), &permList); err != nil {
125+
return err
126+
}
127+
for _, p := range permList {
128+
if p == "role_binding:delete" {
129+
return nil
130+
}
131+
}
132+
permList = append(permList, "role_binding:delete")
133+
updated, err := json.Marshal(permList)
134+
if err != nil {
135+
return err
136+
}
137+
return tx.Exec(`UPDATE roles SET permissions = ?, updated_at = NOW() WHERE name = 'project:editor' AND deleted_at IS NULL`, string(updated)).Error
138+
},
139+
Rollback: func(tx *gorm.DB) error {
140+
return nil
141+
},
142+
}
143+
}

components/ambient-api-server/plugins/roles/plugin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,5 @@ func init() {
8181
presenters.RegisterKind(&Role{}, "Role")
8282

8383
db.RegisterMigration(migration())
84+
db.RegisterMigration(editorCredentialUnbindMigration())
8485
}

0 commit comments

Comments
 (0)