Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions console/rbac/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,93 @@ func (gm *GroupMapping) CheckAccess(userGroups, allowedRoles []string, permissio
)
}

// RoleFromString converts a role name string to a Role constant using case-insensitive matching.
// Returns RoleUnspecified for unknown or empty strings.
func RoleFromString(s string) Role {
switch strings.ToLower(s) {
case "viewer":
return RoleViewer
case "editor":
return RoleEditor
case "owner":
return RoleOwner
default:
return RoleUnspecified
}
}

// CheckAccessSharing verifies access using per-user sharing, per-group sharing,
// and legacy allowed-roles. The highest role found across all three sources is used.
//
// Evaluation order:
// 1. Check shareUsers for userEmail (case-insensitive)
// 2. Check shareGroups for any of userGroups (case-insensitive)
// 3. Legacy: check allowedRoles via GroupMapping.CheckAccess
//
// Returns nil if access is granted, or a PermissionDenied error otherwise.
func (gm *GroupMapping) CheckAccessSharing(
userEmail string,
userGroups []string,
shareUsers map[string]string,
shareGroups map[string]string,
allowedRoles []string,
permission Permission,
) error {
bestLevel := -1

// 1. Check per-user sharing grants
if shareUsers != nil {
emailLower := strings.ToLower(userEmail)
for email, roleName := range shareUsers {
if strings.ToLower(email) == emailLower {
role := RoleFromString(roleName)
if level := roleLevel[role]; level > bestLevel {
bestLevel = level
}
}
}
}

// 2. Check per-group sharing grants
if shareGroups != nil {
for _, ug := range userGroups {
ugLower := strings.ToLower(ug)
for group, roleName := range shareGroups {
if strings.ToLower(group) == ugLower {
role := RoleFromString(roleName)
if level := roleLevel[role]; level > bestLevel {
bestLevel = level
}
}
}
}
}

// 3. Legacy fallback: check allowedRoles via existing mechanism
if len(allowedRoles) > 0 {
if gm.CheckAccess(userGroups, allowedRoles, permission) == nil {
return nil
}
}

// Evaluate best role from sharing sources
if bestLevel > 0 {
// Find the Role with this level
for role, level := range roleLevel {
if level == bestLevel {
if HasPermission(role, permission) {
return nil
}
}
}
}

return connect.NewError(
connect.CodePermissionDenied,
fmt.Errorf("RBAC: authorization denied"),
)
}

// defaultMapping is the package-level default GroupMapping using built-in group names.
var defaultMapping = NewGroupMapping(nil, nil, nil)

Expand Down
164 changes: 164 additions & 0 deletions console/rbac/rbac_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,3 +484,167 @@ func TestCheckAccess(t *testing.T) {
}
})
}

func TestRoleFromString(t *testing.T) {
tests := []struct {
input string
want Role
}{
{"viewer", RoleViewer},
{"editor", RoleEditor},
{"owner", RoleOwner},
{"VIEWER", RoleViewer},
{"Editor", RoleEditor},
{"OWNER", RoleOwner},
{"", RoleUnspecified},
{"unknown", RoleUnspecified},
{"admin", RoleUnspecified},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := RoleFromString(tt.input)
if got != tt.want {
t.Errorf("RoleFromString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestCheckAccessSharing(t *testing.T) {
gm := NewGroupMapping(nil, nil, nil)

t.Run("user email match grants access", func(t *testing.T) {
err := gm.CheckAccessSharing(
"alice@example.com",
[]string{},
map[string]string{"alice@example.com": "viewer"},
nil,
nil,
PermissionSecretsRead,
)
if err != nil {
t.Errorf("expected access granted via email, got: %v", err)
}
})

t.Run("user email match is case-insensitive", func(t *testing.T) {
err := gm.CheckAccessSharing(
"Alice@Example.COM",
[]string{},
map[string]string{"alice@example.com": "viewer"},
nil,
nil,
PermissionSecretsRead,
)
if err != nil {
t.Errorf("expected access granted via case-insensitive email, got: %v", err)
}
})

t.Run("group match in shareGroups grants access", func(t *testing.T) {
err := gm.CheckAccessSharing(
"bob@example.com",
[]string{"platform-team"},
nil,
map[string]string{"platform-team": "editor"},
nil,
PermissionSecretsWrite,
)
if err != nil {
t.Errorf("expected access granted via group share, got: %v", err)
}
})

t.Run("group match in shareGroups is case-insensitive", func(t *testing.T) {
err := gm.CheckAccessSharing(
"bob@example.com",
[]string{"Platform-Team"},
nil,
map[string]string{"platform-team": "viewer"},
nil,
PermissionSecretsRead,
)
if err != nil {
t.Errorf("expected access granted via case-insensitive group share, got: %v", err)
}
})

t.Run("legacy allowedRoles still works", func(t *testing.T) {
err := gm.CheckAccessSharing(
"carol@example.com",
[]string{"viewer"},
nil,
nil,
[]string{"viewer"},
PermissionSecretsRead,
)
if err != nil {
t.Errorf("expected access granted via legacy allowedRoles, got: %v", err)
}
})

t.Run("highest role wins across all sources", func(t *testing.T) {
// User has viewer via email, but owner via group share
// Should allow delete (owner permission)
err := gm.CheckAccessSharing(
"alice@example.com",
[]string{"ops"},
map[string]string{"alice@example.com": "viewer"},
map[string]string{"ops": "owner"},
nil,
PermissionSecretsDelete,
)
if err != nil {
t.Errorf("expected access granted via highest role, got: %v", err)
}
})

t.Run("denies when no source grants sufficient permission", func(t *testing.T) {
err := gm.CheckAccessSharing(
"alice@example.com",
[]string{},
map[string]string{"alice@example.com": "viewer"},
nil,
nil,
PermissionSecretsWrite,
)
if err == nil {
t.Fatal("expected PermissionDenied, got nil")
}
connectErr, ok := err.(*connect.Error)
if !ok {
t.Fatalf("expected *connect.Error, got %T", err)
}
if connectErr.Code() != connect.CodePermissionDenied {
t.Errorf("expected CodePermissionDenied, got %v", connectErr.Code())
}
})

t.Run("denies when no grants at all", func(t *testing.T) {
err := gm.CheckAccessSharing(
"nobody@example.com",
[]string{"unknown-group"},
nil,
nil,
nil,
PermissionSecretsRead,
)
if err == nil {
t.Fatal("expected PermissionDenied, got nil")
}
})

t.Run("empty sharing maps fall through to legacy", func(t *testing.T) {
err := gm.CheckAccessSharing(
"carol@example.com",
[]string{"editor"},
map[string]string{},
map[string]string{},
[]string{"editor"},
PermissionSecretsWrite,
)
if err != nil {
t.Errorf("expected access granted via legacy fallback, got: %v", err)
}
})
}
26 changes: 26 additions & 0 deletions console/secrets/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ func CheckListAccess(gm *rbac.GroupMapping, userGroups, allowedRoles []string) e
return gm.CheckAccess(userGroups, allowedRoles, rbac.PermissionSecretsList)
}

// CheckReadAccessSharing verifies read permission using sharing-aware access control.
func CheckReadAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error {
return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsRead)
}

// CheckWriteAccessSharing verifies write permission using sharing-aware access control.
func CheckWriteAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error {
return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsWrite)
}

// CheckDeleteAccessSharing verifies delete permission using sharing-aware access control.
func CheckDeleteAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error {
return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsDelete)
}

// CheckListAccessSharing verifies list permission using sharing-aware access control.
func CheckListAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error {
return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsList)
}

// CheckAdminAccessSharing verifies admin permission using sharing-aware access control.
// Used for operations like updating sharing grants, which require owner-level access.
func CheckAdminAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error {
return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsAdmin)
}

// CheckAccess verifies that the user has at least one group in common with the allowed groups.
// Deprecated: Use CheckReadAccess or CheckListAccess instead.
// Returns nil if access is granted, or a PermissionDenied error otherwise.
Expand Down
72 changes: 72 additions & 0 deletions console/secrets/authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,78 @@ func TestCheckListAccess(t *testing.T) {
})
}

func TestCheckReadAccessSharing(t *testing.T) {
gm := defaultGM()

t.Run("user email grant allows read", func(t *testing.T) {
err := CheckReadAccessSharing(gm, "alice@example.com", nil,
map[string]string{"alice@example.com": "viewer"}, nil, nil)
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})

t.Run("group grant allows read", func(t *testing.T) {
err := CheckReadAccessSharing(gm, "bob@example.com", []string{"dev-team"},
nil, map[string]string{"dev-team": "viewer"}, nil)
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})

t.Run("legacy fallback works", func(t *testing.T) {
err := CheckReadAccessSharing(gm, "carol@example.com", []string{"viewer"},
nil, nil, []string{"viewer"})
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})
}

func TestCheckWriteAccessSharing(t *testing.T) {
gm := defaultGM()

t.Run("group grant allows write", func(t *testing.T) {
err := CheckWriteAccessSharing(gm, "bob@example.com", []string{"writers"},
nil, map[string]string{"writers": "editor"}, nil)
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})

t.Run("viewer grant denies write", func(t *testing.T) {
err := CheckWriteAccessSharing(gm, "alice@example.com", nil,
map[string]string{"alice@example.com": "viewer"}, nil, nil)
if err == nil {
t.Fatal("expected PermissionDenied, got nil")
}
})
}

func TestCheckDeleteAccessSharing(t *testing.T) {
gm := defaultGM()

t.Run("owner email grant allows delete", func(t *testing.T) {
err := CheckDeleteAccessSharing(gm, "alice@example.com", nil,
map[string]string{"alice@example.com": "owner"}, nil, nil)
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})
}

func TestCheckListAccessSharing(t *testing.T) {
gm := defaultGM()

t.Run("user email grant allows list", func(t *testing.T) {
err := CheckListAccessSharing(gm, "alice@example.com", nil,
map[string]string{"alice@example.com": "viewer"}, nil, nil)
if err != nil {
t.Errorf("expected access granted, got: %v", err)
}
})
}

func TestCheckAccess(t *testing.T) {
t.Run("allows access when user has matching group", func(t *testing.T) {
// Given: User groups ["developers", "readers"], allowed ["admin", "readers"]
Expand Down
Loading