RBAC: Add required component to perform access control checks for user api when running single tenant (#93104)

* Unexport store and create new constructor function

* Add ResourceAuthorizer and LegacyAccessClient

* Configure checks for user store

* List with checks if AccessClient is configured

* Allow system user service account to read all users

---------

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Karl Persson 2024-09-23 11:26:44 +02:00 committed by GitHub
parent bca8bd3c8b
commit 2e38329026
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 606 additions and 61 deletions

View File

@ -40,6 +40,10 @@ func (info *ResourceInfo) WithGroupAndShortName(group string, shortName string)
} }
} }
func (info *ResourceInfo) GetName() string {
return info.resourceName
}
func (info *ResourceInfo) GetSingularName() string { func (info *ResourceInfo) GetSingularName() string {
return info.singularName return info.singularName
} }

View File

@ -51,7 +51,9 @@ func WithRequester(handler http.Handler) http.Handler {
Permissions: map[int64]map[string][]string{ Permissions: map[int64]map[string][]string{
orgId: { orgId: {
"*": {"*"}, // all resources, all scopes "*": {"*"}, // all resources, all scopes
// FIXME(kalleep): We don't support wildcard actions so we need to list all possible actions
// for this user. This is not scalable and we should look into how to fix this.
"org.users:read": {"*"},
// Dashboards do not support wildcard action // Dashboards do not support wildcard action
// dashboards.ActionDashboardsRead: {"*"}, // dashboards.ActionDashboardsRead: {"*"},
// dashboards.ActionDashboardsCreate: {"*"}, // dashboards.ActionDashboardsCreate: {"*"},

View File

@ -0,0 +1,35 @@
package iam
import (
"context"
"fmt"
"github.com/grafana/authlib/claims"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
gfauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
)
func newLegacyAuthorizer(ac accesscontrol.AccessControl, store legacy.LegacyIdentityStore) (authorizer.Authorizer, claims.AccessClient) {
client := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "users",
Attr: "id",
Mapping: map[string]string{
"get": accesscontrol.ActionOrgUsersRead,
"list": accesscontrol.ActionOrgUsersRead,
},
Resolver: accesscontrol.ResourceResolverFunc(func(ctx context.Context, ns claims.NamespaceInfo, name string) ([]string, error) {
res, err := store.GetUserInternalID(ctx, ns, legacy.GetUserInternalIDQuery{
UID: name,
})
if err != nil {
return nil, err
}
return []string{fmt.Sprintf("users:id:%d", res.ID)}, nil
}),
})
return gfauthorizer.NewResourceAuthorizer(client), client
}

View File

@ -14,6 +14,7 @@ import (
type LegacyIdentityStore interface { type LegacyIdentityStore interface {
ListDisplay(ctx context.Context, ns claims.NamespaceInfo, query ListDisplayQuery) (*ListUserResult, error) ListDisplay(ctx context.Context, ns claims.NamespaceInfo, query ListDisplayQuery) (*ListUserResult, error)
GetUserInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetUserInternalIDQuery) (*GetUserInternalIDResult, error)
ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error) ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error)
ListUserTeams(ctx context.Context, ns claims.NamespaceInfo, query ListUserTeamsQuery) (*ListUserTeamsResult, error) ListUserTeams(ctx context.Context, ns claims.NamespaceInfo, query ListUserTeamsQuery) (*ListUserTeamsResult, error)
@ -37,6 +38,11 @@ func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStor
type legacySQLStore struct { type legacySQLStore struct {
sql legacysql.LegacyDatabaseProvider sql legacysql.LegacyDatabaseProvider
ac claims.AccessClient
}
func (s *legacySQLStore) WithAccessClient(ac claims.AccessClient) {
s.ac = ac
} }
// Templates setup. // Templates setup.

View File

@ -2,6 +2,7 @@ package legacy
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"text/template" "text/template"
@ -13,6 +14,79 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
) )
type GetUserInternalIDQuery struct {
OrgID int64
UID string
}
type GetUserInternalIDResult struct {
ID int64
}
var sqlQueryUserInternalIDTemplate = mustTemplate("user_internal_id.sql")
func newGetUserInternalID(sql *legacysql.LegacyDatabaseHelper, q *GetUserInternalIDQuery) getUserInternalIDQuery {
return getUserInternalIDQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Query: q,
}
}
type getUserInternalIDQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Query *GetUserInternalIDQuery
}
func (r getUserInternalIDQuery) Validate() error {
return nil // TODO
}
func (s *legacySQLStore) GetUserInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetUserInternalIDQuery) (*GetUserInternalIDResult, error) {
query.OrgID = ns.OrgID
if query.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newGetUserInternalID(sql, &query)
q, err := sqltemplate.Execute(sqlQueryUserInternalIDTemplate, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryUserInternalIDTemplate.Name(), err)
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
if !rows.Next() {
return nil, errors.New("user not found")
}
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
return &GetUserInternalIDResult{
id,
}, nil
}
type ListUserQuery struct { type ListUserQuery struct {
OrgID int64 OrgID int64
UID string UID string

View File

@ -0,0 +1,7 @@
SELECT u.id
FROM {{ .Ident .UserTable }} as u
INNER JOIN {{ .Ident .OrgUserTable }} as o ON u.id = o.user_id
WHERE o.org_id = {{ .Arg .Query.OrgID }}
AND u.uid = {{ .Arg .Query.UID }}
AND NOT u.is_service_account
LIMIT 1;

View File

@ -3,19 +3,6 @@ package iam
import ( import (
"context" "context"
"github.com/grafana/grafana/pkg/apimachinery/identity"
iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount"
"github.com/grafana/grafana/pkg/registry/apis/iam/sso"
"github.com/grafana/grafana/pkg/registry/apis/iam/team"
"github.com/grafana/grafana/pkg/registry/apis/iam/user"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/storage/legacysql"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
@ -25,14 +12,34 @@ import (
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
common "k8s.io/kube-openapi/pkg/common" common "k8s.io/kube-openapi/pkg/common"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount"
"github.com/grafana/grafana/pkg/registry/apis/iam/sso"
"github.com/grafana/grafana/pkg/registry/apis/iam/team"
"github.com/grafana/grafana/pkg/registry/apis/iam/user"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/storage/legacysql"
) )
var _ builder.APIGroupBuilder = (*IdentityAccessManagementAPIBuilder)(nil) var _ builder.APIGroupBuilder = (*IdentityAccessManagementAPIBuilder)(nil)
// This is used just so wire has something unique to return // This is used just so wire has something unique to return
type IdentityAccessManagementAPIBuilder struct { type IdentityAccessManagementAPIBuilder struct {
Store legacy.LegacyIdentityStore store legacy.LegacyIdentityStore
SSOService ssosettings.Service authorizer authorizer.Authorizer
accessClient claims.AccessClient
// Not set for multi-tenant deployment for now
sso ssosettings.Service
} }
func RegisterAPIService( func RegisterAPIService(
@ -40,20 +47,44 @@ func RegisterAPIService(
apiregistration builder.APIRegistrar, apiregistration builder.APIRegistrar,
ssoService ssosettings.Service, ssoService ssosettings.Service,
sql db.DB, sql db.DB,
ac accesscontrol.AccessControl,
) (*IdentityAccessManagementAPIBuilder, error) { ) (*IdentityAccessManagementAPIBuilder, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil, nil // skip registration unless opting into experimental apis // skip registration unless opting into experimental apis
return nil, nil
} }
store := legacy.NewLegacySQLStores(legacysql.NewDatabaseProvider(sql))
authorizer, client := newLegacyAuthorizer(ac, store)
builder := &IdentityAccessManagementAPIBuilder{ builder := &IdentityAccessManagementAPIBuilder{
Store: legacy.NewLegacySQLStores(legacysql.NewDatabaseProvider(sql)), store: store,
SSOService: ssoService, sso: ssoService,
authorizer: authorizer,
accessClient: client,
} }
apiregistration.RegisterAPI(builder) apiregistration.RegisterAPI(builder)
return builder, nil return builder, nil
} }
func NewAPIService(store legacy.LegacyIdentityStore) *IdentityAccessManagementAPIBuilder {
return &IdentityAccessManagementAPIBuilder{
store: store,
authorizer: authorizer.AuthorizerFunc(
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "no identity found", err
}
if user.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "only grafana admins have access for now", nil
}),
}
}
func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersion { func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersion {
return iamv0.SchemeGroupVersion return iamv0.SchemeGroupVersion
} }
@ -80,27 +111,27 @@ func (b *IdentityAccessManagementAPIBuilder) GetAPIGroupInfo(
storage := map[string]rest.Storage{} storage := map[string]rest.Storage{}
teamResource := iamv0.TeamResourceInfo teamResource := iamv0.TeamResourceInfo
storage[teamResource.StoragePath()] = team.NewLegacyStore(b.Store) storage[teamResource.StoragePath()] = team.NewLegacyStore(b.store)
storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.Store) storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store)
teamBindingResource := iamv0.TeamBindingResourceInfo teamBindingResource := iamv0.TeamBindingResourceInfo
storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.Store) storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.store)
userResource := iamv0.UserResourceInfo userResource := iamv0.UserResourceInfo
storage[userResource.StoragePath()] = user.NewLegacyStore(b.Store) storage[userResource.StoragePath()] = user.NewLegacyStore(b.store, b.accessClient)
storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.Store) storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store)
serviceaccountResource := iamv0.ServiceAccountResourceInfo serviceaccountResource := iamv0.ServiceAccountResourceInfo
storage[serviceaccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.Store) storage[serviceaccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.store)
storage[serviceaccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.Store) storage[serviceaccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store)
if b.SSOService != nil { if b.sso != nil {
ssoResource := iamv0.SSOSettingResourceInfo ssoResource := iamv0.SSOSettingResourceInfo
storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.SSOService) storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso)
} }
// The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter // The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter
storage["display"] = user.NewLegacyDisplayREST(b.Store) storage["display"] = user.NewLegacyDisplayREST(b.store)
apiGroupInfo.VersionedResourcesStorageMap[iamv0.VERSION] = storage apiGroupInfo.VersionedResourcesStorageMap[iamv0.VERSION] = storage
return &apiGroupInfo, nil return &apiGroupInfo, nil
@ -116,16 +147,5 @@ func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
} }
func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer {
// TODO: handle authorization based in entity. return b.authorizer
return authorizer.AuthorizerFunc(
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "no identity found", err
}
if user.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "only grafana admins have access for now", nil
})
} }

View File

@ -10,6 +10,8 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/common"
@ -28,12 +30,13 @@ var (
var resource = iamv0.UserResourceInfo var resource = iamv0.UserResourceInfo
func NewLegacyStore(store legacy.LegacyIdentityStore) *LegacyStore { func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyStore {
return &LegacyStore{store} return &LegacyStore{store, ac}
} }
type LegacyStore struct { type LegacyStore struct {
store legacy.LegacyIdentityStore store legacy.LegacyIdentityStore
ac claims.AccessClient
} }
func (s *LegacyStore) New() runtime.Object { func (s *LegacyStore) New() runtime.Object {
@ -64,23 +67,92 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
return nil, err return nil, err
} }
found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ if s.ac != nil {
OrgID: ns.OrgID, return s.listWithCheck(ctx, ns, common.PaginationFromListOptions(options))
Pagination: common.PaginationFromListOptions(options), }
return s.listWithoutCheck(ctx, ns, common.PaginationFromListOptions(options))
}
func (s *LegacyStore) listWithCheck(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (runtime.Object, error) {
ident, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
check, err := s.ac.Compile(ctx, ident, claims.AccessRequest{
Verb: "list",
Resource: resource.GetName(),
Namespace: ns.Value,
}) })
if err != nil {
return nil, err
}
list := func(p common.Pagination) ([]iamv0.User, int64, int64, error) {
found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{
Pagination: p,
})
if err != nil {
return nil, 0, 0, err
}
out := make([]iamv0.User, 0, len(found.Users))
for _, u := range found.Users {
if check(ns.Value, strconv.FormatInt(u.ID, 10)) {
out = append(out, toUserItem(&u, ns.Value))
}
}
return out, found.Continue, found.RV, nil
}
items, c, rv, err := list(p)
if err != nil {
return nil, err
}
outer:
for len(items) < int(p.Limit) && c != 0 {
var more []iamv0.User
more, c, _, err = list(common.Pagination{Limit: p.Limit, Continue: c})
if err != nil {
return nil, err
}
for _, u := range more {
if len(items) == int(p.Limit) {
break outer
}
items = append(items, u)
}
}
obj := &iamv0.UserList{Items: items}
obj.ListMeta.Continue = common.OptionalFormatInt(c)
obj.ListMeta.ResourceVersion = common.OptionalFormatInt(rv)
return obj, nil
}
func (s *LegacyStore) listWithoutCheck(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (runtime.Object, error) {
found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{
Pagination: p,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
list := &iamv0.UserList{} list := &iamv0.UserList{}
for _, item := range found.Users { for _, item := range found.Users {
list.Items = append(list.Items, *toUserItem(&item, ns.Value)) list.Items = append(list.Items, toUserItem(&item, ns.Value))
} }
list.ListMeta.Continue = common.OptionalFormatInt(found.Continue) list.ListMeta.Continue = common.OptionalFormatInt(found.Continue)
list.ListMeta.ResourceVersion = common.OptionalFormatInt(found.RV) list.ListMeta.ResourceVersion = common.OptionalFormatInt(found.RV)
return list, nil
return list, err
} }
func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
@ -100,10 +172,12 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO
if len(found.Users) < 1 { if len(found.Users) < 1 {
return nil, resource.NewNotFound(name) return nil, resource.NewNotFound(name)
} }
return toUserItem(&found.Users[0], ns.Value), nil
obj := toUserItem(&found.Users[0], ns.Value)
return &obj, nil
} }
func toUserItem(u *user.User, ns string) *iamv0.User { func toUserItem(u *user.User, ns string) iamv0.User {
item := &iamv0.User{ item := &iamv0.User{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: u.UID, Name: u.UID,
@ -125,5 +199,5 @@ func toUserItem(u *user.User, ns string) *iamv0.User {
Name: "SQL", Name: "SQL",
Path: strconv.FormatInt(u.ID, 10), Path: strconv.FormatInt(u.ID, 10),
}) })
return item return *item
} }

View File

@ -28,6 +28,10 @@ type AccessControl interface {
// RegisterScopeAttributeResolver allows the caller to register a scope resolver for a // RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
// specific scope prefix (ex: datasources:name:) // specific scope prefix (ex: datasources:name:)
RegisterScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver) RegisterScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver)
// WithoutResolvers copies AccessControl without any configured resolvers.
// This is useful when we don't want to reuse any pre-configured resolvers
// for a authorization call.
WithoutResolvers() AccessControl
} }
type Service interface { type Service interface {

View File

@ -205,6 +205,16 @@ func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver a
a.resolvers.AddScopeAttributeResolver(prefix, resolver) a.resolvers.AddScopeAttributeResolver(prefix, resolver)
} }
func (a *AccessControl) WithoutResolvers() accesscontrol.AccessControl {
return &AccessControl{
features: a.features,
log: a.log,
zclient: a.zclient,
metrics: a.metrics,
resolvers: accesscontrol.NewResolvers(a.log),
}
}
func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg string, eval accesscontrol.Evaluator) { func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg string, eval accesscontrol.Evaluator) {
ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.debug") ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.debug")
defer span.End() defer span.End()

View File

@ -75,6 +75,10 @@ func (f FakeAccessControl) Evaluate(ctx context.Context, user identity.Requester
func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) { func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
} }
func (f FakeAccessControl) WithoutResolvers() accesscontrol.AccessControl {
return f
}
type FakeStore struct { type FakeStore struct {
ExpectedUserPermissions []accesscontrol.Permission ExpectedUserPermissions []accesscontrol.Permission
ExpectedBasicRolesPermissions []accesscontrol.Permission ExpectedBasicRolesPermissions []accesscontrol.Permission

View File

@ -0,0 +1,131 @@
package accesscontrol
import (
"context"
"errors"
"fmt"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
// ResourceResolver is called before authorization is performed.
// It can be used to translate resoruce name into one or more valid scopes that
// will be used for authorization. If more than one scope is returned from a resolver
// only one needs to match to allow call to be authorized.
type ResourceResolver interface {
Resolve(ctx context.Context, ns claims.NamespaceInfo, name string) ([]string, error)
}
// ResourceResolverFunc is an adapter so that functions can implement ResourceResolver.
type ResourceResolverFunc func(ctx context.Context, ns claims.NamespaceInfo, name string) ([]string, error)
func (r ResourceResolverFunc) Resolve(ctx context.Context, ns claims.NamespaceInfo, name string) ([]string, error) {
return r(ctx, ns, name)
}
type ResourceAuthorizerOptions struct {
// Resource is the resource name in plural.
Resource string
// Attr is attribute used for resource scope. It's usually 'id' or 'uid'
// depending on what is stored for the resource.
Attr string
// Mapping is used to translate k8s verb to rbac action.
// Key is the desired verb and value the rbac action it should be translated into.
Mapping map[string]string
// Resolver if passed can translate into one or more scopes used to authorize resource.
// This is useful when stored scopes are based on something else than k8s name or
// for resources that inherit permission from folder.
Resolver ResourceResolver
}
var _ claims.AccessClient = (*LegacyAccessClient)(nil)
func NewLegacyAccessClient(ac AccessControl, opts ...ResourceAuthorizerOptions) *LegacyAccessClient {
stored := map[string]ResourceAuthorizerOptions{}
for _, o := range opts {
if o.Mapping == nil {
o.Mapping = map[string]string{}
}
stored[o.Resource] = o
}
return &LegacyAccessClient{ac.WithoutResolvers(), stored}
}
type LegacyAccessClient struct {
ac AccessControl
opts map[string]ResourceAuthorizerOptions
}
// HasAccess implements claims.AccessClient.
func (c *LegacyAccessClient) HasAccess(ctx context.Context, id claims.AuthInfo, req claims.AccessRequest) (bool, error) {
ident, ok := id.(identity.Requester)
if !ok {
return false, errors.New("expected identity.Requester for legacy access control")
}
opts, ok := c.opts[req.Resource]
if !ok {
// For now we fallback to grafana admin if no options are found for resource.
if ident.GetIsGrafanaAdmin() {
return true, nil
}
return false, nil
}
action, ok := opts.Mapping[req.Verb]
if !ok {
return false, fmt.Errorf("missing action for %s %s", req.Verb, req.Resource)
}
ns, err := claims.ParseNamespace(req.Namespace)
if err != nil {
return false, err
}
var eval Evaluator
if req.Name != "" {
if opts.Resolver != nil {
scopes, err := opts.Resolver.Resolve(ctx, ns, req.Name)
if err != nil {
return false, err
}
eval = EvalPermission(action, scopes...)
} else {
eval = EvalPermission(action, fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, req.Name))
}
} else if req.Verb == "list" {
// For list request we need to filter out in storage layer.
eval = EvalPermission(action)
} else {
// Assuming that all non list request should have a valid name
return false, fmt.Errorf("unhandled authorization: %s %s", req.Group, req.Verb)
}
return c.ac.Evaluate(ctx, ident, eval)
}
// Compile implements claims.AccessClient.
func (c *LegacyAccessClient) Compile(ctx context.Context, id claims.AuthInfo, req claims.AccessRequest) (claims.AccessChecker, error) {
ident, ok := id.(identity.Requester)
if !ok {
return nil, errors.New("expected identity.Requester for legacy access control")
}
opts, ok := c.opts[req.Resource]
if !ok {
return nil, fmt.Errorf("unsupported resource: %s", req.Resource)
}
action, ok := opts.Mapping[req.Verb]
if !ok {
return nil, fmt.Errorf("missing action for %s %s", req.Verb, req.Resource)
}
check := Checker(ident, action)
return func(_, name string) bool {
return check(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, name))
}, nil
}

View File

@ -0,0 +1,118 @@
package accesscontrol_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
func TestResourceAuthorizer_HasAccess(t *testing.T) {
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
t.Run("should have no opinion for non resource requests", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
})
ok, err := a.HasAccess(context.Background(), &identity.StaticRequester{}, claims.AccessRequest{
Verb: "get",
Resource: "dashboards",
Namespace: "default",
Name: "1",
})
assert.Error(t, err)
assert.Equal(t, false, ok)
})
t.Run("should reject when user don't have correct scope", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{
"get": "dashboards:read",
},
})
ident := newIdent(
accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:2"},
)
ok, err := a.HasAccess(context.Background(), ident, claims.AccessRequest{
Verb: "get",
Namespace: "default",
Resource: "dashboards",
Name: "1",
})
assert.NoError(t, err)
assert.Equal(t, false, ok)
})
t.Run("should just check action for list requests", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{
"list": "dashboards:read",
},
})
ident := newIdent(
accesscontrol.Permission{Action: "dashboards:read"},
)
ok, err := a.HasAccess(context.Background(), ident, claims.AccessRequest{
Verb: "list",
Namespace: "default",
Resource: "dashboards",
})
assert.NoError(t, err)
assert.Equal(t, true, ok)
})
t.Run("should allow when user have correct scope", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{
"get": "dashboards:read",
},
})
ident := newIdent(
accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:1"},
)
ok, err := a.HasAccess(context.Background(), ident, claims.AccessRequest{
Verb: "get",
Namespace: "default",
Resource: "dashboards",
Name: "1",
})
assert.NoError(t, err)
assert.Equal(t, true, ok)
})
}
func newIdent(permissions ...accesscontrol.Permission) *identity.StaticRequester {
pmap := map[string][]string{}
for _, p := range permissions {
pmap[p.Action] = append(pmap[p.Action], p.Scope)
}
return &identity.StaticRequester{
OrgID: 1,
Permissions: map[int64]map[string][]string{1: pmap},
}
}

View File

@ -1,15 +1,12 @@
package accesscontrol package accesscontrol
import ( import (
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/apimachinery/identity"
) )
func Checker(user *user.SignedInUser, action string) func(scopes ...string) bool { func Checker(user identity.Requester, action string) func(scopes ...string) bool {
if user.Permissions == nil || user.Permissions[user.OrgID] == nil { permissions := user.GetPermissions()
return func(scopes ...string) bool { return false } userScopes, ok := permissions[action]
}
userScopes, ok := user.Permissions[user.OrgID][action]
if !ok { if !ok {
return func(scopes ...string) bool { return false } return func(scopes ...string) bool { return false }
} }

View File

@ -265,3 +265,8 @@ func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol
} }
return nil return nil
} }
// WithoutResolvers implements fullAccessControl.
func (m *Mock) WithoutResolvers() accesscontrol.AccessControl {
return m
}

View File

@ -0,0 +1,49 @@
package authorizer
import (
"context"
"errors"
"github.com/grafana/authlib/claims"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
func NewResourceAuthorizer(c claims.AccessClient) authorizer.Authorizer {
return ResourceAuthorizer{c}
}
// ResourceAuthorizer is used to translate authorizer.Authorizer calls to claims.AccessClient calls
type ResourceAuthorizer struct {
c claims.AccessClient
}
func (r ResourceAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
ident, ok := claims.From(ctx)
if !ok {
return authorizer.DecisionDeny, "", errors.New("no identity found for request")
}
ok, err := r.c.HasAccess(ctx, ident, claims.AccessRequest{
Verb: attr.GetVerb(),
Group: attr.GetAPIGroup(),
Resource: attr.GetResource(),
Namespace: attr.GetNamespace(),
Name: attr.GetName(),
Subresource: attr.GetSubresource(),
Path: attr.GetPath(),
})
if err != nil {
return authorizer.DecisionDeny, "", err
}
if !ok {
return authorizer.DecisionDeny, "unauthorized request", nil
}
return authorizer.DecisionAllow, "", nil
}

View File

@ -32,8 +32,8 @@ func (a *recordingAccessControlFake) RegisterScopeAttributeResolver(prefix strin
panic("implement me") panic("implement me")
} }
func (a *recordingAccessControlFake) IsDisabled() bool { func (a *recordingAccessControlFake) WithoutResolvers() accesscontrol.AccessControl {
return a.Disabled panic("unimplemented")
} }
var _ accesscontrol.AccessControl = &recordingAccessControlFake{} var _ accesscontrol.AccessControl = &recordingAccessControlFake{}

View File

@ -129,6 +129,11 @@ func (a *recordingAccessControlFake) RegisterScopeAttributeResolver(prefix strin
panic("implement me") panic("implement me")
} }
func (a *recordingAccessControlFake) WithoutResolvers() ac.AccessControl {
// TODO implement me
panic("implement me")
}
func (a *recordingAccessControlFake) IsDisabled() bool { func (a *recordingAccessControlFake) IsDisabled() bool {
return a.Disabled return a.Disabled
} }