mirror of https://github.com/grafana/grafana
Zanzana: Initial schema loading (#89492)
* Zanzana: Dummy schema loading * Load authorzation model for client --------- Co-authored-by: Karl Persson <kalle.persson@grafana.com>pull/89995/head
parent
ddea4ba8b2
commit
06084f0ed1
@ -0,0 +1,186 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"google.golang.org/grpc" |
||||
"google.golang.org/protobuf/types/known/wrapperspb" |
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/schema" |
||||
) |
||||
|
||||
type ClientOption func(c *Client) |
||||
|
||||
func WithTenantID(tenantID string) ClientOption { |
||||
return func(c *Client) { |
||||
c.tenantID = tenantID |
||||
} |
||||
} |
||||
|
||||
func WithLogger(logger log.Logger) ClientOption { |
||||
return func(c *Client) { |
||||
c.logger = logger |
||||
} |
||||
} |
||||
|
||||
type Client struct { |
||||
logger log.Logger |
||||
client openfgav1.OpenFGAServiceClient |
||||
tenantID string |
||||
storeID string |
||||
modelID string |
||||
} |
||||
|
||||
func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption) (*Client, error) { |
||||
c := &Client{ |
||||
client: openfgav1.NewOpenFGAServiceClient(cc), |
||||
} |
||||
|
||||
for _, o := range opts { |
||||
o(c) |
||||
} |
||||
|
||||
if c.logger == nil { |
||||
c.logger = log.NewNopLogger() |
||||
} |
||||
|
||||
if c.tenantID == "" { |
||||
c.tenantID = "stack-default" |
||||
} |
||||
|
||||
store, err := c.getOrCreateStore(ctx, c.tenantID) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
c.storeID = store.GetId() |
||||
|
||||
modelID, err := c.loadModel(ctx, c.storeID, schema.DSL) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
c.modelID = modelID |
||||
|
||||
return c, nil |
||||
} |
||||
|
||||
func (c *Client) Check(ctx context.Context, in *openfgav1.CheckRequest, opts ...grpc.CallOption) (*openfgav1.CheckResponse, error) { |
||||
return c.client.Check(ctx, in, opts...) |
||||
} |
||||
|
||||
func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest, opts ...grpc.CallOption) (*openfgav1.ListObjectsResponse, error) { |
||||
return c.client.ListObjects(ctx, in, opts...) |
||||
} |
||||
|
||||
func (c *Client) getOrCreateStore(ctx context.Context, name string) (*openfgav1.Store, error) { |
||||
store, err := c.getStore(ctx, name) |
||||
|
||||
if errors.Is(err, errStoreNotFound) { |
||||
var res *openfgav1.CreateStoreResponse |
||||
res, err = c.client.CreateStore(ctx, &openfgav1.CreateStoreRequest{Name: name}) |
||||
if res != nil { |
||||
store = &openfgav1.Store{ |
||||
Id: res.GetId(), |
||||
Name: res.GetName(), |
||||
CreatedAt: res.GetCreatedAt(), |
||||
} |
||||
} |
||||
} |
||||
|
||||
return store, err |
||||
} |
||||
|
||||
var errStoreNotFound = errors.New("store not found") |
||||
|
||||
func (c *Client) getStore(ctx context.Context, name string) (*openfgav1.Store, error) { |
||||
var continuationToken string |
||||
|
||||
// OpenFGA client does not support any filters for stores.
|
||||
// We should create an issue to support some way to get stores by name.
|
||||
// For now we need to go thourh all stores until we find a match or we hit the end.
|
||||
for { |
||||
res, err := c.client.ListStores(ctx, &openfgav1.ListStoresRequest{ |
||||
PageSize: &wrapperspb.Int32Value{Value: 20}, |
||||
ContinuationToken: continuationToken, |
||||
}) |
||||
|
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to initiate zanzana tenant: %w", err) |
||||
} |
||||
|
||||
for _, s := range res.GetStores() { |
||||
if s.GetName() == name { |
||||
return s, nil |
||||
} |
||||
} |
||||
|
||||
// we have no more stores to check
|
||||
if res.GetContinuationToken() == "" { |
||||
return nil, errStoreNotFound |
||||
} |
||||
|
||||
continuationToken = res.GetContinuationToken() |
||||
} |
||||
} |
||||
|
||||
func (c *Client) loadModel(ctx context.Context, storeID string, dsl string) (string, error) { |
||||
var continuationToken string |
||||
|
||||
for { |
||||
// ReadAuthorizationModels returns authorization models for a store sorted in descending order of creation.
|
||||
// So with a pageSize of 1 we will get the latest model.
|
||||
res, err := c.client.ReadAuthorizationModels(ctx, &openfgav1.ReadAuthorizationModelsRequest{ |
||||
StoreId: storeID, |
||||
PageSize: &wrapperspb.Int32Value{Value: 20}, |
||||
ContinuationToken: continuationToken, |
||||
}) |
||||
|
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to load authorization model: %w", err) |
||||
} |
||||
|
||||
for _, model := range res.GetAuthorizationModels() { |
||||
// We need to first convert stored model into dsl and compare it to provided dsl.
|
||||
storedDSL, err := schema.TransformToDSL(model) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
// If provided dsl is equal to a stored dsl we use that as the authorization id
|
||||
if schema.EqualModels(dsl, storedDSL) { |
||||
return res.AuthorizationModels[0].GetId(), nil |
||||
} |
||||
} |
||||
|
||||
// If we have not found any matching authorization model we break the loop and create a new one
|
||||
if res.GetContinuationToken() == "" { |
||||
break |
||||
} |
||||
|
||||
continuationToken = res.GetContinuationToken() |
||||
} |
||||
|
||||
model, err := schema.TransformToModel(dsl) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
writeRes, err := c.client.WriteAuthorizationModel(ctx, &openfgav1.WriteAuthorizationModelRequest{ |
||||
StoreId: c.storeID, |
||||
TypeDefinitions: model.GetTypeDefinitions(), |
||||
SchemaVersion: model.GetSchemaVersion(), |
||||
Conditions: model.GetConditions(), |
||||
}) |
||||
|
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to load authorization model: %w", err) |
||||
} |
||||
|
||||
return writeRes.GetAuthorizationModelId(), nil |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"google.golang.org/grpc" |
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1" |
||||
) |
||||
|
||||
func NewNoop() *NoopClient { |
||||
return &NoopClient{} |
||||
} |
||||
|
||||
type NoopClient struct{} |
||||
|
||||
func (nc NoopClient) Check(ctx context.Context, in *openfgav1.CheckRequest, opts ...grpc.CallOption) (*openfgav1.CheckResponse, error) { |
||||
return nil, nil |
||||
} |
||||
|
||||
func (nc NoopClient) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest, opts ...grpc.CallOption) (*openfgav1.ListObjectsResponse, error) { |
||||
return nil, nil |
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
model |
||||
schema 1.1 |
||||
|
||||
type instance |
||||
|
||||
type user |
||||
|
||||
type org |
||||
relations |
||||
define instance: [instance] |
||||
define member: [user] |
||||
define viewer: [user] |
||||
|
||||
type role |
||||
relations |
||||
define org: [org] |
||||
define instance: [instance] |
||||
define assignee: [user, team#member, role#assignee] |
||||
|
||||
type team |
||||
relations |
||||
define org: [org] |
||||
define admin: [user] |
||||
define member: [user] or admin |
||||
@ -0,0 +1,8 @@ |
||||
package schema |
||||
|
||||
import ( |
||||
_ "embed" |
||||
) |
||||
|
||||
//go:embed schema.fga
|
||||
var DSL string |
||||
@ -0,0 +1,43 @@ |
||||
package schema |
||||
|
||||
import ( |
||||
_ "embed" |
||||
"fmt" |
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1" |
||||
language "github.com/openfga/language/pkg/go/transformer" |
||||
) |
||||
|
||||
func TransformToModel(dsl string) (*openfgav1.AuthorizationModel, error) { |
||||
parsedAuthModel, err := language.TransformDSLToProto(dsl) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to transform dsl to model: %w", err) |
||||
} |
||||
|
||||
return parsedAuthModel, nil |
||||
} |
||||
|
||||
func TransformToDSL(model *openfgav1.AuthorizationModel) (string, error) { |
||||
return language.TransformJSONProtoToDSL(model) |
||||
} |
||||
|
||||
// FIXME(kalleep): We need to figure out a better way to compare equality of two different
|
||||
// authorization model. For now the easiest way I found to comparing different schemas was
|
||||
// to convert them into their json representation but this requires us to first convert dsl into
|
||||
// openfgav1.AuthorizationModel and then later parse it as json.
|
||||
// Comparing parsed authorization model with authorization model from store directly by parsing them as
|
||||
// as json won't work because stored model will have some fields set such as id that are not present in a parsed
|
||||
// dsl from disk.
|
||||
func EqualModels(a, b string) bool { |
||||
astr, err := language.TransformDSLToJSON(a) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
bstr, err := language.TransformDSLToJSON(b) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
return astr == bstr |
||||
} |
||||
@ -0,0 +1,131 @@ |
||||
package schema |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestEqualModels(t *testing.T) { |
||||
type testCase struct { |
||||
desc string |
||||
a string |
||||
b string |
||||
expected bool |
||||
} |
||||
|
||||
tests := []testCase{ |
||||
{ |
||||
desc: "should be equal", |
||||
a: ` |
||||
model |
||||
schema 1.1 |
||||
|
||||
type instance |
||||
|
||||
type user |
||||
|
||||
type org |
||||
relations |
||||
define instance: [instance] |
||||
define member: [user] |
||||
define viewer: [user] |
||||
|
||||
type role |
||||
relations |
||||
define org: [org] |
||||
define instance: [instance] |
||||
define assignee: [user, team#member, role#assignee] |
||||
|
||||
type team |
||||
relations |
||||
define org: [org] |
||||
define admin: [user] |
||||
define member: [user] or org |
||||
`, |
||||
b: ` |
||||
model |
||||
schema 1.1 |
||||
|
||||
type instance |
||||
|
||||
type user |
||||
|
||||
type org |
||||
relations |
||||
define instance: [instance] |
||||
define member: [user] |
||||
define viewer: [user] |
||||
|
||||
type role |
||||
relations |
||||
define org: [org] |
||||
define instance: [instance] |
||||
define assignee: [user, team#member, role#assignee] |
||||
|
||||
type team |
||||
relations |
||||
define org: [org] |
||||
define admin: [user] |
||||
define member: [user] or org |
||||
`, |
||||
expected: true, |
||||
}, |
||||
{ |
||||
desc: "should not be equal", |
||||
a: ` |
||||
model |
||||
schema 1.1 |
||||
|
||||
type instance |
||||
|
||||
type user |
||||
|
||||
type org |
||||
relations |
||||
define instance: [instance] |
||||
define member: [user] |
||||
define viewer: [user] |
||||
|
||||
type role |
||||
relations |
||||
define org: [org] |
||||
define instance: [instance] |
||||
define assignee: [user, team#member, role#assignee] |
||||
|
||||
type team |
||||
relations |
||||
define org: [org] |
||||
define admin: [user] |
||||
define member: [user] or org |
||||
`, |
||||
b: ` |
||||
model |
||||
schema 1.1 |
||||
|
||||
type instance |
||||
|
||||
type user |
||||
|
||||
type org |
||||
relations |
||||
define instance: [instance] |
||||
define member: [user] |
||||
define viewer: [user] |
||||
|
||||
type role |
||||
relations |
||||
define org: [org] |
||||
define instance: [instance] |
||||
define assignee: [user, team#member, role#assignee] |
||||
`, |
||||
expected: false, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.desc, func(t *testing.T) { |
||||
assert.Equal(t, tt.expected, EqualModels(tt.a, tt.b)) |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue