Files
Misi c6a6b9fdd2 IAM: Create and delete user from the legacy store (#107694)
* Add Create for User + DualWriter setup

* Add delete User

* Fix delete + access check

* Add tests for delete user

* Add tests for create user

* Fixes

* Use sqlx session to fix database locked issues

* wip authz checks

* legacyAccessClient

* Update legacyAccessClient, add tests for create user

* Close rows before running other queries

* Use ExecWithReturningId

* Verify deletion in the tests

* Add Validate and Mutate

* Other changes

* Address feedback

* Update tests

---------

Co-authored-by: Gabriel Mabille <gabriel.mabille@grafana.com>
2025-07-17 11:50:40 +02:00

166 lines
5.5 KiB
Go

package accesscontrol
import (
"context"
"errors"
"fmt"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// 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
// Unchecked is used to skip authorization checks for specified verbs.
// This takes precedence over configured Mapping
Unchecked map[string]bool
// 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.
// If no mapping is provided it will get a "default" mapping.
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{}
defaultMapping := func(r string) map[string]string {
return map[string]string{
utils.VerbGet: fmt.Sprintf("%s:read", r),
utils.VerbList: fmt.Sprintf("%s:read", r),
utils.VerbWatch: fmt.Sprintf("%s:read", r),
utils.VerbCreate: fmt.Sprintf("%s:create", r),
utils.VerbUpdate: fmt.Sprintf("%s:write", r),
utils.VerbPatch: fmt.Sprintf("%s:write", r),
utils.VerbDelete: fmt.Sprintf("%s:delete", r),
utils.VerbDeleteCollection: fmt.Sprintf("%s:delete", r),
utils.VerbGetPermissions: fmt.Sprintf("%s.permissions:read", r),
utils.VerbSetPermissions: fmt.Sprintf("%s.permissions:write", r),
}
}
for _, o := range opts {
if o.Unchecked == nil {
o.Unchecked = map[string]bool{}
}
if o.Mapping == nil {
o.Mapping = defaultMapping(o.Resource)
}
stored[o.Resource] = o
}
return &LegacyAccessClient{ac.WithoutResolvers(), stored}
}
type LegacyAccessClient struct {
ac AccessControl
opts map[string]ResourceAuthorizerOptions
}
func (c *LegacyAccessClient) Check(ctx context.Context, id claims.AuthInfo, req claims.CheckRequest) (claims.CheckResponse, error) {
ident, ok := id.(identity.Requester)
if !ok {
return claims.CheckResponse{}, 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 claims.CheckResponse{Allowed: true}, nil
}
return claims.CheckResponse{}, nil
}
skip := opts.Unchecked[req.Verb]
if skip {
return claims.CheckResponse{Allowed: true}, nil
}
action, ok := opts.Mapping[req.Verb]
if !ok {
return claims.CheckResponse{}, fmt.Errorf("missing action for %s %s", req.Verb, req.Resource)
}
ns, err := claims.ParseNamespace(req.Namespace)
if err != nil {
return claims.CheckResponse{}, err
}
var eval Evaluator
if req.Name != "" {
if opts.Resolver != nil {
scopes, err := opts.Resolver.Resolve(ctx, ns, req.Name)
if err != nil {
return claims.CheckResponse{}, err
}
eval = EvalPermission(action, scopes...)
} else {
eval = EvalPermission(action, fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, req.Name))
}
} else if req.Verb == utils.VerbList || req.Verb == utils.VerbCreate {
// For list request we need to filter out in storage layer.
// For create requests we don't have a name yet, so we can only check if the action is allowed.
eval = EvalPermission(action)
} else {
// Assuming that all non list request should have a valid name
return claims.CheckResponse{}, fmt.Errorf("unhandled authorization: %s %s", req.Group, req.Verb)
}
allowed, err := c.ac.Evaluate(ctx, ident, eval)
if err != nil {
return claims.CheckResponse{}, err
}
return claims.CheckResponse{Allowed: allowed}, nil
}
func (c *LegacyAccessClient) Compile(ctx context.Context, id claims.AuthInfo, req claims.ListRequest) (claims.ItemChecker, 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[utils.VerbList]
if !ok {
return nil, fmt.Errorf("missing action for %s %s", utils.VerbList, req.Resource)
}
check := Checker(ident, action)
return func(name, _ string) bool {
return check(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, name))
}, nil
}