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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,5 +6,5 @@ import (
|
||||
|
||||
var WireSet = wire.NewSet(
|
||||
ProvideAuthZClient,
|
||||
ProvideZanzana,
|
||||
ProvideZanzanaClient,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+60
-1
@@ -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]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user