ext jwt client: map k8s-style to rbac permissions (#106279)

* initial commit

* Proposal
Co-Authored-By: mohammad-hamid <mohammad.hamid@grafana.com>

* extend k8s-style mapper
- add tests

* address comments

* cleanup

* address comments

---------

Co-authored-by: Gabriel Mabille <gabriel.mabille@grafana.com>
This commit is contained in:
mohammad-hamid
2025-06-18 11:51:35 -04:00
committed by GitHub
co-authored by Gabriel Mabille
parent 67f50478d9
commit 936dd05eac
8 changed files with 319 additions and 38 deletions
+92 -13
View File
@@ -3,6 +3,7 @@ package sync
import (
"context"
"errors"
"strings"
"golang.org/x/exp/maps"
@@ -13,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/authn"
rbac "github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
)
@@ -28,6 +30,7 @@ func ProvideRBACSync(acService accesscontrol.Service, tracer tracing.Tracer, per
log: log.New("permissions.sync"),
permRegistry: permRegistry,
tracer: tracer,
mapper: rbac.NewMapperRegistry(),
}
}
@@ -36,6 +39,7 @@ type RBACSync struct {
permRegistry permreg.PermissionRegistry
log log.Logger
tracer tracing.Tracer
mapper rbac.MapperRegistry
}
func (s *RBACSync) SyncPermissionsHook(ctx context.Context, ident *authn.Identity, _ *authn.Request) error {
@@ -81,7 +85,8 @@ func (s *RBACSync) fetchPermissions(ctx context.Context, ident *authn.Identity)
permissions := make([]accesscontrol.Permission, 0, 8)
roles := ident.ClientParams.FetchPermissionsParams.Roles
actions := ident.ClientParams.FetchPermissionsParams.AllowedActions
if len(roles) > 0 || len(actions) > 0 {
k8s := ident.ClientParams.FetchPermissionsParams.K8s
if len(roles) > 0 || len(actions) > 0 || len(k8s) > 0 {
for _, role := range roles {
roleDTO, err := s.ac.GetRoleByName(ctx, ident.GetOrgID(), role)
if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) {
@@ -93,19 +98,11 @@ func (s *RBACSync) fetchPermissions(ctx context.Context, ident *authn.Identity)
}
}
for _, action := range actions {
scopes, ok := s.permRegistry.GetScopePrefixes(action)
if !ok {
s.log.Warn("Unknown action scopes", "action", action)
continue
}
if len(scopes) == 0 {
permissions = append(permissions, accesscontrol.Permission{Action: action})
continue
}
for scope := range scopes {
permissions = append(permissions, accesscontrol.Permission{Action: action, Scope: scope + "*"})
}
s.addPermissionsForAction(action, &permissions)
}
// Add K8s permissions
k8sPermissions := s.translateK8sPermissions(ctx, k8s)
permissions = append(permissions, k8sPermissions...)
return permissions, nil
}
@@ -117,6 +114,88 @@ func (s *RBACSync) fetchPermissions(ctx context.Context, ident *authn.Identity)
return permissions, nil
}
// addPermissionsForAction is a helper method that handles the common pattern of:
// 1. Getting scope prefixes for an action
// 2. Adding permissions with appropriate scopes
// 3. Logging warnings for unknown actions
func (s *RBACSync) addPermissionsForAction(action string, permissions *[]accesscontrol.Permission) {
scopes, ok := s.permRegistry.GetScopePrefixes(action)
if !ok {
s.log.Warn("Unknown action scopes", "action", action)
return
}
if len(scopes) == 0 {
*permissions = append(*permissions, accesscontrol.Permission{Action: action})
return
}
for scope := range scopes {
*permissions = append(*permissions, accesscontrol.Permission{Action: action, Scope: scope + "*"})
}
}
func (s *RBACSync) translateK8sPermissions(_ context.Context, k8sPerms []string) []accesscontrol.Permission {
permissions := make([]accesscontrol.Permission, 0, len(k8sPerms))
for _, k8sPerm := range k8sPerms {
parts := strings.Split(k8sPerm, ":")
if len(parts) != 2 {
s.log.Warn("Invalid K8s permission format", "permission", k8sPerm)
continue
}
groupResource := strings.Split(parts[0], "/")
group := groupResource[0]
verb := parts[1]
switch {
case len(groupResource) == 1:
// Case group:verb
resourceMappings := s.mapper.GetAll(group)
if len(resourceMappings) == 0 {
s.log.Warn("No mappings found for group", "group", group)
continue
}
for _, mapping := range resourceMappings {
if verb == "*" {
actions := mapping.AllActions()
for _, action := range actions {
s.addPermissionsForAction(action, &permissions)
}
continue
}
action, ok := mapping.Action(verb)
if !ok {
s.log.Warn("Unknown K8s verb for group", "group", group, "verb", verb)
continue
}
s.addPermissionsForAction(action, &permissions)
}
case len(groupResource) == 2:
// Case group/resource:verb
resource := groupResource[1]
resourceMappings, ok := s.mapper.Get(group, resource)
if !ok {
s.log.Warn("Unknown K8s resource", "group", group, "resource", resource)
continue
}
if verb == "*" {
actions := resourceMappings.AllActions()
for _, action := range actions {
s.addPermissionsForAction(action, &permissions)
}
continue
}
action, ok := resourceMappings.Action(verb)
if !ok {
s.log.Warn("Unknown K8s verb", "group", group, "resource", resource, "verb", verb)
continue
}
s.addPermissionsForAction(action, &permissions)
default:
s.log.Warn("Invalid K8s permission format", "permission", k8sPerm)
continue
}
}
return permissions
}
func cloudRolesToAddAndRemove(ident *authn.Identity) ([]string, []string, error) {
// Since Cloud Admin/Editor/Viewer roles are not yet implemented one-to-one in the Grafana, it becomes a confusing experience for users,
// therefore we are doing granular mapping of all available functionality in the Grafana temporary.
@@ -17,6 +17,7 @@ import (
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
permreg "github.com/grafana/grafana/pkg/services/accesscontrol/permreg/test"
"github.com/grafana/grafana/pkg/services/authn"
rbac "github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
)
@@ -421,6 +422,135 @@ func TestRBACSync_ClearUserPermissionCacheHook(t *testing.T) {
}
}
func TestRBACSync_translateK8sPermissions(t *testing.T) {
type testCase struct {
name string
k8sPerms []string
expectedPerms []accesscontrol.Permission
expectedError error
}
tests := []testCase{
{
name: "should translate folder.grafana.app/folders:get to folders:read",
k8sPerms: []string{
"folder.grafana.app/folders:get",
},
expectedPerms: []accesscontrol.Permission{
{Action: "folders:read", Scope: "folders:uid:*"},
},
expectedError: nil,
},
{
name: "should translate resource with wildcard verb",
k8sPerms: []string{
"folder.grafana.app/folders:*",
},
expectedPerms: []accesscontrol.Permission{
{Action: "folders:read", Scope: "folders:uid:*"},
{Action: "folders:write", Scope: "folders:uid:*"},
{Action: "folders:delete", Scope: "folders:uid:*"},
{Action: "folders:create", Scope: "folders:uid:*"},
{Action: "folders.permissions:read", Scope: "folders:uid:*"},
{Action: "folders.permissions:write", Scope: "folders:uid:*"},
},
expectedError: nil,
},
{
name: "should handle invalid permission format",
k8sPerms: []string{
"invalid-format",
},
expectedPerms: []accesscontrol.Permission{},
expectedError: nil,
},
{
name: "should handle unknown resource",
k8sPerms: []string{
"folder.grafana.app/unknown:get",
},
expectedPerms: []accesscontrol.Permission{},
expectedError: nil,
},
{
name: "should handle unknown verb",
k8sPerms: []string{
"folder.grafana.app/folders:unknown",
},
expectedPerms: []accesscontrol.Permission{},
expectedError: nil,
},
{
name: "should handle group:verb format",
k8sPerms: []string{
"folder.grafana.app:get",
},
expectedPerms: []accesscontrol.Permission{
{Action: "folders:read", Scope: "folders:uid:*"},
},
expectedError: nil,
},
{
name: "should handle group:* format",
k8sPerms: []string{
"folder.grafana.app:*",
},
expectedPerms: []accesscontrol.Permission{
{Action: "folders:read", Scope: "folders:uid:*"},
{Action: "folders:write", Scope: "folders:uid:*"},
{Action: "folders:delete", Scope: "folders:uid:*"},
{Action: "folders:create", Scope: "folders:uid:*"},
{Action: "folders.permissions:read", Scope: "folders:uid:*"},
{Action: "folders.permissions:write", Scope: "folders:uid:*"},
},
expectedError: nil,
},
{
name: "should handle unknown group in group:verb format",
k8sPerms: []string{
"unknown.grafana.app:get",
},
expectedPerms: []accesscontrol.Permission{},
expectedError: nil,
},
{
name: "should handle unknown verb in group:verb format",
k8sPerms: []string{
"folder.grafana.app:unknownverb",
},
expectedPerms: []accesscontrol.Permission{},
expectedError: nil,
},
{
name: "should handle multiple group:verb permissions",
k8sPerms: []string{
"folder.grafana.app:get",
"folder.grafana.app:create",
},
expectedPerms: []accesscontrol.Permission{
{Action: "folders:read", Scope: "folders:uid:*"},
{Action: "folders:create", Scope: "folders:uid:*"},
},
expectedError: nil,
},
{
name: "should skip invalid permissions group/resource/additional/part:verb",
k8sPerms: []string{
"folder.grafana.app/folder/fold1/subresource:get",
},
expectedPerms: []accesscontrol.Permission{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := setupTestEnv(t)
perms := s.translateK8sPermissions(context.Background(), tt.k8sPerms)
assert.ElementsMatch(t, tt.expectedPerms, perms)
})
}
}
func setupTestEnv(t *testing.T) *RBACSync {
acMock := &acmock.Mock{
GetUserPermissionsFunc: func(ctx context.Context, siu identity.Requester, o accesscontrol.Options) ([]accesscontrol.Permission, error) {
@@ -446,11 +576,19 @@ func setupTestEnv(t *testing.T) *RBACSync {
},
}
permRegistry := permreg.ProvidePermissionRegistry(t)
require.NoError(t, permRegistry.RegisterPermission("folders:write", "folders:uid:"))
require.NoError(t, permRegistry.RegisterPermission("folders:create", "folders:uid:"))
require.NoError(t, permRegistry.RegisterPermission("folders:delete", "folders:uid:"))
require.NoError(t, permRegistry.RegisterPermission("folders.permissions:read", "folders:uid:"))
require.NoError(t, permRegistry.RegisterPermission("folders.permissions:write", "folders:uid:"))
s := &RBACSync{
ac: acMock,
log: log.NewNopLogger(),
tracer: tracing.InitializeTracerForTest(),
permRegistry: permRegistry,
mapper: rbac.NewMapperRegistry(),
}
return s
}