IAM: Implement update method in team API (#111660)

* implement team update in legacy store

* add unit tests

* add integration test

* set permissions for user in integration tests

* add more integration tests for update

* update validations

* add unit tests for ValidateOnUpdate() func

* fix integration test
This commit is contained in:
Mihai Doarna
2025-10-03 12:48:38 +03:00
committed by GitHub
parent 76d467f285
commit 0f60e2208e
13 changed files with 484 additions and 7 deletions
+1
View File
@@ -31,6 +31,7 @@ type LegacyIdentityStore interface {
GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error)
CreateTeam(ctx context.Context, ns claims.NamespaceInfo, cmd CreateTeamCommand) (*CreateTeamResult, error)
UpdateTeam(ctx context.Context, ns claims.NamespaceInfo, cmd UpdateTeamCommand) (*UpdateTeamResult, error)
ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error)
DeleteTeam(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamCommand) error
ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error)
+19
View File
@@ -60,6 +60,12 @@ func TestIdentityQueries(t *testing.T) {
return &v
}
updateTeam := func(cmd *UpdateTeamCommand) sqltemplate.SQLTemplate {
v := newUpdateTeam(nodb, cmd)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
listTeams := func(q *ListTeamQuery) sqltemplate.SQLTemplate {
v := newListTeams(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
@@ -393,6 +399,19 @@ func TestIdentityQueries(t *testing.T) {
}),
},
},
sqlUpdateTeamTemplate: {
{
Name: "update_team_basic",
Data: updateTeam(&UpdateTeamCommand{
UID: "team-1",
Name: "Team 1",
Email: "team1@example.com",
IsProvisioned: true,
ExternalUID: "team-1-uid",
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
}),
},
},
sqlDeleteTeamTemplate: {
{
Name: "delete_team_basic",
+85
View File
@@ -265,6 +265,91 @@ func (s *legacySQLStore) CreateTeam(ctx context.Context, ns claims.NamespaceInfo
return &CreateTeamResult{Team: createdTeam}, nil
}
type UpdateTeamCommand struct {
UID string
Name string
Updated DBTime
Email string
ExternalID string
IsProvisioned bool
ExternalUID string
}
type UpdateTeamResult struct {
Team team.Team
}
var sqlUpdateTeamTemplate = mustTemplate("update_team.sql")
func newUpdateTeam(sql *legacysql.LegacyDatabaseHelper, cmd *UpdateTeamCommand) updateTeamQuery {
return updateTeamQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
TeamTable: sql.Table("team"),
Command: cmd,
}
}
type updateTeamQuery struct {
sqltemplate.SQLTemplate
TeamTable string
Command *UpdateTeamCommand
}
func (r updateTeamQuery) Validate() error {
return nil
}
func (s *legacySQLStore) UpdateTeam(ctx context.Context, ns claims.NamespaceInfo, cmd UpdateTeamCommand) (*UpdateTeamResult, error) {
now := time.Now().UTC().Truncate(time.Second)
cmd.Updated = NewDBTime(now)
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newUpdateTeam(sql, &cmd)
var updatedTeam team.Team
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
_, err := s.GetTeamInternalID(ctx, ns, GetTeamInternalIDQuery{
OrgID: ns.OrgID,
UID: cmd.UID,
})
if err != nil {
return fmt.Errorf("team not found: %w", err)
}
teamQuery, err := sqltemplate.Execute(sqlUpdateTeamTemplate, req)
if err != nil {
return fmt.Errorf("failed to execute team update template %q: %w", sqlUpdateTeamTemplate.Name(), err)
}
_, err = st.Exec(ctx, teamQuery, req.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to update team: %w", err)
}
updatedTeam = team.Team{
UID: cmd.UID,
Name: cmd.Name,
Email: cmd.Email,
ExternalUID: cmd.ExternalUID,
IsProvisioned: cmd.IsProvisioned,
Updated: cmd.Updated.Time,
}
return nil
})
if err != nil {
return nil, err
}
return &UpdateTeamResult{Team: updatedTeam}, nil
}
type DeleteTeamCommand struct {
UID string
}
@@ -0,0 +1,7 @@
UPDATE `grafana`.`team`
SET name = 'Team 1',
updated = '2023-01-01 12:00:00',
email = 'team1@example.com',
is_provisioned = TRUE,
external_uid = 'team-1-uid'
WHERE uid = 'team-1'
@@ -0,0 +1,7 @@
UPDATE "grafana"."team"
SET name = 'Team 1',
updated = '2023-01-01 12:00:00',
email = 'team1@example.com',
is_provisioned = TRUE,
external_uid = 'team-1-uid'
WHERE uid = 'team-1'
@@ -0,0 +1,7 @@
UPDATE "grafana"."team"
SET name = 'Team 1',
updated = '2023-01-01 12:00:00',
email = 'team1@example.com',
is_provisioned = TRUE,
external_uid = 'team-1-uid'
WHERE uid = 'team-1'
@@ -0,0 +1,7 @@
UPDATE {{ .Ident .TeamTable }}
SET name = {{ .Arg .Command.Name }},
updated = {{ .Arg .Command.Updated }},
email = {{ .Arg .Command.Email }},
is_provisioned = {{ .Arg .Command.IsProvisioned }},
external_uid = {{ .Arg .Command.ExternalUID }}
WHERE uid = {{ .Arg .Command.UID }}
+7
View File
@@ -2,6 +2,7 @@ package iam
import (
"context"
"fmt"
"maps"
"strings"
@@ -353,6 +354,12 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm
switch typedObj := a.GetObject().(type) {
case *iamv0.ResourcePermission:
return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj)
case *iamv0.Team:
oldTeamObj, ok := a.GetOldObject().(*iamv0.Team)
if !ok {
return fmt.Errorf("expected old object to be a Team, got %T", oldTeamObj)
}
return team.ValidateOnUpdate(ctx, typedObj, oldTeamObj)
}
return nil
case admission.Delete:
+46 -1
View File
@@ -112,7 +112,52 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation
// Update implements rest.Updater.
func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update")
if !s.enableAuthnMutation {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update")
}
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, false, err
}
oldObj, err := s.Get(ctx, name, nil)
if err != nil {
return oldObj, false, err
}
obj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return oldObj, false, err
}
teamObj, ok := obj.(*iamv0alpha1.Team)
if !ok {
return nil, false, fmt.Errorf("expected Team object, got %T", obj)
}
if updateValidation != nil {
if err := updateValidation(ctx, obj, oldObj); err != nil {
return oldObj, false, err
}
}
updateCmd := legacy.UpdateTeamCommand{
UID: teamObj.Name,
Name: teamObj.Spec.Title,
Email: teamObj.Spec.Email,
IsProvisioned: teamObj.Spec.Provisioned,
ExternalUID: teamObj.Spec.ExternalUID,
}
result, err := s.store.UpdateTeam(ctx, ns, updateCmd)
if err != nil {
return oldObj, false, err
}
iamTeam := toTeamObject(result.Team, ns)
return &iamTeam, false, nil
}
func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+25
View File
@@ -30,3 +30,28 @@ func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.Team) error {
return nil
}
func ValidateOnUpdate(ctx context.Context, obj, old *iamv0alpha1.Team) error {
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
}
if obj.Spec.Title == "" {
return apierrors.NewBadRequest("the team must have a title")
}
if !requester.IsIdentityType(types.TypeServiceAccount) && obj.Spec.Provisioned && !old.Spec.Provisioned {
return apierrors.NewBadRequest("provisioned teams are only allowed for service accounts")
}
if old.Spec.Provisioned && !obj.Spec.Provisioned {
return apierrors.NewBadRequest("provisioned teams cannot be updated to non-provisioned teams")
}
if !obj.Spec.Provisioned && obj.Spec.ExternalUID != "" {
return apierrors.NewBadRequest("externalUID is only allowed for provisioned teams")
}
return nil
}
+210
View File
@@ -115,3 +115,213 @@ func TestValidateOnCreate(t *testing.T) {
})
}
}
func TestValidateOnUpdate(t *testing.T) {
tests := []struct {
name string
requester *identity.StaticRequester
obj *iamv0alpha1.Team
old *iamv0alpha1.Team
want error
}{
{
name: "valid update - no changes to provisioned status",
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: nil,
},
{
name: "valid update - service account changing to provisioned",
requester: &identity.StaticRequester{
Type: types.TypeServiceAccount,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
Provisioned: true,
ExternalUID: "test-uid",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: nil,
},
{
name: "valid update - already provisioned team",
requester: &identity.StaticRequester{
Type: types.TypeServiceAccount,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
Provisioned: true,
ExternalUID: "updated-uid",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
Provisioned: true,
ExternalUID: "original-uid",
},
},
want: nil,
},
{
name: "invalid update - no title",
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "",
Email: "updated@test.com",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: apierrors.NewBadRequest("the team must have a title"),
},
{
name: "invalid update - user trying to change to provisioned",
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
Provisioned: true,
ExternalUID: "test-uid",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: apierrors.NewBadRequest("provisioned teams are only allowed for service accounts"),
},
{
name: "invalid update - changing from provisioned to non-provisioned",
requester: &identity.StaticRequester{
Type: types.TypeServiceAccount,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
Provisioned: true,
ExternalUID: "original-uid",
},
},
want: apierrors.NewBadRequest("provisioned teams cannot be updated to non-provisioned teams"),
},
{
name: "invalid update - has externalUID but not provisioned",
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
ExternalUID: "test-uid",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: apierrors.NewBadRequest("externalUID is only allowed for provisioned teams"),
},
{
name: "invalid update - no requester in context",
requester: nil,
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
},
},
want: apierrors.NewUnauthorized("no identity found"),
},
{
name: "valid update - adding externalUID to provisioned team",
requester: &identity.StaticRequester{
Type: types.TypeServiceAccount,
OrgRole: identity.RoleAdmin,
},
obj: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "updated title",
Email: "updated@test.com",
Provisioned: true,
ExternalUID: "new-uid",
},
},
old: &iamv0alpha1.Team{
Spec: iamv0alpha1.TeamSpec{
Title: "original title",
Email: "original@test.com",
Provisioned: true,
},
},
want: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := identity.WithRequester(context.Background(), test.requester)
err := ValidateOnUpdate(ctx, test.obj, test.old)
assert.Equal(t, test.want, err)
})
}
}