Zanzana: periodic sync of team members (#94752)

* Rewrite zanzana collector to fetch all available pages

* Register access control as a background service

* If zanzana is enabled we run Syncs and start Reconciliation job

* Update pkg/services/authz/zanzana/client/client.go

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* Use server lock when doing performing reconciliation
This commit is contained in:
Karl Persson
2024-10-17 15:28:33 +02:00
committed by GitHub
co-authored by Alexander Zobnin
parent a50507e645
commit 4083b2208e
17 changed files with 364 additions and 83 deletions
@@ -0,0 +1,19 @@
package dualwrite
// batch will call fn with a batch of T for specified size.
func batch[T any](items []T, batchSize int, fn func([]T) error) error {
count := len(items)
for i := 0; i < count; {
end := i + batchSize
if end > count {
end = count
}
if err := fn(items[i:end]); err != nil {
return err
}
i = end
}
return nil
}
@@ -0,0 +1,109 @@
package dualwrite
import (
"context"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
func teamMembershipCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context) (map[string]map[string]*openfgav1.TupleKey, error) {
query := `
SELECT t.uid as team_uid, u.uid as user_uid, tm.permission
FROM team_member tm
INNER JOIN team t ON tm.team_id = t.id
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON tm.user_id = u.id
`
type membership struct {
TeamUID string `xorm:"team_uid"`
UserUID string `xorm:"user_uid"`
Permission int
}
var memberships []membership
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&memberships)
})
if err != nil {
return nil, err
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, m := range memberships {
tuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, m.UserUID, ""),
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, m.TeamUID, ""),
}
// Admin permission is 4 and member 0
if m.Permission == 4 {
tuple.Relation = zanzana.RelationTeamAdmin
} else {
tuple.Relation = zanzana.RelationTeamMember
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
}
}
func zanzanaCollector(client zanzana.Client, relations []string) zanzanaTupleCollector {
return func(ctx context.Context, client zanzana.Client, object string) (map[string]*openfgav1.TupleKey, error) {
// list will use continuation token to collect all tuples for object and relation
list := func(relation string) ([]*openfgav1.Tuple, error) {
first, err := client.Read(ctx, &openfgav1.ReadRequest{
TupleKey: &openfgav1.ReadRequestTupleKey{
Object: object,
Relation: relation,
},
})
if err != nil {
return nil, err
}
c := first.ContinuationToken
for c != "" {
res, err := client.Read(ctx, &openfgav1.ReadRequest{
TupleKey: &openfgav1.ReadRequestTupleKey{
Object: object,
Relation: relation,
},
})
if err != nil {
return nil, err
}
c = res.ContinuationToken
first.Tuples = append(first.Tuples, res.Tuples...)
}
return first.Tuples, nil
}
out := make(map[string]*openfgav1.TupleKey)
for _, r := range relations {
tuples, err := list(r)
if err != nil {
return nil, err
}
for _, t := range tuples {
out[t.Key.String()] = t.Key
}
}
return out, nil
}
}
@@ -0,0 +1,614 @@
package dualwrite
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/accesscontrol/migrator")
// A TupleCollector is responsible to build and store [openfgav1.TupleKey] into provided tuple map.
// They key used should be a unique group key for the collector so we can skip over an already synced group.
type TupleCollector func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error
// ZanzanaReconciler is a component to reconcile RBAC permissions to zanzana.
// We should rewrite the migration after we have "migrated" all possible actions
// into our schema.
type ZanzanaReconciler struct {
lock *serverlock.ServerLockService
log log.Logger
client zanzana.Client
// collectors are one time best effort migrations that gives up on first conflict.
// These are deprecated and everything should move be resourceReconcilers that are periodically synced
// between grafana db and zanzana store.
collectors []TupleCollector
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
// These are run periodically to try to maintain a consistent state.
reconcilers []resourceReconciler
}
func NewZanzanaReconciler(client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, collectors ...TupleCollector) *ZanzanaReconciler {
// Append shared collectors that is used by both enterprise and oss
collectors = append(
collectors,
managedPermissionsCollector(store),
folderTreeCollector(store),
basicRolesCollector(store),
customRolesCollector(store),
basicRoleAssignemtCollector(store),
userRoleAssignemtCollector(store),
teamRoleAssignemtCollector(store),
fixedRoleTuplesCollector(store),
)
return &ZanzanaReconciler{
client: client,
lock: lock,
log: log.New("zanzana.reconciler"),
collectors: collectors,
reconcilers: []resourceReconciler{
newResourceReconciler(
"team memberships",
teamMembershipCollector(store),
zanzanaCollector(client, []string{zanzana.RelationTeamMember, zanzana.RelationTeamAdmin}),
client,
),
},
}
}
// Sync runs all collectors and tries to write all collected tuples.
// It will skip over any "sync group" that has already been written.
func (r *ZanzanaReconciler) Sync(ctx context.Context) error {
r.log.Info("Starting zanzana permissions sync")
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.Sync")
defer span.End()
tuplesMap := make(map[string][]*openfgav1.TupleKey)
for _, c := range r.collectors {
if err := c(ctx, tuplesMap); err != nil {
return fmt.Errorf("failed to collect permissions: %w", err)
}
}
for key, tuples := range tuplesMap {
if err := batch(tuples, 100, func(items []*openfgav1.TupleKey) error {
return r.client.Write(ctx, &openfgav1.WriteRequest{
Writes: &openfgav1.WriteRequestWrites{
TupleKeys: items,
},
})
}); err != nil {
if strings.Contains(err.Error(), "cannot write a tuple which already exists") {
r.log.Debug("Skipping already synced permissions", "sync_key", key)
continue
}
return err
}
}
r.reconcile(ctx)
return nil
}
// Reconcile schedules as job that will run and reconcile resources between
// legacy access control and zanzana.
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
// FIXME: try to reconcile at start whenever we have moved all syncs to reconcilers
// r.reconcile(ctx)
// FIXME:
// 1. We should be a bit graceful about reconciliations so we are not hammering dbs
// 2. We should be able to configure reconciliation interval
ticker := time.NewTicker(1 * time.Hour)
for {
select {
case <-ticker.C:
r.reconcile(ctx)
case <-ctx.Done():
return ctx.Err()
}
}
}
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
run := func(ctx context.Context) {
now := time.Now()
for _, reconciler := range r.reconcilers {
if err := reconciler.reconcile(ctx); err != nil {
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
}
}
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
}
// in tests we can skip creating a lock
if r.lock == nil {
run(ctx)
return
}
// We ignore the error for now
_ = r.lock.LockExecuteAndRelease(ctx, "zanzana-reconciliation", 10*time.Hour, func(ctx context.Context) {
run(ctx)
})
}
// managedPermissionsCollector collects managed permissions into provided tuple map.
// It will only store actions that are supported by our schema. Managed permissions can
// be directly mapped to user/team/role without having to write an intermediate role.
func managedPermissionsCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "managed"
query := `
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN user_role ur ON r.id = ur.role_id
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
LEFT JOIN team_role tr ON r.id = tr.role_id
LEFT JOIN team t ON tr.team_id = t.id
LEFT JOIN builtin_role br ON r.id = br.role_id
WHERE r.name LIKE 'managed:%'
`
type Permission struct {
RoleName string `xorm:"role_name"`
OrgID int64 `xorm:"org_id"`
Action string `xorm:"action"`
Kind string
Identifier string
UserUID string `xorm:"user_uid"`
TeamUID string `xorm:"team_uid"`
}
var permissions []Permission
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&permissions)
})
if err != nil {
return err
}
for _, p := range permissions {
var subject string
if len(p.UserUID) > 0 {
subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "")
} else if len(p.TeamUID) > 0 {
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member")
} else {
// FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place
continue
}
tuple, ok := zanzana.TranslateToTuple(subject, p.Action, p.Kind, p.Identifier, p.OrgID)
if !ok {
continue
}
// our "sync key" is a combination of collectorID and action so we can run this
// sync new data when more actions are supported
key := fmt.Sprintf("%s-%s", collectorID, p.Action)
tuples[key] = append(tuples[key], tuple)
}
return nil
}
}
// folderTreeCollector collects folder tree structure and writes it as relation tuples
func folderTreeCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.folderTreeCollector")
defer span.End()
const collectorID = "folder"
const query = `
SELECT uid, parent_uid, org_id FROM folder
`
type folder struct {
OrgID int64 `xorm:"org_id"`
FolderUID string `xorm:"uid"`
ParentUID string `xorm:"parent_uid"`
}
var folders []folder
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&folders)
})
if err != nil {
return err
}
for _, f := range folders {
var tuple *openfgav1.TupleKey
if f.ParentUID != "" {
tuple = &openfgav1.TupleKey{
Object: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.FolderUID, "", strconv.FormatInt(f.OrgID, 10)),
Relation: zanzana.RelationParent,
User: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.ParentUID, "", strconv.FormatInt(f.OrgID, 10)),
}
} else {
// Map root folders to org
tuple = &openfgav1.TupleKey{
Object: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.FolderUID, "", strconv.FormatInt(f.OrgID, 10)),
Relation: zanzana.RelationOrg,
User: zanzana.NewTupleEntry(zanzana.TypeOrg, strconv.FormatInt(f.OrgID, 10), ""),
}
}
tuples[collectorID] = append(tuples[collectorID], tuple)
}
return nil
}
}
// basicRolesCollector migrates basic roles to OpenFGA tuples
func basicRolesCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "basic_role"
const query = `
SELECT r.name, r.uid as role_uid, p.action, p.kind, p.identifier, r.org_id
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN builtin_role br ON r.id = br.role_id
WHERE r.name LIKE 'basic:%'
`
type Permission struct {
RoleName string `xorm:"role_name"`
OrgID int64 `xorm:"org_id"`
Action string `xorm:"action"`
Kind string
Identifier string
RoleUID string `xorm:"role_uid"`
}
var permissions []Permission
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&permissions)
})
if err != nil {
return err
}
for _, p := range permissions {
type Org struct {
Id int64
Name string
}
var orgs []Org
orgsQuery := "SELECT id, name FROM org"
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(orgsQuery).Find(&orgs)
})
if err != nil {
return err
}
// Populate basic roles permissions for every org
for _, org := range orgs {
var subject string
if p.RoleUID != "" {
subject = zanzana.NewScopedTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee", strconv.FormatInt(org.Id, 10))
} else {
continue
}
var tuple *openfgav1.TupleKey
ok := false
if p.Identifier == "" || p.Identifier == "*" {
tuple, ok = zanzana.TranslateToOrgTuple(subject, p.Action, org.Id)
} else {
tuple, ok = zanzana.TranslateToTuple(subject, p.Action, p.Kind, p.Identifier, org.Id)
}
if !ok {
continue
}
key := fmt.Sprintf("%s-%s", collectorID, p.Action)
if !slices.ContainsFunc(tuples[key], func(e *openfgav1.TupleKey) bool {
// skip duplicated tuples
return e.Object == tuple.Object && e.Relation == tuple.Relation && e.User == tuple.User
}) {
tuples[key] = append(tuples[key], tuple)
}
}
}
return nil
}
}
// customRolesCollector migrates custom roles to OpenFGA tuples
func customRolesCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "custom_role"
const query = `
SELECT r.name, r.uid as role_uid, p.action, p.kind, p.identifier, r.org_id
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN builtin_role br ON r.id = br.role_id
WHERE r.name NOT LIKE 'basic:%'
AND r.name NOT LIKE 'fixed:%'
AND r.name NOT LIKE 'managed:%'
`
type Permission struct {
RoleName string `xorm:"role_name"`
OrgID int64 `xorm:"org_id"`
Action string `xorm:"action"`
Kind string
Identifier string
RoleUID string `xorm:"role_uid"`
}
var permissions []Permission
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&permissions)
})
if err != nil {
return err
}
for _, p := range permissions {
var subject string
if p.RoleUID != "" {
subject = zanzana.NewScopedTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee", strconv.FormatInt(p.OrgID, 10))
} else {
continue
}
var tuple *openfgav1.TupleKey
ok := false
if p.Identifier == "" || p.Identifier == "*" {
tuple, ok = zanzana.TranslateToOrgTuple(subject, p.Action, p.OrgID)
} else {
tuple, ok = zanzana.TranslateToTuple(subject, p.Action, p.Kind, p.Identifier, p.OrgID)
}
if !ok {
continue
}
key := fmt.Sprintf("%s-%s", collectorID, p.Action)
if !slices.ContainsFunc(tuples[key], func(e *openfgav1.TupleKey) bool {
// skip duplicated tuples
return e.Object == tuple.Object && e.Relation == tuple.Relation && e.User == tuple.User
}) {
tuples[key] = append(tuples[key], tuple)
}
}
return nil
}
}
func basicRoleAssignemtCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "basic_role_assignment"
query := `
SELECT ou.org_id, u.uid as user_uid, ou.role as org_role, u.is_admin
FROM org_user ou
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ou.user_id
`
type Assignment struct {
OrgID int64 `xorm:"org_id"`
UserUID string `xorm:"user_uid"`
OrgRole string `xorm:"org_role"`
IsAdmin bool `xorm:"is_admin"`
}
var assignments []Assignment
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&assignments)
})
if err != nil {
return err
}
for _, a := range assignments {
var subject string
if a.UserUID != "" && a.OrgRole != "" {
subject = zanzana.NewTupleEntry(zanzana.TypeUser, a.UserUID, "")
} else {
continue
}
roleUID := zanzana.TranslateBasicRole(a.OrgRole)
tuple := &openfgav1.TupleKey{
User: subject,
Relation: zanzana.RelationAssignee,
Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, roleUID, "", strconv.FormatInt(a.OrgID, 10)),
}
key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee)
tuples[key] = append(tuples[key], tuple)
}
return nil
}
}
func userRoleAssignemtCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "user_role_assignment"
query := `
SELECT ur.org_id, u.uid AS user_uid, r.uid AS role_uid, r.name AS role_name
FROM user_role ur
LEFT JOIN role r ON r.id = ur.role_id
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
WHERE r.name NOT LIKE 'managed:%'
`
type Assignment struct {
OrgID int64 `xorm:"org_id"`
UserUID string `xorm:"user_uid"`
RoleUID string `xorm:"role_uid"`
RoleName string `xorm:"role_name"`
}
var assignments []Assignment
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&assignments)
})
if err != nil {
return err
}
for _, a := range assignments {
if a.UserUID == "" || a.RoleUID == "" {
continue
}
subject := zanzana.NewTupleEntry(zanzana.TypeUser, a.UserUID, "")
if strings.HasPrefix(a.RoleUID, "fixed_") {
// Fixed roles are defined in shema, so they are relations itself. Assignment should look like:
// user:<uid> fixed_folders_reader org:1
relation := zanzana.TranslateFixedRole(a.RoleName)
tuple := &openfgav1.TupleKey{
User: subject,
Relation: relation,
Object: zanzana.NewTupleEntry(zanzana.TypeOrg, strconv.FormatInt(a.OrgID, 10), ""),
}
key := fmt.Sprintf("%s-%s", collectorID, relation)
tuples[key] = append(tuples[key], tuple)
} else {
tuple := &openfgav1.TupleKey{
User: subject,
Relation: zanzana.RelationAssignee,
Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, a.RoleUID, "", strconv.FormatInt(a.OrgID, 10)),
}
key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee)
tuples[key] = append(tuples[key], tuple)
}
}
return nil
}
}
func teamRoleAssignemtCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "team_role_assignment"
const query = `
SELECT tr.org_id, t.uid AS team_uid, r.uid AS role_uid, r.name AS role_name
FROM team_role tr
LEFT JOIN role r ON r.id = tr.role_id
LEFT JOIN team t ON t.id = tr.team_id
WHERE r.name NOT LIKE 'managed:%'
`
type Assignment struct {
OrgID int64 `xorm:"org_id"`
TeamUID string `xorm:"team_uid"`
RoleUID string `xorm:"role_uid"`
RoleName string `xorm:"role_name"`
}
var assignments []Assignment
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&assignments)
})
if err != nil {
return err
}
for _, a := range assignments {
if a.TeamUID == "" || a.RoleUID == "" {
continue
}
subject := zanzana.NewTupleEntry(zanzana.TypeTeam, a.TeamUID, "member")
if strings.HasPrefix(a.RoleUID, "fixed_") {
// Fixed roles are defined in shema, so they are relations itself. Assignment should look like:
// team:<uid> fixed_folders_reader org:1
relation := zanzana.TranslateFixedRole(a.RoleName)
tuple := &openfgav1.TupleKey{
User: subject,
Relation: relation,
Object: zanzana.NewTupleEntry(zanzana.TypeOrg, strconv.FormatInt(a.OrgID, 10), ""),
}
key := fmt.Sprintf("%s-%s", collectorID, relation)
tuples[key] = append(tuples[key], tuple)
} else {
tuple := &openfgav1.TupleKey{
User: subject,
Relation: zanzana.RelationAssignee,
Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, a.RoleUID, "", strconv.FormatInt(a.OrgID, 10)),
}
key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee)
tuples[key] = append(tuples[key], tuple)
}
}
return nil
}
}
// fixedRoleTuplesCollector migrates fixed roles permissions that cannot be described in schema.
// Those are permissions like general folder read and create that have specific resource id.
func fixedRoleTuplesCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "fixed_role"
type Org struct {
Id int64
Name string
}
var orgs []Org
orgsQuery := "SELECT id, name FROM org"
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(orgsQuery).Find(&orgs)
})
if err != nil {
return err
}
type Assignment struct {
RoleName string
Action string
Kind string
Identifier string
}
assignments := []Assignment{
{RoleName: "fixed:dashboards:creator", Action: "folders:read", Kind: "folders", Identifier: "general"},
{RoleName: "fixed:dashboards:creator", Action: "dashboards:create", Kind: "folders", Identifier: "general"},
{RoleName: "fixed:folders:creator", Action: "folders:create", Kind: "folders", Identifier: "general"},
{RoleName: "fixed:folders.general:reader", Action: "folders:read", Kind: "folders", Identifier: "general"},
}
for _, a := range assignments {
fixedRole := zanzana.TranslateFixedRole(a.RoleName)
subject := zanzana.NewTupleEntry(zanzana.TypeRole, fixedRole, "assignee")
for _, org := range orgs {
tuple, ok := zanzana.TranslateToTuple(subject, a.Action, a.Kind, a.Identifier, org.Id)
if !ok {
continue
}
key := fmt.Sprintf("%s-%s", collectorID, a.Action)
tuples[key] = append(tuples[key], tuple)
}
}
return nil
}
}
@@ -0,0 +1,99 @@
package dualwrite
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
// legacyTupleCollector collects tuples groupd by object and tupleKey
type legacyTupleCollector func(ctx context.Context) (map[string]map[string]*openfgav1.TupleKey, error)
// zanzanaTupleCollector collects tuples from zanzana for given object
type zanzanaTupleCollector func(ctx context.Context, client zanzana.Client, object string) (map[string]*openfgav1.TupleKey, error)
type resourceReconciler struct {
name string
legacy legacyTupleCollector
zanzana zanzanaTupleCollector
client zanzana.Client
}
func newResourceReconciler(name string, legacy legacyTupleCollector, zanzana zanzanaTupleCollector, client zanzana.Client) resourceReconciler {
return resourceReconciler{name, legacy, zanzana, client}
}
func (r resourceReconciler) reconcile(ctx context.Context) error {
// 1. Fetch grafana resources stored in grafana db.
res, err := r.legacy(ctx)
if err != nil {
return fmt.Errorf("failed to collect legacy tuples for %s: %w", r.name, err)
}
var (
writes = []*openfgav1.TupleKey{}
deletes = []*openfgav1.TupleKeyWithoutCondition{}
)
for object, tuples := range res {
// 2. Fetch all tuples for given object.
// Due to limitations in open fga api we need to collect tuples per object
zanzanaTuples, err := r.zanzana(ctx, r.client, object)
if err != nil {
return fmt.Errorf("failed to collect zanzanaa tuples for %s: %w", r.name, err)
}
// 3. Check if tuples from grafana db exists in zanzana and if not add them to writes
for key, t := range tuples {
_, ok := zanzanaTuples[key]
if !ok {
writes = append(writes, t)
}
}
// 4. Check if tuple from zanzana don't exists in grafana db, if not add them to deletes.
for key, tuple := range zanzanaTuples {
_, ok := tuples[key]
if !ok {
deletes = append(deletes, &openfgav1.TupleKeyWithoutCondition{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
})
}
}
}
if len(writes) == 0 && len(deletes) == 0 {
return nil
}
// FIXME: batch them together
if len(writes) > 0 {
err := batch(writes, 100, func(items []*openfgav1.TupleKey) error {
return r.client.Write(ctx, &openfgav1.WriteRequest{
Writes: &openfgav1.WriteRequestWrites{TupleKeys: items},
})
})
if err != nil {
return err
}
}
if len(deletes) > 0 {
err := batch(deletes, 100, func(items []*openfgav1.TupleKeyWithoutCondition) error {
return r.client.Write(ctx, &openfgav1.WriteRequest{
Deletes: &openfgav1.WriteRequestDeletes{TupleKeys: items},
})
})
if err != nil {
return err
}
}
return nil
}