mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Zanzana: reconcile generic schema (#95492)
* Rename to CheckObject * Implement authz.AccessClient * Move folder tree to reconciler and use new schema * Move shared functionality to common package * Add reconciler for managed permissions and resource translations * Add support for folder resources
This commit is contained in:
parent
7b5c84f366
commit
e0163c93c2
@ -236,7 +236,7 @@ func (a *AccessControl) Check(ctx context.Context, req accesscontrol.CheckReques
|
||||
}
|
||||
|
||||
// Check direct access to resource first
|
||||
res, err := a.zclient.Check(ctx, in)
|
||||
res, err := a.zclient.CheckObject(ctx, in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@ -262,7 +262,7 @@ func (a *AccessControl) Check(ctx context.Context, req accesscontrol.CheckReques
|
||||
TupleKey: folderKey,
|
||||
}
|
||||
|
||||
folderRes, err := a.zclient.Check(ctx, folderReq)
|
||||
folderRes, err := a.zclient.CheckObject(ctx, folderReq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
@ -58,6 +58,119 @@ func teamMembershipCollector(store db.DB) legacyTupleCollector {
|
||||
}
|
||||
}
|
||||
|
||||
// folderTreeCollector collects folder tree structure and writes it as relation tuples
|
||||
func folderTreeCollector2(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.folderTreeCollector")
|
||||
defer span.End()
|
||||
|
||||
const query = `
|
||||
SELECT uid, parent_uid, org_id FROM folder
|
||||
`
|
||||
type folder struct {
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
FolderUID string `xorm:"uid"`
|
||||
ParentUID string `xorm:"parent_uid"`
|
||||
}
|
||||
|
||||
var folders []folder
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query).Find(&folders)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, f := range folders {
|
||||
var tuple *openfgav1.TupleKey
|
||||
if f.ParentUID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tuple = &openfgav1.TupleKey{
|
||||
Object: zanzana.NewTupleEntry("folder2", f.FolderUID, ""),
|
||||
Relation: zanzana.RelationParent,
|
||||
User: zanzana.NewTupleEntry("folder2", f.ParentUID, ""),
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
// managedPermissionsCollector collects managed permissions into provided tuple map.
|
||||
// It will only store actions that are supported by our schema. Managed permissions can
|
||||
// be directly mapped to user/team/role without having to write an intermediate role.
|
||||
func managedPermissionsCollector2(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
query := `
|
||||
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id
|
||||
FROM permission p
|
||||
INNER JOIN role r ON p.role_id = r.id
|
||||
LEFT JOIN user_role ur ON r.id = ur.role_id
|
||||
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
|
||||
LEFT JOIN team_role tr ON r.id = tr.role_id
|
||||
LEFT JOIN team t ON tr.team_id = t.id
|
||||
LEFT JOIN builtin_role br ON r.id = br.role_id
|
||||
WHERE r.name LIKE 'managed:%'
|
||||
`
|
||||
type Permission struct {
|
||||
RoleName string `xorm:"role_name"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Action string `xorm:"action"`
|
||||
Kind string
|
||||
Identifier string
|
||||
UserUID string `xorm:"user_uid"`
|
||||
TeamUID string `xorm:"team_uid"`
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query).Find(&permissions)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, p := range permissions {
|
||||
var subject string
|
||||
if len(p.UserUID) > 0 {
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "")
|
||||
} else if len(p.TeamUID) > 0 {
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member")
|
||||
} else {
|
||||
// FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place
|
||||
continue
|
||||
}
|
||||
|
||||
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
func zanzanaCollector(client zanzana.Client, relations []string) zanzanaTupleCollector {
|
||||
return func(ctx context.Context, client zanzana.Client, object string) (map[string]*openfgav1.TupleKey, error) {
|
||||
// list will use continuation token to collect all tuples for object and relation
|
||||
|
@ -65,6 +65,18 @@ func NewZanzanaReconciler(client zanzana.Client, store db.DB, lock *serverlock.S
|
||||
zanzanaCollector(client, []string{zanzana.RelationTeamMember, zanzana.RelationTeamAdmin}),
|
||||
client,
|
||||
),
|
||||
newResourceReconciler(
|
||||
"folder tree",
|
||||
folderTreeCollector2(store),
|
||||
zanzanaCollector(client, []string{zanzana.RelationParent}),
|
||||
client,
|
||||
),
|
||||
newResourceReconciler(
|
||||
"managed permissison",
|
||||
managedPermissionsCollector2(store),
|
||||
zanzanaCollector(client, zanzana.ResourceRelations),
|
||||
client,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -136,7 +148,6 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
||||
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
||||
}
|
||||
|
||||
// in tests we can skip creating a lock
|
||||
if r.lock == nil {
|
||||
run(ctx)
|
||||
return
|
||||
|
@ -4,18 +4,23 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/authlib/claims"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/client"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/zanzana/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// Client is a wrapper around [openfgav1.OpenFGAServiceClient]
|
||||
type Client interface {
|
||||
Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error)
|
||||
authz.AccessClient
|
||||
List(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (*authzextv1.ListResponse, error)
|
||||
|
||||
CheckObject(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error)
|
||||
Read(ctx context.Context, in *openfgav1.ReadRequest) (*openfgav1.ReadResponse, error)
|
||||
ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error)
|
||||
Write(ctx context.Context, in *openfgav1.WriteRequest) error
|
||||
|
@ -11,9 +11,17 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/wrapperspb"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
"github.com/grafana/authlib/claims"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/zanzana/proto/v1"
|
||||
)
|
||||
|
||||
var _ authz.AccessClient = (*Client)(nil)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/client")
|
||||
|
||||
type ClientOption func(c *Client)
|
||||
@ -32,7 +40,10 @@ func WithLogger(logger log.Logger) ClientOption {
|
||||
|
||||
type Client struct {
|
||||
logger log.Logger
|
||||
client openfgav1.OpenFGAServiceClient
|
||||
openfga openfgav1.OpenFGAServiceClient
|
||||
authz authzv1.AuthzServiceClient
|
||||
authzext authzextv1.AuthzExtentionServiceClient
|
||||
|
||||
tenantID string
|
||||
storeID string
|
||||
modelID string
|
||||
@ -40,7 +51,9 @@ type Client struct {
|
||||
|
||||
func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption) (*Client, error) {
|
||||
c := &Client{
|
||||
client: openfgav1.NewOpenFGAServiceClient(cc),
|
||||
openfga: openfgav1.NewOpenFGAServiceClient(cc),
|
||||
authz: authzv1.NewAuthzServiceClient(cc),
|
||||
authzext: authzextv1.NewAuthzExtentionServiceClient(cc),
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
@ -72,13 +85,70 @@ func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
|
||||
// Check implements authz.AccessClient.
|
||||
func (c *Client) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "authz.zanzana.client.Check")
|
||||
defer span.End()
|
||||
|
||||
res, err := c.authz.Check(ctx, &authzv1.CheckRequest{
|
||||
Subject: id.GetUID(),
|
||||
Verb: req.Verb,
|
||||
Group: req.Group,
|
||||
Resource: req.Resource,
|
||||
Namespace: req.Namespace,
|
||||
Name: req.Name,
|
||||
Subresource: req.Subresource,
|
||||
Path: req.Path,
|
||||
Folder: req.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return authz.CheckResponse{}, err
|
||||
}
|
||||
|
||||
return authz.CheckResponse{Allowed: res.GetAllowed()}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
|
||||
ctx, span := tracer.Start(ctx, "authz.zanzana.client.Compile")
|
||||
defer span.End()
|
||||
|
||||
_, err := c.authzext.List(ctx, &authzextv1.ListRequest{
|
||||
Subject: id.GetUID(),
|
||||
Group: req.Group,
|
||||
Verb: utils.VerbList,
|
||||
Resource: req.Resource,
|
||||
Namespace: req.Namespace,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FIXME: implement checker
|
||||
return func(namespace, name, folder string) bool { return false }, nil
|
||||
}
|
||||
|
||||
func (c *Client) List(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (*authzextv1.ListResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "authz.zanzana.client.List")
|
||||
defer span.End()
|
||||
|
||||
return c.authzext.List(ctx, &authzextv1.ListRequest{
|
||||
Subject: id.GetUID(),
|
||||
Group: req.Group,
|
||||
Verb: utils.VerbList,
|
||||
Resource: req.Resource,
|
||||
Namespace: req.Namespace,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) CheckObject(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "authz.zanzana.client.CheckObject")
|
||||
defer span.End()
|
||||
|
||||
in.StoreId = c.storeID
|
||||
in.AuthorizationModelId = c.modelID
|
||||
return c.client.Check(ctx, in)
|
||||
return c.openfga.Check(ctx, in)
|
||||
}
|
||||
|
||||
func (c *Client) Read(ctx context.Context, in *openfgav1.ReadRequest) (*openfgav1.ReadResponse, error) {
|
||||
@ -86,7 +156,7 @@ func (c *Client) Read(ctx context.Context, in *openfgav1.ReadRequest) (*openfgav
|
||||
defer span.End()
|
||||
|
||||
in.StoreId = c.storeID
|
||||
return c.client.Read(ctx, in)
|
||||
return c.openfga.Read(ctx, in)
|
||||
}
|
||||
|
||||
func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) {
|
||||
@ -96,13 +166,13 @@ func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsReque
|
||||
|
||||
in.StoreId = c.storeID
|
||||
in.AuthorizationModelId = c.modelID
|
||||
return c.client.ListObjects(ctx, in)
|
||||
return c.openfga.ListObjects(ctx, in)
|
||||
}
|
||||
|
||||
func (c *Client) Write(ctx context.Context, in *openfgav1.WriteRequest) error {
|
||||
in.StoreId = c.storeID
|
||||
in.AuthorizationModelId = c.modelID
|
||||
_, err := c.client.Write(ctx, in)
|
||||
_, err := c.openfga.Write(ctx, in)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -115,7 +185,7 @@ func (c *Client) getStore(ctx context.Context, name string) (*openfgav1.Store, e
|
||||
// We should create an issue to support some way to get stores by name.
|
||||
// For now we need to go thourh all stores until we find a match or we hit the end.
|
||||
for {
|
||||
res, err := c.client.ListStores(ctx, &openfgav1.ListStoresRequest{
|
||||
res, err := c.openfga.ListStores(ctx, &openfgav1.ListStoresRequest{
|
||||
PageSize: &wrapperspb.Int32Value{Value: 20},
|
||||
ContinuationToken: continuationToken,
|
||||
})
|
||||
@ -142,7 +212,7 @@ func (c *Client) getStore(ctx context.Context, name string) (*openfgav1.Store, e
|
||||
func (c *Client) loadModel(ctx context.Context, storeID string) (string, error) {
|
||||
// ReadAuthorizationModels returns authorization models for a store sorted in descending order of creation.
|
||||
// So with a pageSize of 1 we will get the latest model.
|
||||
res, err := c.client.ReadAuthorizationModels(ctx, &openfgav1.ReadAuthorizationModelsRequest{
|
||||
res, err := c.openfga.ReadAuthorizationModels(ctx, &openfgav1.ReadAuthorizationModelsRequest{
|
||||
StoreId: storeID,
|
||||
PageSize: &wrapperspb.Int32Value{Value: 1},
|
||||
})
|
||||
|
@ -3,16 +3,34 @@ package client
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/authlib/claims"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/zanzana/proto/v1"
|
||||
)
|
||||
|
||||
var _ authz.AccessClient = (*NoopClient)(nil)
|
||||
|
||||
func NewNoop() *NoopClient {
|
||||
return &NoopClient{}
|
||||
}
|
||||
|
||||
type NoopClient struct{}
|
||||
|
||||
func (nc NoopClient) Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
|
||||
func (nc *NoopClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
|
||||
return authz.CheckResponse{}, nil
|
||||
}
|
||||
|
||||
func (nc *NoopClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nc *NoopClient) List(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (*authzextv1.ListResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nc NoopClient) CheckObject(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
34
pkg/services/authz/zanzana/common/info.go
Normal file
34
pkg/services/authz/zanzana/common/info.go
Normal file
@ -0,0 +1,34 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
|
||||
folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
)
|
||||
|
||||
type TypeInfo struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
var typedResources = map[string]TypeInfo{
|
||||
NewNamespaceResourceIdent(
|
||||
folderalpha1.FolderResourceInfo.GroupResource().Group,
|
||||
folderalpha1.FolderResourceInfo.GroupResource().Resource,
|
||||
): TypeInfo{Type: "folder2"},
|
||||
}
|
||||
|
||||
func GetTypeInfo(group, resource string) (TypeInfo, bool) {
|
||||
info, ok := typedResources[NewNamespaceResourceIdent(group, resource)]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
var VerbMapping = map[string]string{
|
||||
utils.VerbGet: "read",
|
||||
utils.VerbList: "read",
|
||||
utils.VerbWatch: "read",
|
||||
utils.VerbCreate: "create",
|
||||
utils.VerbUpdate: "write",
|
||||
utils.VerbPatch: "write",
|
||||
utils.VerbDelete: "delete",
|
||||
utils.VerbDeleteCollection: "delete",
|
||||
}
|
86
pkg/services/authz/zanzana/common/tuple.go
Normal file
86
pkg/services/authz/zanzana/common/tuple.go
Normal file
@ -0,0 +1,86 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
resourceType = "resource"
|
||||
namespaceType = "namespace"
|
||||
folderResourceType = "folder_resource"
|
||||
)
|
||||
|
||||
func NewTypedIdent(typ string, name string) string {
|
||||
return fmt.Sprintf("%s:%s", typ, name)
|
||||
}
|
||||
|
||||
func NewResourceIdent(group, resource, name string) string {
|
||||
return fmt.Sprintf("%s:%s/%s", resourceType, FormatGroupResource(group, resource), name)
|
||||
}
|
||||
|
||||
func NewFolderResourceIdent(group, resource, folder string) string {
|
||||
return fmt.Sprintf("%s:%s/%s", folderResourceType, FormatGroupResource(group, resource), folder)
|
||||
}
|
||||
|
||||
func NewNamespaceResourceIdent(group, resource string) string {
|
||||
return fmt.Sprintf("%s:%s", namespaceType, FormatGroupResource(group, resource))
|
||||
}
|
||||
|
||||
func FormatGroupResource(group, resource string) string {
|
||||
return fmt.Sprintf("%s/%s", group, resource)
|
||||
}
|
||||
|
||||
func NewResourceTuple(subject, relation, group, resource, name string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: NewResourceIdent(group, resource, name),
|
||||
Condition: &openfgav1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"resource_group": structpb.NewStringValue(FormatGroupResource(group, resource)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewFolderResourceTuple(subject, relation, group, resource, folder string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: NewFolderResourceIdent(group, resource, folder),
|
||||
Condition: &openfgav1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"resource_group": structpb.NewStringValue(FormatGroupResource(group, resource)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewNamespaceResourceTuple(subject, relation, group, resource string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: NewNamespaceResourceIdent(group, resource),
|
||||
}
|
||||
}
|
||||
|
||||
func NewFolderTuple(subject, relation, name string) *openfgav1.TupleKey {
|
||||
return NewTypedTuple("folder2", subject, relation, name)
|
||||
}
|
||||
|
||||
func NewTypedTuple(typ, subject, relation, name string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: NewTypedIdent(typ, name),
|
||||
}
|
||||
}
|
@ -11,8 +11,6 @@ import (
|
||||
"go.opentelemetry.io/otel"
|
||||
"google.golang.org/protobuf/types/known/wrapperspb"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/zanzana/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/schema"
|
||||
@ -199,47 +197,3 @@ func (s *Server) loadModel(ctx context.Context, storeID string, modules []transf
|
||||
|
||||
return writeRes.GetAuthorizationModelId(), nil
|
||||
}
|
||||
|
||||
func newTypedIdent(typ string, name string) string {
|
||||
return fmt.Sprintf("%s:%s", typ, name)
|
||||
}
|
||||
|
||||
func newResourceIdent(group, resource, name string) string {
|
||||
return fmt.Sprintf("%s:%s/%s", resourceType, formatGroupResource(group, resource), name)
|
||||
}
|
||||
|
||||
func newFolderResourceIdent(group, resource, folder string) string {
|
||||
return fmt.Sprintf("%s:%s/%s", folderResourceType, formatGroupResource(group, resource), folder)
|
||||
}
|
||||
|
||||
func newNamespaceResourceIdent(group, resource string) string {
|
||||
return fmt.Sprintf("%s:%s", namespaceType, formatGroupResource(group, resource))
|
||||
}
|
||||
|
||||
func formatGroupResource(group, resource string) string {
|
||||
return fmt.Sprintf("%s/%s", group, resource)
|
||||
}
|
||||
|
||||
type TypeInfo struct {
|
||||
typ string
|
||||
}
|
||||
|
||||
var typedResources = map[string]TypeInfo{
|
||||
newNamespaceResourceIdent(folderalpha1.GROUP, folderalpha1.RESOURCE): TypeInfo{typ: "folder2"},
|
||||
}
|
||||
|
||||
func typeInfo(group, resource string) (TypeInfo, bool) {
|
||||
info, ok := typedResources[newNamespaceResourceIdent(group, resource)]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
var mapping = map[string]string{
|
||||
utils.VerbGet: "read",
|
||||
utils.VerbList: "read",
|
||||
utils.VerbWatch: "read",
|
||||
utils.VerbCreate: "create",
|
||||
utils.VerbUpdate: "write",
|
||||
utils.VerbPatch: "write",
|
||||
utils.VerbDelete: "delete",
|
||||
utils.VerbDeleteCollection: "delete",
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
@ -12,14 +13,14 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
|
||||
ctx, span := tracer.Start(ctx, "authzServer.Check")
|
||||
defer span.End()
|
||||
|
||||
if info, ok := typeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.checkTyped(ctx, r, info)
|
||||
}
|
||||
return s.checkGeneric(ctx, r)
|
||||
}
|
||||
|
||||
func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info TypeInfo) (*authzv1.CheckResponse, error) {
|
||||
relation := mapping[r.GetVerb()]
|
||||
func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) {
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has direct access to resource
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
@ -28,7 +29,7 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info T
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newTypedIdent(info.typ, r.GetName()),
|
||||
Object: common.NewTypedIdent(info.Type, r.GetName()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@ -46,7 +47,7 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info T
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@ -57,7 +58,7 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info T
|
||||
}
|
||||
|
||||
func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
|
||||
relation := mapping[r.GetVerb()]
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
// 1. check if subject has direct access to resource
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: s.storeID,
|
||||
@ -65,11 +66,11 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()),
|
||||
Object: common.NewResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()),
|
||||
},
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(formatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -90,7 +91,7 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
|
||||
@ -113,11 +114,11 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newFolderResourceIdent(r.GetGroup(), r.GetResource(), r.GetFolder()),
|
||||
Object: common.NewFolderResourceIdent(r.GetGroup(), r.GetResource(), r.GetFolder()),
|
||||
},
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(formatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/zanzana/proto/v1"
|
||||
)
|
||||
|
||||
@ -16,14 +16,14 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext
|
||||
ctx, span := tracer.Start(ctx, "authzServer.List")
|
||||
defer span.End()
|
||||
|
||||
if info, ok := typeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.listTyped(ctx, r, info)
|
||||
}
|
||||
|
||||
return s.listGeneric(ctx, r)
|
||||
}
|
||||
func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info TypeInfo) (*authzextv1.ListResponse, error) {
|
||||
relation := mapping[r.GetVerb()]
|
||||
func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info common.TypeInfo) (*authzextv1.ListResponse, error) {
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has access through namespace because then they can read all of them
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
@ -32,7 +32,7 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@ -47,8 +47,8 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
|
||||
listRes, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: s.storeID,
|
||||
AuthorizationModelId: s.modelID,
|
||||
Type: info.typ,
|
||||
Relation: mapping[utils.VerbGet],
|
||||
Type: info.Type,
|
||||
Relation: relation,
|
||||
User: r.GetSubject(),
|
||||
})
|
||||
if err != nil {
|
||||
@ -56,12 +56,12 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
|
||||
}
|
||||
|
||||
return &authzextv1.ListResponse{
|
||||
Items: typedObjects(info.typ, listRes.GetObjects()),
|
||||
Items: typedObjects(info.Type, listRes.GetObjects()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*authzextv1.ListResponse, error) {
|
||||
relation := mapping[r.GetVerb()]
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has access through namespace because then they can read all of them
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
@ -70,7 +70,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: newNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@ -90,7 +90,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
|
||||
User: r.GetSubject(),
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(formatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -107,7 +107,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
|
||||
User: r.GetSubject(),
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(formatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -6,10 +6,10 @@ import (
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@ -67,65 +67,17 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
AuthorizationModelId: srv.modelID,
|
||||
Writes: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: []*openfgav1.TupleKey{
|
||||
newResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
|
||||
newNamespaceResourceTuple("user:2", "read", dashboardGroup, dashboardResource),
|
||||
newResourceTuple("user:3", "view", dashboardGroup, dashboardResource, "1"),
|
||||
newFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "1"),
|
||||
newFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "3"),
|
||||
newFolderResourceTuple("user:5", "view", dashboardGroup, dashboardResource, "1"),
|
||||
newFolderTuple("user:6", "read", "1"),
|
||||
newNamespaceResourceTuple("user:7", "read", folderGroup, folderResource),
|
||||
common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewNamespaceResourceTuple("user:2", "read", dashboardGroup, dashboardResource),
|
||||
common.NewResourceTuple("user:3", "view", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "3"),
|
||||
common.NewFolderResourceTuple("user:5", "view", dashboardGroup, dashboardResource, "1"),
|
||||
common.NewFolderTuple("user:6", "read", "1"),
|
||||
common.NewNamespaceResourceTuple("user:7", "read", folderGroup, folderResource),
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newResourceTuple(subject, relation, group, resource, name string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: newResourceIdent(group, resource, name),
|
||||
Condition: &openfgav1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"resource_group": structpb.NewStringValue(formatGroupResource(group, resource)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newFolderResourceTuple(subject, relation, group, resource, folder string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: newFolderResourceIdent(group, resource, folder),
|
||||
Condition: &openfgav1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"resource_group": structpb.NewStringValue(formatGroupResource(group, resource)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newNamespaceResourceTuple(subject, relation, group, resource string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: newNamespaceResourceIdent(group, resource),
|
||||
}
|
||||
}
|
||||
|
||||
func newFolderTuple(subject, relation, name string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: newTypedIdent("folder2", name),
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
package zanzana
|
||||
|
||||
import (
|
||||
dashboardalpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
|
||||
folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
)
|
||||
|
||||
type actionKindTranslation struct {
|
||||
objectType string
|
||||
orgScoped bool
|
||||
@ -102,3 +107,67 @@ var basicRolesTranslations = map[string]string{
|
||||
RoleViewer: "basic_viewer",
|
||||
RoleNone: "basic_none",
|
||||
}
|
||||
|
||||
type resourceTranslation struct {
|
||||
typ string
|
||||
group string
|
||||
resource string
|
||||
mapping map[string]actionMappig
|
||||
}
|
||||
|
||||
type actionMappig struct {
|
||||
relation string
|
||||
group string
|
||||
resource string
|
||||
}
|
||||
|
||||
func newMapping(relation string) actionMappig {
|
||||
return newScopedMapping(relation, "", "")
|
||||
}
|
||||
|
||||
func newScopedMapping(relation, group, resource string) actionMappig {
|
||||
return actionMappig{relation, group, resource}
|
||||
}
|
||||
|
||||
var (
|
||||
folderGroup = folderalpha1.FolderResourceInfo.GroupResource().Group
|
||||
folderResource = folderalpha1.FolderResourceInfo.GroupResource().Resource
|
||||
|
||||
dashboardGroup = dashboardalpha1.DashboardResourceInfo.GroupResource().Group
|
||||
dashboardResource = dashboardalpha1.DashboardResourceInfo.GroupResource().Resource
|
||||
)
|
||||
|
||||
var resourceTranslations = map[string]resourceTranslation{
|
||||
KindFolders: {
|
||||
typ: TypeFolder2,
|
||||
group: folderGroup,
|
||||
resource: folderResource,
|
||||
mapping: map[string]actionMappig{
|
||||
"folders:read": newMapping(RelationRead),
|
||||
"folders:write": newMapping(RelationWrite),
|
||||
"folders:create": newMapping(RelationCreate),
|
||||
"folders:delete": newMapping(RelationDelete),
|
||||
"folders.permissions:read": newMapping(RelationPermissionsRead),
|
||||
"folders.permissions:write": newMapping(RelationPermissionsWrite),
|
||||
"dashboards:read": newScopedMapping(RelationRead, dashboardGroup, dashboardResource),
|
||||
"dashboards:write": newScopedMapping(RelationWrite, dashboardGroup, dashboardResource),
|
||||
"dashboards:create": newScopedMapping(RelationCreate, dashboardGroup, dashboardResource),
|
||||
"dashboards:delete": newScopedMapping(RelationDelete, dashboardGroup, dashboardResource),
|
||||
"dashboards.permissions:read": newScopedMapping(RelationPermissionsRead, dashboardGroup, dashboardResource),
|
||||
"dashboards.permissions:write": newScopedMapping(RelationPermissionsWrite, dashboardGroup, dashboardResource),
|
||||
},
|
||||
},
|
||||
KindDashboards: {
|
||||
typ: TypeResource,
|
||||
group: dashboardGroup,
|
||||
resource: dashboardResource,
|
||||
mapping: map[string]actionMappig{
|
||||
"dashboards:read": newMapping(RelationRead),
|
||||
"dashboards:write": newMapping(RelationWrite),
|
||||
"dashboards:create": newMapping(RelationCreate),
|
||||
"dashboards:delete": newMapping(RelationDelete),
|
||||
"dashboards.permissions:read": newMapping(RelationPermissionsRead),
|
||||
"dashboards.permissions:write": newMapping(RelationPermissionsWrite),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
|
||||
@ -13,8 +14,10 @@ const (
|
||||
TypeTeam string = "team"
|
||||
TypeRole string = "role"
|
||||
TypeFolder string = "folder"
|
||||
TypeFolder2 string = "folder2"
|
||||
TypeDashboard string = "dashboard"
|
||||
TypeOrg string = "org"
|
||||
TypeResource string = "resource"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -23,8 +26,19 @@ const (
|
||||
RelationParent string = "parent"
|
||||
RelationAssignee string = "assignee"
|
||||
RelationOrg string = "org"
|
||||
|
||||
// FIXME action sets
|
||||
RelationAdmin string = "admin"
|
||||
RelationRead string = "read"
|
||||
RelationWrite string = "write"
|
||||
RelationCreate string = "create"
|
||||
RelationDelete string = "delete"
|
||||
RelationPermissionsRead string = "permissions_read"
|
||||
RelationPermissionsWrite string = "permissions_write"
|
||||
)
|
||||
|
||||
var ResourceRelations = []string{RelationRead, RelationWrite, RelationCreate, RelationDelete, RelationPermissionsRead, RelationPermissionsWrite}
|
||||
|
||||
const (
|
||||
KindOrg string = "org"
|
||||
KindDashboards string = "dashboards"
|
||||
@ -89,6 +103,33 @@ func TranslateToTuple(user string, action, kind, identifier string, orgID int64)
|
||||
return tuple, true
|
||||
}
|
||||
|
||||
func TranslateToResourceTuple(subject string, action, kind, name string) (*openfgav1.TupleKey, bool) {
|
||||
translation, ok := resourceTranslations[kind]
|
||||
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
m, ok := translation.mapping[action]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if translation.typ == TypeResource {
|
||||
return common.NewResourceTuple(subject, m.relation, translation.group, translation.resource, name), true
|
||||
}
|
||||
|
||||
if translation.typ == TypeFolder2 {
|
||||
if m.group != "" && m.resource != "" {
|
||||
return common.NewFolderResourceTuple(subject, m.relation, m.group, m.resource, name), true
|
||||
}
|
||||
|
||||
return common.NewFolderTuple(subject, m.relation, name), true
|
||||
}
|
||||
|
||||
return common.NewTypedTuple(translation.typ, subject, m.relation, name), true
|
||||
}
|
||||
|
||||
func TranslateToOrgTuple(user string, action string, orgID int64) (*openfgav1.TupleKey, bool) {
|
||||
typeTranslation, ok := actionKindTranslations[KindOrg]
|
||||
if !ok {
|
||||
|
Loading…
Reference in New Issue
Block a user