Zanzana: Team bindings write APIs (#114493)

* Zanzana: Team bindings write APIs

* Update pkg/services/authz/zanzana/server/server_mutate_teambindings.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* fix missing import

* fix linter

---------

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Alexander Zobnin
2025-11-28 10:12:50 +01:00
committed by GitHub
co-authored by Gabriel MABILLE
parent 646fb2aa35
commit 8e7ba60b93
6 changed files with 593 additions and 198 deletions
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,8 @@ message MutateOperation {
AddUserOrgRoleOperation add_user_org_role = 7;
CreateRoleBindingOperation create_role_binding = 8;
DeleteRoleBindingOperation delete_role_binding = 9;
CreateTeamBindingOperation create_team_binding = 10;
DeleteTeamBindingOperation delete_team_binding = 11;
}
}
@@ -111,6 +113,24 @@ message DeleteRoleBindingOperation {
string role_name = 4;
}
message CreateTeamBindingOperation {
// uid of the identity
string subject_name = 1;
// uid of the team
string team_name = 2;
// permission of the identity in the team (admin/member)
string permission = 3;
}
message DeleteTeamBindingOperation {
// uid of the identity
string subject_name = 1;
// uid of the team
string team_name = 2;
// permission of the identity in the team (admin/member)
string permission = 3;
}
message Resource {
// group of the resource (e.g: "dashboard.grafana.app")
string group = 1;
@@ -16,6 +16,7 @@ const (
OperationGroupPermission OperationGroup = "permission"
OperationGroupUserOrgRole OperationGroup = "user_org_role"
OperationGroupRoleBinding OperationGroup = "role_binding"
OperationGroupTeamBinding OperationGroup = "team_binding"
)
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
@@ -68,6 +69,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
if err := s.mutateRoleBindings(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate role bindings: %w", err)
}
case OperationGroupTeamBinding:
if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate team bindings: %w", err)
}
default:
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
}
@@ -86,6 +91,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
return OperationGroupUserOrgRole, nil
case *authzextv1.MutateOperation_CreateRoleBinding, *authzextv1.MutateOperation_DeleteRoleBinding:
return OperationGroupRoleBinding, nil
case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding:
return OperationGroupTeamBinding, nil
}
return OperationGroup(""), errors.New("unsupported mutate operation type")
}
@@ -0,0 +1,98 @@
package server
import (
"context"
"errors"
"fmt"
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"
)
func (s *Server) mutateTeamBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
ctx, span := s.tracer.Start(ctx, "server.mutateTeamBindings")
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_CreateTeamBinding:
tuple, err := s.getTeamBindingTuple(ctx, op.CreateTeamBinding.GetSubjectName(), op.CreateTeamBinding.GetTeamName(), op.CreateTeamBinding.GetPermission())
if err != nil {
return err
}
writeTuples = append(writeTuples, tuple)
case *authzextv1.MutateOperation_DeleteTeamBinding:
tuple, err := s.getTeamBindingTuple(ctx, op.DeleteTeamBinding.GetSubjectName(), op.DeleteTeamBinding.GetTeamName(), op.DeleteTeamBinding.GetPermission())
if err != nil {
return err
}
deleteTuple := &openfgav1.TupleKeyWithoutCondition{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
}
deleteTuples = append(deleteTuples, deleteTuple)
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 role binding tuples", "error", err)
return err
}
return nil
}
func (s *Server) getTeamBindingTuple(ctx context.Context, subject string, team string, permission string) (*openfgav1.TupleKey, error) {
if subject == "" {
return nil, errors.New("subject name cannot be empty")
}
if team == "" {
return nil, errors.New("team name cannot be empty")
}
relation := ""
switch permission {
case string(iamv0.TeamBindingTeamPermissionAdmin):
relation = zanzana.RelationTeamAdmin
case string(iamv0.TeamBindingTeamPermissionMember):
relation = zanzana.RelationTeamMember
default:
return nil, fmt.Errorf("unknown team permission '%s', expected member or admin", permission)
}
tuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, subject, ""),
Relation: relation,
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, team, ""),
}
return tuple, nil
}
@@ -0,0 +1,74 @@
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 setupMutateTeamBindings(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewTuple("user:1", common.RelationTeamMember, "team:foo"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutateTeamBindings(t *testing.T, srv *Server) {
setupMutateTeamBindings(t, srv)
t.Run("should update user team binding and delete old team binding", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_CreateTeamBinding{
CreateTeamBinding: &v1.CreateTeamBindingOperation{
SubjectName: "1",
TeamName: "foo",
Permission: "admin",
},
},
},
{
Operation: &v1.MutateOperation_DeleteTeamBinding{
DeleteTeamBinding: &v1.DeleteTeamBindingOperation{
SubjectName: "1",
TeamName: "foo",
Permission: "member",
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationTeamAdmin,
Object: "team:foo",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:1", res.Tuples[0].Key.User)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationTeamMember,
Object: "team:foo",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
}
@@ -140,6 +140,10 @@ func TestIntegrationServer(t *testing.T) {
t.Run("test mutate role bindings", func(t *testing.T) {
testMutateRoleBindings(t, srv)
})
t.Run("test mutate team bindings", func(t *testing.T) {
testMutateTeamBindings(t, srv)
})
}
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {