diff --git a/pkg/apimachinery/utils/resource.go b/pkg/apimachinery/utils/resource.go index 17725db2c0c..683b984e201 100644 --- a/pkg/apimachinery/utils/resource.go +++ b/pkg/apimachinery/utils/resource.go @@ -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 { return info.singularName } diff --git a/pkg/apiserver/endpoints/filters/requester.go b/pkg/apiserver/endpoints/filters/requester.go index 63baf363619..acb2127abd3 100644 --- a/pkg/apiserver/endpoints/filters/requester.go +++ b/pkg/apiserver/endpoints/filters/requester.go @@ -51,7 +51,9 @@ func WithRequester(handler http.Handler) http.Handler { Permissions: map[int64]map[string][]string{ orgId: { "*": {"*"}, // 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.ActionDashboardsRead: {"*"}, // dashboards.ActionDashboardsCreate: {"*"}, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go new file mode 100644 index 00000000000..022f6b34618 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer.go @@ -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 +} diff --git a/pkg/registry/apis/iam/legacy/sql.go b/pkg/registry/apis/iam/legacy/sql.go index 554bd114412..47b29e65e82 100644 --- a/pkg/registry/apis/iam/legacy/sql.go +++ b/pkg/registry/apis/iam/legacy/sql.go @@ -14,6 +14,7 @@ import ( type LegacyIdentityStore interface { 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) ListUserTeams(ctx context.Context, ns claims.NamespaceInfo, query ListUserTeamsQuery) (*ListUserTeamsResult, error) @@ -37,6 +38,11 @@ func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStor type legacySQLStore struct { sql legacysql.LegacyDatabaseProvider + ac claims.AccessClient +} + +func (s *legacySQLStore) WithAccessClient(ac claims.AccessClient) { + s.ac = ac } // Templates setup. diff --git a/pkg/registry/apis/iam/legacy/user.go b/pkg/registry/apis/iam/legacy/user.go index f1f51c43481..3b479cf25eb 100644 --- a/pkg/registry/apis/iam/legacy/user.go +++ b/pkg/registry/apis/iam/legacy/user.go @@ -2,6 +2,7 @@ package legacy import ( "context" + "errors" "fmt" "text/template" @@ -13,6 +14,79 @@ import ( "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 { OrgID int64 UID string diff --git a/pkg/registry/apis/iam/legacy/user_internal_id.sql b/pkg/registry/apis/iam/legacy/user_internal_id.sql new file mode 100644 index 00000000000..b2d8e4092fd --- /dev/null +++ b/pkg/registry/apis/iam/legacy/user_internal_id.sql @@ -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; diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index c1536f08aec..ff6d89b48b7 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -3,19 +3,6 @@ package iam import ( "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" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -25,14 +12,34 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" 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) // This is used just so wire has something unique to return type IdentityAccessManagementAPIBuilder struct { - Store legacy.LegacyIdentityStore - SSOService ssosettings.Service + store legacy.LegacyIdentityStore + authorizer authorizer.Authorizer + accessClient claims.AccessClient + + // Not set for multi-tenant deployment for now + sso ssosettings.Service } func RegisterAPIService( @@ -40,20 +47,44 @@ func RegisterAPIService( apiregistration builder.APIRegistrar, ssoService ssosettings.Service, sql db.DB, + ac accesscontrol.AccessControl, ) (*IdentityAccessManagementAPIBuilder, error) { 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{ - Store: legacy.NewLegacySQLStores(legacysql.NewDatabaseProvider(sql)), - SSOService: ssoService, + store: store, + sso: ssoService, + authorizer: authorizer, + accessClient: client, } apiregistration.RegisterAPI(builder) 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 { return iamv0.SchemeGroupVersion } @@ -80,27 +111,27 @@ func (b *IdentityAccessManagementAPIBuilder) GetAPIGroupInfo( storage := map[string]rest.Storage{} teamResource := iamv0.TeamResourceInfo - storage[teamResource.StoragePath()] = team.NewLegacyStore(b.Store) - storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.Store) + storage[teamResource.StoragePath()] = team.NewLegacyStore(b.store) + storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) teamBindingResource := iamv0.TeamBindingResourceInfo - storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.Store) + storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.store) userResource := iamv0.UserResourceInfo - storage[userResource.StoragePath()] = user.NewLegacyStore(b.Store) - storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.Store) + storage[userResource.StoragePath()] = user.NewLegacyStore(b.store, b.accessClient) + storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) serviceaccountResource := iamv0.ServiceAccountResourceInfo - storage[serviceaccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.Store) - storage[serviceaccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.Store) + storage[serviceaccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.store) + storage[serviceaccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) - if b.SSOService != nil { + if b.sso != nil { 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 - storage["display"] = user.NewLegacyDisplayREST(b.Store) + storage["display"] = user.NewLegacyDisplayREST(b.store) apiGroupInfo.VersionedResourcesStorageMap[iamv0.VERSION] = storage return &apiGroupInfo, nil @@ -116,16 +147,5 @@ func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes() *builder.APIRoutes { } func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { - // TODO: handle authorization based in entity. - 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 - }) + return b.authorizer } diff --git a/pkg/registry/apis/iam/user/store.go b/pkg/registry/apis/iam/user/store.go index b67ce7cf22a..26104cf9638 100644 --- a/pkg/registry/apis/iam/user/store.go +++ b/pkg/registry/apis/iam/user/store.go @@ -10,6 +10,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "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" iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/iam/common" @@ -28,12 +30,13 @@ var ( var resource = iamv0.UserResourceInfo -func NewLegacyStore(store legacy.LegacyIdentityStore) *LegacyStore { - return &LegacyStore{store} +func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyStore { + return &LegacyStore{store, ac} } type LegacyStore struct { store legacy.LegacyIdentityStore + ac claims.AccessClient } func (s *LegacyStore) New() runtime.Object { @@ -64,23 +67,92 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ - OrgID: ns.OrgID, - Pagination: common.PaginationFromListOptions(options), + if s.ac != nil { + return s.listWithCheck(ctx, ns, 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 { return nil, err } list := &iamv0.UserList{} 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.ResourceVersion = common.OptionalFormatInt(found.RV) - - return list, err + return list, nil } 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 { 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{ ObjectMeta: metav1.ObjectMeta{ Name: u.UID, @@ -125,5 +199,5 @@ func toUserItem(u *user.User, ns string) *iamv0.User { Name: "SQL", Path: strconv.FormatInt(u.ID, 10), }) - return item + return *item } diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index d833654d46e..5e614226f3e 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -28,6 +28,10 @@ type AccessControl interface { // RegisterScopeAttributeResolver allows the caller to register a scope resolver for a // specific scope prefix (ex: datasources:name:) 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 { diff --git a/pkg/services/accesscontrol/acimpl/accesscontrol.go b/pkg/services/accesscontrol/acimpl/accesscontrol.go index 4a6b8a99e3e..acd1edc7149 100644 --- a/pkg/services/accesscontrol/acimpl/accesscontrol.go +++ b/pkg/services/accesscontrol/acimpl/accesscontrol.go @@ -205,6 +205,16 @@ func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver a 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) { ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.debug") defer span.End() diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index d916f553f32..ce23a52544e 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -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) WithoutResolvers() accesscontrol.AccessControl { + return f +} + type FakeStore struct { ExpectedUserPermissions []accesscontrol.Permission ExpectedBasicRolesPermissions []accesscontrol.Permission diff --git a/pkg/services/accesscontrol/authorizer.go b/pkg/services/accesscontrol/authorizer.go new file mode 100644 index 00000000000..5ff41207592 --- /dev/null +++ b/pkg/services/accesscontrol/authorizer.go @@ -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 +} diff --git a/pkg/services/accesscontrol/authorizer_test.go b/pkg/services/accesscontrol/authorizer_test.go new file mode 100644 index 00000000000..b53de0326dc --- /dev/null +++ b/pkg/services/accesscontrol/authorizer_test.go @@ -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}, + } +} diff --git a/pkg/services/accesscontrol/checker.go b/pkg/services/accesscontrol/checker.go index 1b0e7d48e03..94095edfca1 100644 --- a/pkg/services/accesscontrol/checker.go +++ b/pkg/services/accesscontrol/checker.go @@ -1,15 +1,12 @@ package accesscontrol 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 { - if user.Permissions == nil || user.Permissions[user.OrgID] == nil { - return func(scopes ...string) bool { return false } - } - - userScopes, ok := user.Permissions[user.OrgID][action] +func Checker(user identity.Requester, action string) func(scopes ...string) bool { + permissions := user.GetPermissions() + userScopes, ok := permissions[action] if !ok { return func(scopes ...string) bool { return false } } diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index ef6f50b9d93..747df53d23e 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -265,3 +265,8 @@ func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol } return nil } + +// WithoutResolvers implements fullAccessControl. +func (m *Mock) WithoutResolvers() accesscontrol.AccessControl { + return m +} diff --git a/pkg/services/apiserver/auth/authorizer/resource.go b/pkg/services/apiserver/auth/authorizer/resource.go new file mode 100644 index 00000000000..a2c0a05534a --- /dev/null +++ b/pkg/services/apiserver/auth/authorizer/resource.go @@ -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 +} diff --git a/pkg/services/ngalert/accesscontrol/testing.go b/pkg/services/ngalert/accesscontrol/testing.go index 38909578d30..7b79a533a8d 100644 --- a/pkg/services/ngalert/accesscontrol/testing.go +++ b/pkg/services/ngalert/accesscontrol/testing.go @@ -32,8 +32,8 @@ func (a *recordingAccessControlFake) RegisterScopeAttributeResolver(prefix strin panic("implement me") } -func (a *recordingAccessControlFake) IsDisabled() bool { - return a.Disabled +func (a *recordingAccessControlFake) WithoutResolvers() accesscontrol.AccessControl { + panic("unimplemented") } var _ accesscontrol.AccessControl = &recordingAccessControlFake{} diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go index 8b85525de78..cb9dba0a02d 100644 --- a/pkg/services/ngalert/api/testing.go +++ b/pkg/services/ngalert/api/testing.go @@ -129,6 +129,11 @@ func (a *recordingAccessControlFake) RegisterScopeAttributeResolver(prefix strin panic("implement me") } +func (a *recordingAccessControlFake) WithoutResolvers() ac.AccessControl { + // TODO implement me + panic("implement me") +} + func (a *recordingAccessControlFake) IsDisabled() bool { return a.Disabled }