Zanzana: app platform style write APIs (#112812)

* refactor zanzana client instantiation

* refactor client imports

* POC write API (Mutate)

* fix linter

* delete exisitng folder parents

* refactor common functions

* minor refactor

* groupd operations by type

* atomic folder operations

* use deleteExisting for deletes

* Add tests for folders

* more tests

* resource permissions tests

* add more tests

* fix mock zanzana client

* fix linter

* fix linter

* re-use types from apps

* add some comments to the protobuf
This commit is contained in:
Alexander Zobnin
2025-10-28 11:22:13 +01:00
committed by GitHub
parent 19d88de3cf
commit 7a7fd45bdd
25 changed files with 2108 additions and 502 deletions
@@ -5,15 +5,16 @@ import (
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/operator"
foldersKind "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/authz"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// PermissionStore interface for managing folder permissions
@@ -36,7 +37,7 @@ type FolderReconciler struct {
func NewFolderReconciler(cfg ReconcilerConfig) (operator.Reconciler, error) {
// Create Zanzana client
zanzanaClient, err := authz.NewZanzanaClient("*", cfg.ZanzanaCfg)
zanzanaClient, err := authz.NewRemoteZanzanaClient("*", cfg.ZanzanaCfg)
if err != nil {
return nil, fmt.Errorf("unable to create zanzana client: %w", err)
+2 -2
View File
@@ -442,7 +442,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
zanzanaClient, err := authz.ProvideZanzana(cfg, sqlStore, tracingService, featureToggles, registerer)
zanzanaClient, err := authz.ProvideZanzanaClient(cfg, sqlStore, tracingService, featureToggles, registerer)
if err != nil {
return nil, err
}
@@ -1061,7 +1061,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
zanzanaClient, err := authz.ProvideZanzana(cfg, sqlStore, tracingService, featureToggles, registerer)
zanzanaClient, err := authz.ProvideZanzanaClient(cfg, sqlStore, tracingService, featureToggles, registerer)
if err != nil {
return nil, err
}
@@ -242,6 +242,10 @@ func (m *mockZanzanaClient) Compile(ctx context.Context, id authlib.AuthInfo, re
return args.Get(0).(authlib.ItemChecker), args.Get(1).(authlib.Zookie), args.Error(2)
}
func (m *mockZanzanaClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {
return nil
}
func TestIntegrationTeamMembershipCollector(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,70 @@ service AuthzExtentionService {
rpc Read(ReadRequest) returns (ReadResponse);
rpc Write(WriteRequest) returns (WriteResponse);
rpc Mutate(MutateRequest) returns (MutateResponse);
}
message MutateRequest {
string namespace = 1;
repeated MutateOperation operations = 2;
}
message MutateResponse {}
message MutateOperation {
oneof operation {
SetFolderParentOperation set_folder_parent = 1;
DeleteFolderOperation delete_folder = 2;
CreatePermissionOperation create_permission = 3;
DeletePermissionOperation delete_permission = 4;
}
}
message SetFolderParentOperation {
// UID of the folder
string folder = 1;
// UID of the parent folder
string parent = 2;
// If true, delete all existing parent relations associated with the folder
bool delete_existing = 3;
}
message DeleteFolderOperation {
// UID of the folder to delete
string folder = 1;
// UID of the parent folder
string parent = 2;
// If true, delete all existing parent relations associated with the folder
bool delete_existing = 3;
}
message CreatePermissionOperation {
Resource resource = 1;
Permission permission = 2;
}
message DeletePermissionOperation {
Resource resource = 1;
Permission permission = 2;
}
message Resource {
// group of the resource (e.g: "dashboard.grafana.app")
string group = 1;
// kind of the resource (e.g: "dashboards")
string resource = 2;
// uid of the resource
string name = 3;
}
message Permission {
// kind of the identity getting the permission (e.g: "user", "team", "serviceaccount")
string kind = 1;
// uid of the identity getting the permission
string name = 2;
// action set granted to the user (e.g. "admin" or "edit", "view")
string verb = 3;
}
message TupleKey {
@@ -22,6 +22,7 @@ const (
AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck"
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate"
)
// AuthzExtentionServiceClient is the client API for AuthzExtentionService service.
@@ -31,6 +32,7 @@ type AuthzExtentionServiceClient interface {
BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error)
Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error)
Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error)
Mutate(ctx context.Context, in *MutateRequest, opts ...grpc.CallOption) (*MutateResponse, error)
}
type authzExtentionServiceClient struct {
@@ -71,6 +73,16 @@ func (c *authzExtentionServiceClient) Write(ctx context.Context, in *WriteReques
return out, nil
}
func (c *authzExtentionServiceClient) Mutate(ctx context.Context, in *MutateRequest, opts ...grpc.CallOption) (*MutateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MutateResponse)
err := c.cc.Invoke(ctx, AuthzExtentionService_Mutate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthzExtentionServiceServer is the server API for AuthzExtentionService service.
// All implementations should embed UnimplementedAuthzExtentionServiceServer
// for forward compatibility
@@ -78,6 +90,7 @@ type AuthzExtentionServiceServer interface {
BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error)
Read(context.Context, *ReadRequest) (*ReadResponse, error)
Write(context.Context, *WriteRequest) (*WriteResponse, error)
Mutate(context.Context, *MutateRequest) (*MutateResponse, error)
}
// UnimplementedAuthzExtentionServiceServer should be embedded to have forward compatible implementations.
@@ -93,6 +106,9 @@ func (UnimplementedAuthzExtentionServiceServer) Read(context.Context, *ReadReque
func (UnimplementedAuthzExtentionServiceServer) Write(context.Context, *WriteRequest) (*WriteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Write not implemented")
}
func (UnimplementedAuthzExtentionServiceServer) Mutate(context.Context, *MutateRequest) (*MutateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented")
}
// UnsafeAuthzExtentionServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AuthzExtentionServiceServer will
@@ -159,6 +175,24 @@ func _AuthzExtentionService_Write_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _AuthzExtentionService_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MutateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthzExtentionServiceServer).Mutate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthzExtentionService_Mutate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthzExtentionServiceServer).Mutate(ctx, req.(*MutateRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthzExtentionService_ServiceDesc is the grpc.ServiceDesc for AuthzExtentionService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -178,6 +212,10 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Write",
Handler: _AuthzExtentionService_Write_Handler,
},
{
MethodName: "Mutate",
Handler: _AuthzExtentionService_Mutate_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "extention.proto",
+3 -2
View File
@@ -33,6 +33,7 @@ import (
"github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
zClient "github.com/grafana/grafana/pkg/services/authz/zanzana/client"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/setting"
@@ -84,7 +85,7 @@ func ProvideAuthZClient(
case clientModeCloud:
rbacClient, err := newRemoteRBACClient(authCfg, tracer, reg)
if zanzanaEnabled {
return zanzana.WithShadowClient(rbacClient, zanzanaClient, reg)
return zClient.WithShadowClient(rbacClient, zanzanaClient, reg)
}
return rbacClient, err
default:
@@ -131,7 +132,7 @@ func ProvideAuthZClient(
)
if zanzanaEnabled {
return zanzana.WithShadowClient(rbacClient, zanzanaClient, reg)
return zClient.WithShadowClient(rbacClient, zanzanaClient, reg)
}
return rbacClient, nil
+1 -1
View File
@@ -6,5 +6,5 @@ import (
var WireSet = wire.NewSet(
ProvideAuthZClient,
ProvideZanzana,
ProvideZanzanaClient,
)
+22 -20
View File
@@ -24,26 +24,26 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
zClient "github.com/grafana/grafana/pkg/services/authz/zanzana/client"
zServer "github.com/grafana/grafana/pkg/services/authz/zanzana/server"
zStore "github.com/grafana/grafana/pkg/services/authz/zanzana/store"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/services/grpcserver/interceptors"
"github.com/grafana/grafana/pkg/setting"
)
// ProvideZanzana used to register ZanzanaClient.
// ProvideZanzanaClient used to register ZanzanaClient.
// It will also start an embedded ZanzanaSever if mode is set to "embedded".
func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features featuremgmt.FeatureToggles, reg prometheus.Registerer) (zanzana.Client, error) {
func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features featuremgmt.FeatureToggles, reg prometheus.Registerer) (zanzana.Client, error) {
//nolint:staticcheck // not yet migrated to OpenFeature
if !features.IsEnabledGlobally(featuremgmt.FlagZanzana) {
return zanzana.NewNoopClient(), nil
return zClient.NewNoopClient(), nil
}
logger := log.New("zanzana.server")
var client zanzana.Client
switch cfg.ZanzanaClient.Mode {
case setting.ZanzanaModeClient:
return NewZanzanaClient(
return NewRemoteZanzanaClient(
fmt.Sprintf("stacks-%s", cfg.StackID),
ZanzanaClientConfig{
URL: cfg.ZanzanaClient.Addr,
@@ -51,18 +51,20 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features
TokenExchangeURL: cfg.ZanzanaClient.TokenExchangeURL,
ServerCertFile: cfg.ZanzanaClient.ServerCertFile,
})
case setting.ZanzanaModeEmbedded:
store, err := zanzana.NewEmbeddedStore(cfg, db, logger)
logger := log.New("zanzana.server")
store, err := zStore.NewEmbeddedStore(cfg, db, logger)
if err != nil {
return nil, fmt.Errorf("failed to start zanzana: %w", err)
}
openfga, err := zanzana.NewOpenFGAServer(cfg.ZanzanaServer, store)
openfga, err := zServer.NewOpenFGAServer(cfg.ZanzanaServer, store)
if err != nil {
return nil, fmt.Errorf("failed to start zanzana: %w", err)
}
srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger, tracer, reg)
srv, err := zServer.NewServer(cfg.ZanzanaServer, openfga, logger, tracer, reg)
if err != nil {
return nil, fmt.Errorf("failed to start zanzana: %w", err)
}
@@ -82,16 +84,15 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features
authzv1.RegisterAuthzServiceServer(channel, srv)
authzextv1.RegisterAuthzExtentionServiceServer(channel, srv)
client, err = zanzana.NewClient(channel)
client, err := zClient.New(channel)
if err != nil {
return nil, fmt.Errorf("failed to initialize zanzana client: %w", err)
}
return client, nil
default:
return nil, fmt.Errorf("unsupported zanzana mode: %s", cfg.ZanzanaClient.Mode)
}
return client, nil
}
type ZanzanaClientConfig struct {
@@ -101,7 +102,8 @@ type ZanzanaClientConfig struct {
ServerCertFile string
}
func NewZanzanaClient(namespace string, cfg ZanzanaClientConfig) (zanzana.Client, error) {
// NewRemoteZanzanaClient creates a new Zanzana client that connects to remote Zanzana server.
func NewRemoteZanzanaClient(namespace string, cfg ZanzanaClientConfig) (zanzana.Client, error) {
tokenClient, err := authnlib.NewTokenExchangeClient(authnlib.TokenExchangeConfig{
Token: cfg.Token,
TokenExchangeURL: cfg.TokenExchangeURL,
@@ -134,7 +136,7 @@ func NewZanzanaClient(namespace string, cfg ZanzanaClientConfig) (zanzana.Client
return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err)
}
client, err := zanzana.NewClient(conn)
client, err := zClient.New(conn)
if err != nil {
return nil, fmt.Errorf("failed to initialize zanzana client: %w", err)
}
@@ -186,17 +188,17 @@ func (z *Zanzana) start(ctx context.Context) error {
return err
}
store, err := zanzana.NewStore(z.cfg, z.logger)
store, err := zStore.NewStore(z.cfg, z.logger)
if err != nil {
return fmt.Errorf("failed to initilize zanana store: %w", err)
}
openfgaServer, err := zanzana.NewOpenFGAServer(z.cfg.ZanzanaServer, store)
openfgaServer, err := zServer.NewOpenFGAServer(z.cfg.ZanzanaServer, store)
if err != nil {
return fmt.Errorf("failed to start zanzana: %w", err)
}
zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer, z.reg)
zanzanaServer, err := zServer.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer, z.reg)
if err != nil {
return fmt.Errorf("failed to start zanzana: %w", err)
}
@@ -240,7 +242,7 @@ func (z *Zanzana) start(ctx context.Context) error {
authzextv1.RegisterAuthzExtentionServiceServer(grpcServer, zanzanaServer)
// register grpc health server
healthServer := zanzana.NewHealthServer(zanzanaServer)
healthServer := zServer.NewHealthServer(zanzanaServer)
healthv1pb.RegisterHealthServer(grpcServer, healthServer)
if _, err := grpcserver.ProvideReflectionService(z.cfg, z.handle); err != nil {
@@ -253,7 +255,7 @@ func (z *Zanzana) start(ctx context.Context) error {
func (z *Zanzana) running(ctx context.Context) error {
if z.cfg.Env == setting.Dev && z.cfg.ZanzanaServer.OpenFGAHttpAddr != "" {
go func() {
srv, err := zanzana.NewOpenFGAHttpServer(z.cfg.ZanzanaServer, z.handle)
srv, err := zServer.NewOpenFGAHttpServer(z.cfg.ZanzanaServer, z.handle)
if err != nil {
z.logger.Error("failed to create OpenFGA HTTP server", "error", err)
} else {
+1 -15
View File
@@ -3,13 +3,9 @@ package zanzana
import (
"context"
"google.golang.org/grpc"
authlib "github.com/grafana/authlib/types"
"github.com/prometheus/client_golang/prometheus"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/client"
)
// Client is a wrapper around [openfgav1.OpenFGAServiceClient]
@@ -18,16 +14,6 @@ type Client interface {
Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error)
Write(ctx context.Context, req *authzextv1.WriteRequest) error
BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error)
}
func NewClient(cc grpc.ClientConnInterface) (*client.Client, error) {
return client.New(cc)
}
func WithShadowClient(accessClient authlib.AccessClient, zanzanaClient authlib.AccessClient, reg prometheus.Registerer) (authlib.AccessClient, error) {
return client.WithShadowClient(accessClient, zanzanaClient, reg), nil
}
func NewNoopClient() *client.NoopClient {
return client.NewNoop()
Mutate(ctx context.Context, req *authzextv1.MutateRequest) error
}
@@ -12,9 +12,11 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
var _ authlib.AccessClient = (*Client)(nil)
var _ zanzana.Client = (*Client)(nil)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/client")
@@ -72,3 +74,19 @@ func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckReque
return c.authzext.BatchCheck(ctx, req)
}
func (c *Client) WriteNew(ctx context.Context, req *authzextv1.WriteRequest) error {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Write")
defer span.End()
_, err := c.authzext.Write(ctx, req)
return err
}
func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Mutate")
defer span.End()
_, err := c.authzext.Mutate(ctx, req)
return err
}
+7 -1
View File
@@ -6,11 +6,13 @@ import (
authlib "github.com/grafana/authlib/types"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
var _ authlib.AccessClient = (*NoopClient)(nil)
var _ zanzana.Client = (*NoopClient)(nil)
func NewNoop() *NoopClient {
func NewNoopClient() *NoopClient {
return &NoopClient{}
}
@@ -35,3 +37,7 @@ func (nc NoopClient) Write(ctx context.Context, req *authzextv1.WriteRequest) er
func (nc NoopClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
return nil, nil
}
func (nc NoopClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {
return nil
}
@@ -6,6 +6,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
)
@@ -19,14 +20,14 @@ type ShadowClient struct {
}
// WithShadowClient returns a new access client that runs zanzana checks in the background.
func WithShadowClient(accessClient authlib.AccessClient, zanzanaClient authlib.AccessClient, reg prometheus.Registerer) authlib.AccessClient {
func WithShadowClient(accessClient authlib.AccessClient, zanzanaClient authlib.AccessClient, reg prometheus.Registerer) (authlib.AccessClient, error) {
client := &ShadowClient{
logger: log.New("zanzana-shadow-client"),
accessClient: accessClient,
zanzanaClient: zanzanaClient,
metrics: newShadowClientMetrics(reg),
}
return client
return client, nil
}
func (c *ShadowClient) Check(ctx context.Context, id authlib.AuthInfo, req authlib.CheckRequest, folder string) (authlib.CheckResponse, error) {
@@ -1,6 +1,8 @@
package zanzana
package common
import (
authlib "github.com/grafana/authlib/types"
dashboards "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
)
@@ -79,3 +81,60 @@ var resourceTranslations = map[string]resourceTranslation{
},
},
}
func TranslateToCheckRequest(namespace, action, kind, name string) (*authlib.CheckRequest, bool) {
translation, ok := resourceTranslations[kind]
if !ok {
return nil, false
}
m, ok := translation.mapping[action]
if !ok {
return nil, false
}
verb, ok := RelationToVerbMapping[m.relation]
if !ok {
return nil, false
}
req := &authlib.CheckRequest{
Namespace: namespace,
Verb: verb,
Group: translation.group,
Resource: translation.resource,
Name: name,
}
return req, true
}
func TranslateToListRequest(namespace, action, kind string) (*authlib.ListRequest, bool) {
translation, ok := resourceTranslations[kind]
if !ok {
return nil, false
}
// FIXME: support different verbs
req := &authlib.ListRequest{
Namespace: namespace,
Group: translation.group,
Resource: translation.resource,
}
return req, true
}
func TranslateToGroupResource(kind string) string {
translation, ok := resourceTranslations[kind]
if !ok {
return ""
}
return FormatGroupResource(translation.group, translation.resource, "")
}
func TranslateBasicRole(name string) string {
return basicRolesTranslations[name]
}
+86 -12
View File
@@ -1,12 +1,14 @@
package common
import (
"fmt"
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"google.golang.org/protobuf/types/known/structpb"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
@@ -33,6 +35,11 @@ const (
TypeTeamPrefix string = TypeTeam + ":"
)
const (
KindDashboards string = dashboardV1.DASHBOARD_RESOURCE
KindFolders string = folderV1.RESOURCE
)
const (
RelationTeamMember string = "member"
RelationTeamAdmin string = "admin"
@@ -144,6 +151,10 @@ func isValidRelation(relation string, valid []string) bool {
return false
}
func IsFolderResourceTuple(t *openfgav1.TupleKey) bool {
return strings.HasPrefix(t.Object, TypeFolder) && strings.HasPrefix(t.Relation, "resource_")
}
func SubresourceRelation(relation string) string {
return TypeResource + "_" + relation
}
@@ -178,6 +189,69 @@ func FormatGroupResource(group, resource, subresource string) string {
return b.String()
}
// NewTupleEntry constructs new openfga entry type:name[#relation].
// Relation allows to specify group of users (subjects) related to type:name
// (for example, team:devs#member refers to users which are members of team devs)
func NewTupleEntry(objectType, name, relation string) string {
obj := fmt.Sprintf("%s:%s", objectType, name)
if relation != "" {
obj = fmt.Sprintf("%s#%s", obj, relation)
}
return obj
}
func NewObjectEntry(objectType, group, resource, subresource, name string) string {
if objectType == TypeFolder {
return TypeFolder + ":" + name
}
obj := fmt.Sprintf("%s:%s/%s", objectType, group, resource)
if subresource != "" {
obj = fmt.Sprintf("%s/%s", obj, subresource)
}
if name != "" {
obj = fmt.Sprintf("%s/%s", obj, name)
}
return obj
}
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 name == "*" {
return NewGroupResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource), true
}
if translation.typ == TypeResource {
return NewResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource, name), true
}
if translation.typ == TypeFolder {
if m.group != "" && m.resource != "" {
return NewFolderResourceTuple(subject, m.relation, m.group, m.resource, m.subresource, name), true
}
return NewFolderTuple(subject, m.relation, name), true
}
return NewTypedTuple(translation.typ, subject, m.relation, name), true
}
func MergeFolderResourceTuples(a, b *openfgav1.TupleKey) {
va := a.Condition.Context.Fields["subresources"]
vb := b.Condition.Context.Fields["subresources"]
va.GetListValue().Values = append(va.GetListValue().Values, vb.GetListValue().Values...)
}
func NewResourceTuple(subject, relation, group, resource, subresource, name string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
User: subject,
@@ -200,6 +274,18 @@ func isSubresourceRelationSet(relation string) bool {
relation == RelationSubresourceSetAdmin
}
func NewFolderParentTuple(folder, parent string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
Object: NewFolderIdent(folder),
Relation: RelationParent,
User: NewFolderIdent(parent),
}
}
func NewFolderTuple(subject, relation, name string) *openfgav1.TupleKey {
return NewTypedTuple(TypeFolder, subject, relation, name)
}
func NewFolderResourceTuple(subject, relation, group, resource, subresource, folder string) *openfgav1.TupleKey {
relation = SubresourceRelation(relation)
var condition *openfgav1.RelationshipCondition
@@ -256,18 +342,6 @@ func NewGroupResourceTuple(subject, relation, group, resource, subresource strin
}
}
func NewFolderParentTuple(folder, parent string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
Object: NewFolderIdent(folder),
Relation: RelationParent,
User: NewFolderIdent(parent),
}
}
func NewFolderTuple(subject, relation, name string) *openfgav1.TupleKey {
return NewTypedTuple(TypeFolder, subject, relation, name)
}
func NewTypedTuple(typ, subject, relation, name string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
User: subject,
-30
View File
@@ -1,31 +1 @@
package zanzana
import (
"net/http"
openfgaserver "github.com/openfga/openfga/pkg/server"
openfgastorage "github.com/openfga/openfga/pkg/storage"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authz/zanzana/server"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/setting"
)
func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger, tracer tracing.Tracer, reg prometheus.Registerer) (*server.Server, error) {
return server.NewServer(cfg, openfga, logger, tracer, reg)
}
func NewHealthServer(target server.DiagnosticServer) *server.HealthServer {
return server.NewHealthServer(target)
}
func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store openfgastorage.OpenFGADatastore) (*openfgaserver.Server, error) {
return server.NewOpenFGAServer(cfg, store)
}
func NewOpenFGAHttpServer(cfg setting.ZanzanaServerSettings, srv grpcserver.Provider) (*http.Server, error) {
return server.NewOpenFGAHttpServer(cfg, srv)
}
@@ -0,0 +1,90 @@
package server
import (
"context"
"errors"
"fmt"
"time"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
type OperationGroup string
const (
OperationGroupFolder OperationGroup = "folder"
OperationGroupPermission OperationGroup = "permission"
)
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
ctx, span := s.tracer.Start(ctx, "server.Mutate")
defer span.End()
defer func(t time.Time) {
s.metrics.requestDurationSeconds.WithLabelValues("server.Mutate", req.GetNamespace()).Observe(time.Since(t).Seconds())
}(time.Now())
res, err := s.mutate(ctx, req)
if err != nil {
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform mutate request")
}
return res, nil
}
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
return nil, err
}
storeInf, err := s.getStoreInfo(ctx, req.Namespace)
if err != nil {
return nil, fmt.Errorf("failed to get openfga store: %w", err)
}
groupedOperations, err := groupByOperation(req.GetOperations())
if err != nil {
return nil, fmt.Errorf("failed to group operations: %w", err)
}
for operationGroup, operations := range groupedOperations {
switch operationGroup {
case OperationGroupFolder:
if err := s.mutateFolders(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate folder: %w", err)
}
case OperationGroupPermission:
if err := s.mutateResourcePermissions(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate resource permissions: %w", err)
}
default:
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
}
}
return &authzextv1.MutateResponse{}, nil
}
func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, error) {
switch operation.Operation.(type) {
case *authzextv1.MutateOperation_SetFolderParent, *authzextv1.MutateOperation_DeleteFolder:
return OperationGroupFolder, nil
case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission:
return OperationGroupPermission, nil
}
return OperationGroup(""), errors.New("unsupported mutate operation type")
}
func groupByOperation(operations []*authzextv1.MutateOperation) (map[OperationGroup][]*authzextv1.MutateOperation, error) {
grouped := make(map[OperationGroup][]*authzextv1.MutateOperation)
for _, operation := range operations {
operationGroup, err := getOperationGroup(operation)
if err != nil {
return nil, err
}
grouped[operationGroup] = append(grouped[operationGroup], operation)
}
return grouped, nil
}
@@ -0,0 +1,142 @@
package server
import (
"context"
"fmt"
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func (s *Server) mutateFolders(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
ctx, span := s.tracer.Start(ctx, "server.mutateFolder")
defer span.End()
writeTuples := make([]*openfgav1.TupleKey, 0)
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
for _, operation := range operations {
switch op := operation.Operation.(type) {
case *authzextv1.MutateOperation_SetFolderParent:
tuple, err := s.getFolderWriteTuple(ctx, store, op.SetFolderParent)
if err != nil {
return err
}
if tuple != nil {
writeTuples = append(writeTuples, tuple)
}
// Delete existing parent tuples
if op.SetFolderParent.GetDeleteExisting() {
tuples, err := s.getFolderDeleteTuples(ctx, store, op.SetFolderParent.GetFolder(), op.SetFolderParent.GetParent(), true)
if err != nil {
return err
}
deleteTuples = append(deleteTuples, tuples...)
}
case *authzextv1.MutateOperation_DeleteFolder:
tuples, err := s.getFolderDeleteTuples(ctx, store, op.DeleteFolder.GetFolder(), op.DeleteFolder.GetParent(), op.DeleteFolder.GetDeleteExisting())
if err != nil {
return err
}
deleteTuples = append(deleteTuples, tuples...)
default:
s.logger.Debug("unsupported mutate operation", "operation", op)
}
}
if len(writeTuples) == 0 && len(deleteTuples) == 0 {
return nil
}
writeReq := &openfgav1.WriteRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
}
if len(writeTuples) > 0 {
writeReq.Writes = &openfgav1.WriteRequestWrites{
TupleKeys: writeTuples,
OnDuplicate: "ignore",
}
}
if len(deleteTuples) > 0 {
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
TupleKeys: deleteTuples,
OnMissing: "ignore",
}
}
_, err := s.openfga.Write(ctx, writeReq)
if err != nil {
s.logger.Error("failed to write folder tuples", "error", err)
return err
}
return nil
}
func (s *Server) getFolderWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.SetFolderParentOperation) (*openfgav1.TupleKey, error) {
// Folder is at the root level
if req.GetParent() == "" {
return nil, nil
}
if strings.ContainsAny(req.GetFolder(), "#:") {
return nil, fmt.Errorf("folder UID contains invalid characters: %s", req.GetFolder())
}
tuple := zanzana.NewFolderParentTuple(req.GetFolder(), req.GetParent())
return tuple, nil
}
func (s *Server) getFolderDeleteTuples(ctx context.Context, store *storeInfo, folderUID string, parentUID string, deleteExisting bool) ([]*openfgav1.TupleKeyWithoutCondition, error) {
tupleKeysToDelete := make([]*openfgav1.TupleKeyWithoutCondition, 0)
if folderUID != "" && parentUID != "" && !deleteExisting {
tuple := zanzana.NewFolderParentTuple(folderUID, parentUID)
tupleKeysToDelete = append(tupleKeysToDelete, &openfgav1.TupleKeyWithoutCondition{
User: tuple.GetUser(),
Relation: tuple.GetRelation(),
Object: tuple.GetObject(),
})
}
if deleteExisting {
parentTuples, err := s.listFolderParents(ctx, store, folderUID)
if err != nil {
return nil, fmt.Errorf("failed to list folder parents: %w", err)
}
for _, tuple := range parentTuples {
tupleKeysToDelete = append(tupleKeysToDelete, &openfgav1.TupleKeyWithoutCondition{
User: tuple.Key.User,
Relation: tuple.Key.Relation,
Object: tuple.Key.Object,
})
}
}
return tupleKeysToDelete, nil
}
func (s *Server) listFolderParents(ctx context.Context, store *storeInfo, folderUID string) ([]*openfgav1.Tuple, error) {
ctx, span := s.tracer.Start(ctx, "server.listFolderParents")
defer span.End()
object := zanzana.NewFolderIdent(folderUID)
resp, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{
StoreId: store.ID,
TupleKey: &openfgav1.ReadRequestTupleKey{
Object: object,
Relation: zanzana.RelationParent,
},
})
if err != nil {
return nil, fmt.Errorf("failed to list folder parents: %w", err)
}
return resp.Tuples, nil
}
@@ -0,0 +1,164 @@
package server
import (
"testing"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func setupMutateFolders(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewFolderParentTuple("11", "1"),
common.NewFolderParentTuple("12", "1"),
common.NewFolderParentTuple("111", "11"),
common.NewFolderParentTuple("112", "11"),
common.NewFolderParentTuple("broken", "foo"),
common.NewFolderParentTuple("broken", "bar"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutateFolders(t *testing.T, srv *Server) {
setupMutateFolders(t, srv)
t.Run("should create new folder parent relation", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_SetFolderParent{
SetFolderParent: &v1.SetFolderParentOperation{
Folder: "new-folder",
Parent: "1",
DeleteExisting: false,
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:new-folder",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "folder:new-folder", res.Tuples[0].Key.Object)
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
})
t.Run("should delete folder parent relation", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeleteFolder{
DeleteFolder: &v1.DeleteFolderOperation{
Folder: "11",
Parent: "1",
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:11",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
t.Run("should clean up all parent relations", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeleteFolder{
DeleteFolder: &v1.DeleteFolderOperation{
Folder: "broken",
DeleteExisting: true,
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:broken",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_SetFolderParent{
SetFolderParent: &v1.SetFolderParentOperation{
Folder: "new-folder-2",
Parent: "1",
},
},
},
{
Operation: &v1.MutateOperation_DeleteFolder{
DeleteFolder: &v1.DeleteFolderOperation{
Folder: "12",
Parent: "1",
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:new-folder-2",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "folder:new-folder-2", res.Tuples[0].Key.Object)
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:12",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
}
@@ -0,0 +1,173 @@
package server
import (
"context"
"errors"
"fmt"
"strings"
"google.golang.org/protobuf/types/known/structpb"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
var (
errEmptyName = errors.New("name cannot be empty")
errInvalidBasicRole = errors.New("invalid basic role")
errUnknownKind = errors.New("unknown permission kind")
)
func (s *Server) mutateResourcePermissions(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
ctx, span := s.tracer.Start(ctx, "server.mutateResourcePermissions")
defer span.End()
writeTuples := make([]*openfgav1.TupleKey, 0)
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
for _, operation := range operations {
switch op := operation.Operation.(type) {
case *authzextv1.MutateOperation_CreatePermission:
tuple, err := s.getPermissionWriteTuple(ctx, op.CreatePermission)
if err != nil {
return err
}
writeTuples = append(writeTuples, tuple)
case *authzextv1.MutateOperation_DeletePermission:
tuple, err := s.getPermissionDeleteTuple(ctx, op.DeletePermission)
if err != nil {
return err
}
deleteTuples = append(deleteTuples, tuple)
default:
s.logger.Debug("unsupported mutate operation", "operation", op)
}
}
writeReq := &openfgav1.WriteRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
}
if len(writeTuples) > 0 {
writeReq.Writes = &openfgav1.WriteRequestWrites{
TupleKeys: writeTuples,
OnDuplicate: "ignore",
}
}
if len(deleteTuples) > 0 {
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
TupleKeys: deleteTuples,
OnMissing: "ignore",
}
}
_, err := s.openfga.Write(ctx, writeReq)
if err != nil {
s.logger.Error("failed to write resource permission tuples", "error", err)
return err
}
return nil
}
func (s *Server) getPermissionWriteTuple(ctx context.Context, req *authzextv1.CreatePermissionOperation) (*openfgav1.TupleKey, error) {
resource := req.GetResource()
permission := req.GetPermission()
object := zanzana.NewObjectEntry(toZanzanaType(resource.GetGroup()), resource.GetGroup(), resource.GetResource(), "", resource.GetName())
tuple, err := NewResourceTuple(object, resource, permission)
if err != nil {
return nil, err
}
return tuple, nil
}
func (s *Server) getPermissionDeleteTuple(ctx context.Context, req *authzextv1.DeletePermissionOperation) (*openfgav1.TupleKeyWithoutCondition, error) {
resource := req.GetResource()
permission := req.GetPermission()
object := zanzana.NewObjectEntry(toZanzanaType(resource.GetGroup()), resource.GetGroup(), resource.GetResource(), "", resource.GetName())
tuple, err := NewResourceTuple(object, resource, permission)
if err != nil {
return nil, err
}
return &openfgav1.TupleKeyWithoutCondition{
User: tuple.GetUser(),
Relation: tuple.GetRelation(),
Object: tuple.GetObject(),
}, nil
}
func toZanzanaType(apiGroup string) string {
if apiGroup == "folder.grafana.app" {
return zanzana.TypeFolder
}
return zanzana.TypeResource
}
func NewResourceTuple(object string, resource *authzextv1.Resource, perm *authzextv1.Permission) (*openfgav1.TupleKey, error) {
// Typ is "folder" or "resource"
typ := toZanzanaType(resource.Group)
// subject
subject, err := toZanzanaSubject(perm.GetKind(), perm.GetName())
if err != nil {
return nil, err
}
key := &openfgav1.TupleKey{
// e.g. "user:{uid}", "serviceaccount:{uid}", "team:{uid}", "basicrole:{viewer|editor|admin}"
User: subject,
// "view", "edit", "admin"
Relation: strings.ToLower(perm.Verb),
// e.g. "folder:{name}" or "resource:{apiGroup}/{resource}/{name}"
Object: object,
}
// For resources we add a condition to filter by apiGroup/resource
// e.g "group_filter": {"group_resource": "dashboards.grafana.app/dashboards"}
if typ == zanzana.TypeResource {
key.Condition = &openfgav1.RelationshipCondition{
Name: "group_filter",
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"group_resource": structpb.NewStringValue(
resource.GetGroup() + "/" + resource.GetResource(),
),
},
},
}
}
return key, nil
}
func toZanzanaSubject(kind string, name string) (string, error) {
if name == "" {
return "", errEmptyName
}
iamKind := iamv0.ResourcePermissionSpecPermissionKind(kind)
switch iamKind {
case iamv0.ResourcePermissionSpecPermissionKindUser:
return zanzana.NewTupleEntry(zanzana.TypeUser, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindServiceAccount:
return zanzana.NewTupleEntry(zanzana.TypeServiceAccount, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindTeam:
return zanzana.NewTupleEntry(zanzana.TypeTeam, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindBasicRole:
basicRole := zanzana.TranslateBasicRole(name)
if basicRole == "" {
return "", fmt.Errorf("%w: %s", errInvalidBasicRole, name)
}
// e.g role:basic_viewer#assignee
return zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, zanzana.RelationAssignee), nil
}
// should not happen since we are after create
// validation webhook should have caught invalid kinds
return "", errUnknownKind
}
@@ -0,0 +1,115 @@
package server
import (
"testing"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func setupMutateResourcePermissions(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
common.NewTypedResourceTuple("user:2", common.RelationGet, common.TypeFolder, folderGroup, folderResource, "", "1"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutateResourcePermissions(t *testing.T, srv *Server) {
setupMutateResourcePermissions(t, srv)
t.Run("should create new resource permission", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "foo",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "bar",
Verb: common.RelationGet,
},
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationGet,
Object: "resource:dashboard.grafana.app/dashboards/foo",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:bar", res.Tuples[0].Key.User)
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
require.Equal(t, "resource:dashboard.grafana.app/dashboards/foo", res.Tuples[0].Key.Object)
})
t.Run("should delete resource permission", func(t *testing.T) {
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
User: "user:1",
Object: "resource:dashboard.grafana.app/dashboards/1",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 2)
_, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "1",
Verb: common.RelationUpdate,
},
},
},
},
},
})
require.NoError(t, err)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationGet,
Object: "resource:dashboard.grafana.app/dashboards/1",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:1", res.Tuples[0].Key.User)
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
require.Equal(t, "resource:dashboard.grafana.app/dashboards/1", res.Tuples[0].Key.Object)
})
}
@@ -0,0 +1,135 @@
package server
import (
"testing"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func setupMutate(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewFolderParentTuple("11", "1"),
common.NewFolderParentTuple("12", "1"),
common.NewFolderParentTuple("111", "11"),
common.NewFolderParentTuple("112", "11"),
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
common.NewTypedResourceTuple("user:2", common.RelationGet, common.TypeFolder, folderGroup, folderResource, "", "1"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutate(t *testing.T, srv *Server) {
setupMutate(t, srv)
t.Run("should perform multiple mutate operations", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_SetFolderParent{
SetFolderParent: &v1.SetFolderParentOperation{
Folder: "new-folder",
Parent: "1",
DeleteExisting: false,
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "foo",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "bar",
Verb: common.RelationGet,
},
},
},
},
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "1",
Verb: common.RelationUpdate,
},
},
},
},
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "1",
Verb: common.RelationGet,
},
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Object: "folder:new-folder",
Relation: "parent",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "folder:new-folder", res.Tuples[0].Key.Object)
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationGet,
Object: "resource:dashboard.grafana.app/dashboards/foo",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:bar", res.Tuples[0].Key.User)
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
require.Equal(t, "resource:dashboard.grafana.app/dashboards/foo", res.Tuples[0].Key.Object)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationGet,
Object: "resource:dashboard.grafana.app/dashboards/1",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
}
@@ -44,6 +44,36 @@ const (
statusSubresource = "status"
)
func setup(t *testing.T, srv *Server) *Server {
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
common.NewGroupResourceTuple("user:2", common.RelationGet, dashboardGroup, dashboardResource, ""),
common.NewGroupResourceTuple("user:2", common.RelationUpdate, dashboardGroup, dashboardResource, ""),
common.NewResourceTuple("user:3", common.RelationSetView, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "3"),
common.NewFolderResourceTuple("user:5", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderTuple("user:6", common.RelationGet, "1"),
common.NewGroupResourceTuple("user:7", common.RelationGet, folderGroup, folderResource, ""),
common.NewFolderParentTuple("5", "4"),
common.NewFolderParentTuple("6", "5"),
common.NewFolderResourceTuple("user:8", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "5"),
common.NewFolderResourceTuple("user:9", common.RelationCreate, dashboardGroup, dashboardResource, "", "5"),
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "10"),
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "11"),
common.NewGroupResourceTuple("user:11", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource),
common.NewFolderResourceTuple("user:12", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "5"),
common.NewFolderResourceTuple("user:13", common.RelationGet, folderGroup, folderResource, statusSubresource, "5"),
common.NewTypedResourceTuple("user:14", common.RelationGet, common.TypeTeam, teamGroup, teamResource, statusSubresource, "1"),
common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"),
common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
@@ -64,27 +94,43 @@ func TestIntegrationServer(t *testing.T) {
}
}
srv := setup(t, testStore, cfg)
srv := setupOpenFGAServer(t, testStore, cfg)
t.Run("test check", func(t *testing.T) {
setup(t, srv)
testCheck(t, srv)
})
t.Run("test list", func(t *testing.T) {
setup(t, srv)
testList(t, srv)
})
t.Run("test list streaming", func(t *testing.T) {
setup(t, srv)
srv.cfg.UseStreamedListObjects = true
testList(t, srv)
srv.cfg.UseStreamedListObjects = false
})
t.Run("test batch check", func(t *testing.T) {
setup(t, srv)
testBatchCheck(t, srv)
})
t.Run("test mutate", func(t *testing.T) {
testMutate(t, srv)
})
t.Run("test mutate folders", func(t *testing.T) {
testMutateFolders(t, srv)
})
t.Run("test mutate resource permissions", func(t *testing.T) {
testMutateResourcePermissions(t, srv)
})
}
func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
t.Helper()
store, err := store.NewEmbeddedStore(cfg, testDB, log.NewNopLogger())
@@ -95,38 +141,25 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService(), prometheus.NewRegistry())
require.NoError(t, err)
return srv
}
func setupOpenFGADatabase(t *testing.T, srv *Server, tuples []*openfgav1.TupleKey) *Server {
t.Helper()
storeInf, err := srv.getStoreInfo(context.Background(), namespace)
require.NoError(t, err)
// Clean up any existing store
_, err = srv.openfga.DeleteStore(context.Background(), &openfgav1.DeleteStoreRequest{
StoreId: storeInf.ID,
})
require.NoError(t, err)
// seed tuples
writes := &openfgav1.WriteRequestWrites{
TupleKeys: []*openfgav1.TupleKey{
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
common.NewGroupResourceTuple("user:2", common.RelationGet, dashboardGroup, dashboardResource, ""),
common.NewGroupResourceTuple("user:2", common.RelationUpdate, dashboardGroup, dashboardResource, ""),
common.NewResourceTuple("user:3", common.RelationSetView, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "3"),
common.NewFolderResourceTuple("user:5", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "1"),
common.NewFolderTuple("user:6", common.RelationGet, "1"),
common.NewGroupResourceTuple("user:7", common.RelationGet, folderGroup, folderResource, ""),
common.NewFolderParentTuple("5", "4"),
common.NewFolderParentTuple("6", "5"),
common.NewFolderResourceTuple("user:8", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "5"),
common.NewFolderResourceTuple("user:9", common.RelationCreate, dashboardGroup, dashboardResource, "", "5"),
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "10"),
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "11"),
common.NewGroupResourceTuple("user:11", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource),
common.NewFolderResourceTuple("user:12", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "5"),
common.NewFolderResourceTuple("user:13", common.RelationGet, folderGroup, folderResource, statusSubresource, "5"),
common.NewTypedResourceTuple("user:14", common.RelationGet, common.TypeTeam, teamGroup, teamResource, statusSubresource, "1"),
common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"),
common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"),
},
}
for _, w := range writes.TupleKeys {
t.Log(w.String())
TupleKeys: tuples,
OnDuplicate: "ignore",
}
// First, try to delete any existing tuples to avoid conflicts
@@ -140,16 +173,18 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
}
// Try to delete existing tuples (ignore errors if they don't exist)
_, _ = openfga.Write(context.Background(), &openfgav1.WriteRequest{
_, err = srv.openfga.Write(context.Background(), &openfgav1.WriteRequest{
StoreId: storeInf.ID,
AuthorizationModelId: storeInf.ModelID,
Deletes: &openfgav1.WriteRequestDeletes{
TupleKeys: deletes,
OnMissing: "ignore",
},
})
require.NoError(t, err)
// Now write the new tuples
_, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{
_, err = srv.openfga.Write(context.Background(), &openfgav1.WriteRequest{
StoreId: storeInf.ID,
AuthorizationModelId: storeInf.ModelID,
Writes: writes,
-18
View File
@@ -1,18 +0,0 @@
package zanzana
import (
"github.com/openfga/openfga/pkg/storage"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
)
func NewStore(cfg *setting.Cfg, logger log.Logger) (storage.OpenFGADatastore, error) {
return store.NewStore(cfg, logger)
}
func NewEmbeddedStore(cfg *setting.Cfg, db db.DB, logger log.Logger) (storage.OpenFGADatastore, error) {
return store.NewEmbeddedStore(cfg, db, logger)
}
+13 -132
View File
@@ -1,12 +1,6 @@
package zanzana
import (
"fmt"
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
@@ -54,8 +48,8 @@ var (
)
const (
KindDashboards string = "dashboards"
KindFolders string = "folders"
KindDashboards = common.KindDashboards
KindFolders = common.KindFolders
)
var (
@@ -68,128 +62,15 @@ var (
ToOpenFGATuples = common.ToOpenFGATuples
ToOpenFGATupleKey = common.ToOpenFGATupleKey
ToOpenFGATupleKeyWithoutCondition = common.ToOpenFGATupleKeyWithoutCondition
NewTupleEntry = common.NewTupleEntry
NewObjectEntry = common.NewObjectEntry
TranslateToResourceTuple = common.TranslateToResourceTuple
IsFolderResourceTuple = common.IsFolderResourceTuple
MergeFolderResourceTuples = common.MergeFolderResourceTuples
TranslateToCheckRequest = common.TranslateToCheckRequest
TranslateToListRequest = common.TranslateToListRequest
TranslateToGroupResource = common.TranslateToGroupResource
TranslateBasicRole = common.TranslateBasicRole
)
// NewTupleEntry constructs new openfga entry type:name[#relation].
// Relation allows to specify group of users (subjects) related to type:name
// (for example, team:devs#member refers to users which are members of team devs)
func NewTupleEntry(objectType, name, relation string) string {
obj := fmt.Sprintf("%s:%s", objectType, name)
if relation != "" {
obj = fmt.Sprintf("%s#%s", obj, relation)
}
return obj
}
func NewObjectEntry(objectType, group, resource, subresource, name string) string {
if objectType == TypeFolder {
return TypeFolder + ":" + name
}
obj := fmt.Sprintf("%s:%s/%s", objectType, group, resource)
if subresource != "" {
obj = fmt.Sprintf("%s/%s", obj, subresource)
}
if name != "" {
obj = fmt.Sprintf("%s/%s", obj, name)
}
return obj
}
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 name == "*" {
return common.NewGroupResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource), true
}
if translation.typ == TypeResource {
return common.NewResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource, name), true
}
if translation.typ == TypeFolder {
if m.group != "" && m.resource != "" {
return common.NewFolderResourceTuple(subject, m.relation, m.group, m.resource, m.subresource, name), true
}
return common.NewFolderTuple(subject, m.relation, name), true
}
return common.NewTypedTuple(translation.typ, subject, m.relation, name), true
}
func IsFolderResourceTuple(t *openfgav1.TupleKey) bool {
return strings.HasPrefix(t.Object, TypeFolder) && strings.HasPrefix(t.Relation, "resource_")
}
func MergeFolderResourceTuples(a, b *openfgav1.TupleKey) {
va := a.Condition.Context.Fields["subresources"]
vb := b.Condition.Context.Fields["subresources"]
va.GetListValue().Values = append(va.GetListValue().Values, vb.GetListValue().Values...)
}
func TranslateToCheckRequest(namespace, action, kind, name string) (*authlib.CheckRequest, bool) {
translation, ok := resourceTranslations[kind]
if !ok {
return nil, false
}
m, ok := translation.mapping[action]
if !ok {
return nil, false
}
verb, ok := common.RelationToVerbMapping[m.relation]
if !ok {
return nil, false
}
req := &authlib.CheckRequest{
Namespace: namespace,
Verb: verb,
Group: translation.group,
Resource: translation.resource,
Name: name,
}
return req, true
}
func TranslateToListRequest(namespace, action, kind string) (*authlib.ListRequest, bool) {
translation, ok := resourceTranslations[kind]
if !ok {
return nil, false
}
// FIXME: support different verbs
req := &authlib.ListRequest{
Namespace: namespace,
Group: translation.group,
Resource: translation.resource,
}
return req, true
}
func TranslateToGroupResource(kind string) string {
translation, ok := resourceTranslations[kind]
if !ok {
return ""
}
return common.FormatGroupResource(translation.group, translation.resource, "")
}
func TranslateBasicRole(name string) string {
return basicRolesTranslations[name]
}