IAM: Create Service Account API and legacy store impl (#110411)

* wip

* IAM: Create Service Account

* Add dual writer

* Update openapi_test.go

* Add integration tests

* Add sql tests

* Add Role to SA spec, add validation, add DBTime, add tests

* Format, update test

* Fixes

* Add check for External

* Address feedback

* Update tests

* Address feedback

* make gen-go

* Simplify a bit

* Fixes

* make update-workspace

* Update pkg/registry/apis/iam/serviceaccount/store.go

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* Address feedback, add test for generateName

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Misi
2025-09-08 14:31:32 +02:00
committed by GitHub
co-authored by Ryan McKinley
parent 8a28381278
commit badea8bc37
46 changed files with 1961 additions and 198 deletions
@@ -0,0 +1,7 @@
INSERT INTO {{ .Ident .UserTable }}
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
({{ .Arg .Command.UID }}, 0, {{ .Arg .Command.Login }}, {{ .Arg .Command.Email }}, {{ .Arg .Command.Name }},
{{ .Arg .Command.OrgID }}, false, {{ .Arg .Command.IsDisabled }}, false,
false, true, '', '', {{ .Arg .Command.Created }}, {{ .Arg .Command.Updated }}, {{ .Arg .Command.LastSeenAt }})
+115 -1
View File
@@ -9,6 +9,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
@@ -107,10 +108,28 @@ type ServiceAccount struct {
UID string
Name string
Disabled bool
Role string
Created time.Time
Updated time.Time
}
type CreateServiceAccountCommand struct {
UID string
Name string
Email string
Login string
Role string
IsDisabled bool
OrgID int64
Created DBTime
Updated DBTime
LastSeenAt time.Time
}
type CreateServiceAccountResult struct {
ServiceAccount ServiceAccount
}
var sqlQueryServiceAccountsTemplate = mustTemplate("service_accounts_query.sql")
func newListServiceAccounts(sql *legacysql.LegacyDatabaseHelper, q *ListServiceAccountsQuery) listServiceAccountsQuery {
@@ -167,7 +186,7 @@ func (s *legacySQLStore) ListServiceAccounts(ctx context.Context, ns claims.Name
var lastID int64
for rows.Next() {
var s ServiceAccount
err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Created, &s.Updated)
err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Role, &s.Created, &s.Updated)
if err != nil {
return res, err
}
@@ -286,3 +305,98 @@ func (s *legacySQLStore) ListServiceAccountTokens(ctx context.Context, ns claims
return res, err
}
var sqlCreateServiceAccountTemplate = mustTemplate("create_service_account.sql")
func newCreateServiceAccount(sql *legacysql.LegacyDatabaseHelper, cmd *CreateServiceAccountCommand) createServiceAccountQuery {
return createServiceAccountQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Command: cmd,
}
}
type createServiceAccountQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Command *CreateServiceAccountCommand
}
func (r createServiceAccountQuery) Validate() error {
return nil
}
func (s *legacySQLStore) CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error) {
cmd.OrgID = ns.OrgID
cmd.Email = cmd.Login
now := time.Now().UTC().Truncate(time.Second)
lastSeenAt := now.AddDate(-10, 0, 0) // Set last seen 10 years ago like in user service
cmd.Created = NewDBTime(now)
cmd.Updated = NewDBTime(now)
cmd.LastSeenAt = lastSeenAt
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newCreateServiceAccount(sql, &cmd)
var createdSA ServiceAccount
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
userQuery, err := sqltemplate.Execute(sqlCreateServiceAccountTemplate, req)
if err != nil {
return fmt.Errorf("execute service account template %q: %w", sqlCreateServiceAccountTemplate.Name(), err)
}
serviceAccountID, err := st.ExecWithReturningId(ctx, userQuery, req.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to create service account: %w", err)
}
orgUserCmd := &CreateOrgUserCommand{
OrgID: ns.OrgID,
UserID: serviceAccountID,
Role: cmd.Role,
Created: cmd.Created,
Updated: cmd.Updated,
}
orgUserReq := newCreateOrgUser(sql, orgUserCmd)
orgUserQuery, err := sqltemplate.Execute(sqlCreateOrgUserTemplate, orgUserReq)
if err != nil {
return fmt.Errorf("execute org_user template %q: %w", sqlCreateOrgUserTemplate.Name(), err)
}
_, err = st.Exec(ctx, orgUserQuery, orgUserReq.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to create org_user relationship: %w", err)
}
createdSA = ServiceAccount{
ID: serviceAccountID,
UID: cmd.UID,
Name: cmd.Name,
Role: cmd.Role,
Disabled: cmd.IsDisabled,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
}
return nil
})
if err != nil {
return nil, err
}
return &CreateServiceAccountResult{ServiceAccount: createdSA}, nil
}
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM {{ .Ident .UserTable }} as u JOIN {{ .Ident .OrgUserTable }} as o ON u.id = o.user_id
+49 -3
View File
@@ -2,9 +2,11 @@ package legacy
import (
"context"
"database/sql/driver"
"embed"
"fmt"
"text/template"
"time"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/storage/legacysql"
@@ -22,6 +24,8 @@ type LegacyIdentityStore interface {
GetServiceAccountInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetServiceAccountInternalIDQuery) (*GetServiceAccountInternalIDResult, error)
ListServiceAccounts(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountsQuery) (*ListServiceAccountResult, error)
CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error)
ListServiceAccountTokens(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountTokenQuery) (*ListServiceAccountTokenResult, error)
GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error)
@@ -30,9 +34,7 @@ type LegacyIdentityStore interface {
ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error)
}
var (
_ LegacyIdentityStore = (*legacySQLStore)(nil)
)
var _ LegacyIdentityStore = (*legacySQLStore)(nil)
func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStore {
return &legacySQLStore{
@@ -58,3 +60,47 @@ func mustTemplate(filename string) *template.Template {
}
panic(fmt.Sprintf("template file not found: %s", filename))
}
type DBTime struct {
time.Time
}
func NewDBTime(t time.Time) DBTime {
return DBTime{Time: t}
}
func (t DBTime) Value() (driver.Value, error) {
if t.IsZero() {
return nil, nil
}
return t.Format(time.DateTime), nil
}
func (t *DBTime) Scan(value interface{}) error {
if value == nil {
t.Time = time.Time{}
return nil
}
var parsedTime time.Time
var err error
switch v := value.(type) {
case []byte:
parsedTime, err = time.Parse(time.DateTime, string(v))
case string:
parsedTime, err = time.Parse(time.DateTime, v)
case time.Time:
parsedTime = v
default:
return fmt.Errorf("could not scan type %T into DBTime", value)
}
if err != nil {
return fmt.Errorf("could not parse time: %w", err)
}
t.Time = parsedTime
return nil
}
+46 -10
View File
@@ -88,6 +88,12 @@ func TestIdentityQueries(t *testing.T) {
return &v
}
createServiceAccounts := func(cmd *CreateServiceAccountCommand) sqltemplate.SQLTemplate {
v := newCreateServiceAccount(nodb, cmd)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
listServiceAccountTokens := func(q *ListServiceAccountTokenQuery) sqltemplate.SQLTemplate {
v := newListServiceAccountTokens(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
@@ -361,8 +367,8 @@ func TestIdentityQueries(t *testing.T) {
OrgID: 1,
UserID: 123,
Role: "Viewer",
Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
}),
},
{
@@ -371,8 +377,8 @@ func TestIdentityQueries(t *testing.T) {
OrgID: 2,
UserID: 456,
Role: "Admin",
Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
}),
},
},
@@ -391,9 +397,9 @@ func TestIdentityQueries(t *testing.T) {
IsProvisioned: false,
Salt: "randomsalt",
Rands: "randomrands",
Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
LastSeenAt: NewDBTime(time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)),
Role: "Viewer",
}),
},
@@ -411,13 +417,43 @@ func TestIdentityQueries(t *testing.T) {
IsProvisioned: true,
Salt: "adminsalt",
Rands: "adminrands",
Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
LastSeenAt: NewDBTime(time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC)),
Role: "Admin",
}),
},
},
sqlCreateServiceAccountTemplate: {
{
Name: "create_service_account_basic",
Data: createServiceAccounts(&CreateServiceAccountCommand{
UID: "abcdef",
Name: "Service Account 1",
Email: "sa-1-service-account-1",
Login: "sa-1-service-account-1",
IsDisabled: false,
OrgID: 1,
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC),
}),
},
{
Name: "create_service_account_disabled",
Data: createServiceAccounts(&CreateServiceAccountCommand{
UID: "abcdef",
Name: "Disabled Service Account",
Email: "sa-2-disabled-service-account",
Login: "sa-2-disabled-service-account",
IsDisabled: true,
OrgID: 2,
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC),
}),
},
},
},
})
}
@@ -0,0 +1,7 @@
INSERT INTO `grafana`.`user`
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO `grafana`.`user`
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
+11 -11
View File
@@ -303,9 +303,9 @@ type CreateUserCommand struct {
IsProvisioned bool
Salt string
Rands string
Created time.Time
Updated time.Time
LastSeenAt time.Time
Created DBTime
Updated DBTime
LastSeenAt DBTime
Role string
}
@@ -317,8 +317,8 @@ type CreateOrgUserCommand struct {
OrgID int64
UserID int64
Role string
Created time.Time
Updated time.Time
Created DBTime
Updated DBTime
}
type DeleteUserCommand struct {
@@ -395,9 +395,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
cmd.Salt = salt
cmd.Rands = rands
cmd.Created = now
cmd.Updated = now
cmd.LastSeenAt = lastSeenAt
cmd.Created = NewDBTime(now)
cmd.Updated = NewDBTime(now)
cmd.LastSeenAt = NewDBTime(lastSeenAt)
cmd.Role = "Viewer" // TODO: https://github.com/grafana/identity-access-team/issues/1552
sql, err := s.sql(ctx)
@@ -451,9 +451,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
IsProvisioned: cmd.IsProvisioned,
Salt: cmd.Salt,
Rands: cmd.Rands,
Created: cmd.Created,
Updated: cmd.Updated,
LastSeenAt: cmd.LastSeenAt,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
LastSeenAt: cmd.LastSeenAt.Time,
IsServiceAccount: false,
}
+35 -26
View File
@@ -138,6 +138,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
teamBindingResource := iamv0.TeamBindingResourceInfo
storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.store)
// User store registration
userResource := iamv0.UserResourceInfo
legacyStore := user.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation)
storage[userResource.StoragePath()] = legacyStore
@@ -157,8 +158,26 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
}
storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store)
// Service Accounts store registration
serviceAccountResource := iamv0.ServiceAccountResourceInfo
storage[serviceAccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient)
saLegacyStore := serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation)
storage[serviceAccountResource.StoragePath()] = saLegacyStore
if b.enableDualWriter {
store, err := grafanaregistry.NewRegistryStore(opts.Scheme, serviceAccountResource, opts.OptsGetter)
if err != nil {
return err
}
dw, err := opts.DualWriteBuilder(serviceAccountResource.GroupResource(), saLegacyStore, store)
if err != nil {
return err
}
storage[serviceAccountResource.StoragePath()] = dw
}
storage[serviceAccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store)
if b.sso != nil {
@@ -269,15 +288,21 @@ func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authoriz
func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
switch a.GetOperation() {
case admission.Create:
if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() {
switch typedObj := a.GetObject().(type) {
case *iamv0.User:
return b.validateCreateUser(ctx, a, o)
case *iamv0.ServiceAccount:
return serviceaccount.ValidateOnCreate(ctx, typedObj)
}
return nil
case admission.Connect:
case admission.Delete:
case admission.Update:
return nil
case admission.Delete:
return nil
case admission.Connect:
return nil
}
return nil
}
@@ -289,7 +314,7 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewBadRequest("no identity found")
return apierrors.NewUnauthorized("no identity found")
}
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
@@ -312,8 +337,11 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont
func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
switch a.GetOperation() {
case admission.Create:
if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() {
return b.mutateUser(ctx, a, o)
switch typedObj := a.GetObject().(type) {
case *iamv0.User:
return user.MutateOnCreate(ctx, typedObj)
case *iamv0.ServiceAccount:
return serviceaccount.MutateOnCreate(ctx, typedObj)
}
return nil
case admission.Update:
@@ -327,25 +355,6 @@ func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admis
return nil
}
func (b *IdentityAccessManagementAPIBuilder) mutateUser(_ context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
userObj, ok := a.GetObject().(*iamv0.User)
if !ok {
return nil
}
userObj.Spec.Email = strings.ToLower(userObj.Spec.Email)
userObj.Spec.Login = strings.ToLower(userObj.Spec.Login)
if userObj.Spec.Login == "" {
userObj.Spec.Login = userObj.Spec.Email
}
if userObj.Spec.Email == "" {
userObj.Spec.Email = userObj.Spec.Login
}
return nil
}
func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter,
reg prometheus.Registerer, ac types.AccessClient, storageBackend resource.StorageBackend) (grafanarest.Storage, error) {
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
@@ -0,0 +1,16 @@
package serviceaccount
import (
"context"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error {
// External service accounts have None org role by default
if obj.Spec.Plugin != "" && obj.Spec.Role == "" {
obj.Spec.Role = iamv0alpha1.ServiceAccountOrgRoleNone
}
return nil
}
@@ -0,0 +1,69 @@
package serviceaccount
import (
"context"
"testing"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/endpoints/request"
)
func TestMutateOnCreate(t *testing.T) {
ctx := request.WithNamespace(context.Background(), "default")
testCases := []struct {
name string
inputSA *iamv0alpha1.ServiceAccount
expectedRole iamv0alpha1.ServiceAccountOrgRole
}{
{
name: "non-external sa with editor role",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "My Test SA",
Role: iamv0alpha1.ServiceAccountOrgRoleEditor,
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleEditor,
},
{
name: "external sa with admin role is not overridden",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "grafana-plugin-name",
Plugin: "grafana-plugin-name",
Role: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
{
name: "external sa with no role specified gets none",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "sa-1-extsvc-grafana-plugin-name",
Plugin: "grafana-plugin-name",
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleNone,
},
{
name: "non-external sa with no role specified",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Another SA",
},
},
expectedRole: "", // Role is not mutated if not present and not external
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := MutateOnCreate(ctx, tc.inputSA)
require.NoError(t, err)
require.Equal(t, tc.expectedRole, tc.inputSA.Spec.Role)
})
}
}
+89 -7
View File
@@ -3,7 +3,9 @@ package serviceaccount
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -12,9 +14,12 @@ import (
claims "github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/util"
)
var (
@@ -23,17 +28,85 @@ var (
_ rest.Getter = (*LegacyStore)(nil)
_ rest.Lister = (*LegacyStore)(nil)
_ rest.Storage = (*LegacyStore)(nil)
_ rest.CreaterUpdater = (*LegacyStore)(nil)
_ rest.GracefulDeleter = (*LegacyStore)(nil)
_ rest.CollectionDeleter = (*LegacyStore)(nil)
)
var resource = iamv0alpha1.ServiceAccountResourceInfo
func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyStore {
return &LegacyStore{store, ac}
func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient, enableAuthnMutation bool) *LegacyStore {
return &LegacyStore{store, ac, enableAuthnMutation}
}
type LegacyStore struct {
store legacy.LegacyIdentityStore
ac claims.AccessClient
store legacy.LegacyIdentityStore
ac claims.AccessClient
enableAuthnMutation bool
}
// DeleteCollection implements rest.CollectionDeleter.
func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
}
// Delete implements rest.GracefulDeleter.
func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
}
// 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")
}
// Create implements rest.Creater.
func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if !s.enableAuthnMutation {
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "create")
}
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
saObj, ok := obj.(*iamv0alpha1.ServiceAccount)
if !ok {
return nil, fmt.Errorf("expected ServiceAccount object, got %T", obj)
}
if saObj.GenerateName != "" {
saObj.Name = saObj.GenerateName + util.GenerateShortUID()
saObj.GenerateName = ""
}
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
login := serviceaccounts.GenerateLogin(serviceaccounts.ServiceAccountPrefix, ns.OrgID, saObj.Spec.Title)
if saObj.Spec.Plugin != "" {
login = serviceaccounts.ExtSvcLoginPrefix(ns.OrgID) + slugify.Slugify(saObj.Spec.Title)
}
createCmd := legacy.CreateServiceAccountCommand{
IsDisabled: saObj.Spec.Disabled,
Name: saObj.Spec.Title,
UID: saObj.Name,
Login: strings.ToLower(login),
Role: string(saObj.Spec.Role),
}
result, err := s.store.CreateServiceAccount(ctx, ns, createCmd)
if err != nil {
return nil, err
}
iamSA := s.toSAItem(result.ServiceAccount, ns.Value)
return &iamSA, nil
}
func (s *LegacyStore) New() runtime.Object {
@@ -72,7 +145,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items))
for _, sa := range found.Items {
items = append(items, toSAItem(sa, ns.Value))
items = append(items, s.toSAItem(sa, ns.Value))
}
return &common.ListResponse[iamv0alpha1.ServiceAccount]{
@@ -93,7 +166,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
return obj, nil
}
func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
func (s *LegacyStore) toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
item := iamv0alpha1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: sa.UID,
@@ -102,8 +175,10 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
CreationTimestamp: metav1.NewTime(sa.Created),
},
Spec: iamv0alpha1.ServiceAccountSpec{
Plugin: extractPluginNameFromTitle(sa.Name),
Title: sa.Name,
Disabled: sa.Disabled,
Role: iamv0alpha1.ServiceAccountOrgRole(sa.Role),
},
}
obj, _ := utils.MetaAccessor(&item)
@@ -112,6 +187,13 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
return item
}
func extractPluginNameFromTitle(title string) string {
if strings.HasPrefix(title, serviceaccounts.ExtSvcPrefix) {
return strings.TrimLeft(title, serviceaccounts.ExtSvcPrefix)
}
return ""
}
func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
@@ -130,6 +212,6 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO
return nil, resource.NewNotFound(name)
}
res := toSAItem(found.Items[0], ns.Value)
res := s.toSAItem(found.Items[0], ns.Value)
return &res, nil
}
@@ -0,0 +1,58 @@
package serviceaccount
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error {
if obj.Spec.Title == "" {
return apierrors.NewBadRequest("service account must have a title")
}
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
}
requestedRole := identity.RoleType(obj.Spec.Role)
if !requestedRole.IsValid() {
return apierrors.NewBadRequest(fmt.Sprintf("invalid role: %s", requestedRole))
}
if obj.Spec.Plugin != "" {
if !strings.HasPrefix(obj.Spec.Title, serviceaccounts.ExtSvcPrefix) {
return apierrors.NewBadRequest("title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix)
}
if !strings.HasSuffix(obj.Spec.Title, strings.ToLower(obj.Spec.Plugin)) {
return apierrors.NewBadRequest("title of external service accounts must end with " + strings.ToLower(obj.Spec.Plugin))
}
if !requester.IsIdentityType(types.TypeAccessPolicy) {
return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(),
obj.Name,
fmt.Errorf("only service identities can create external service accounts"))
}
if obj.Spec.Role != iamv0alpha1.ServiceAccountOrgRoleNone {
return apierrors.NewBadRequest("external service accounts must have role None")
}
}
if !requester.HasRole(requestedRole) {
return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(),
obj.Name,
fmt.Errorf("can not assign a role higher than user's role"))
}
return nil
}
@@ -0,0 +1,195 @@
package serviceaccount
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func TestValidateOnCreate(t *testing.T) {
tests := []struct {
name string
serviceAccount *iamv0alpha1.ServiceAccount
requester *identity.StaticRequester
expectError bool
errorContains string
}{
{
name: "valid service account with user requester",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: false,
},
{
name: "empty title",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "service account must have a title",
},
{
name: "invalid role",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: "InvalidRole",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "invalid role",
},
{
name: "role higher than requester's role",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleViewer,
},
expectError: true,
errorContains: "can not assign a role higher than user's role",
},
{
name: "external service account - valid",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-plugin",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test-plugin",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: false,
},
{
name: "external service account - invalid title prefix",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "invalid-prefix-test",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix,
},
{
name: "external service account - invalid title suffix",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "wrong-suffix",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "title of external service accounts must end with test",
},
{
name: "external service account - non-access-policy requester",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-test",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "only service identities can create external service accounts",
},
{
name: "external service account - role not None",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-test",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "external service accounts must have role None",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := identity.WithRequester(
context.Background(),
tt.requester,
)
err := ValidateOnCreate(ctx, tt.serviceAccount)
if tt.expectError {
require.Error(t, err)
if tt.errorContains != "" {
require.Contains(t, err.Error(), tt.errorContains)
}
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateOnCreate_NoRequester(t *testing.T) {
serviceAccount := &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
}
err := ValidateOnCreate(context.Background(), serviceAccount)
require.Error(t, err)
require.Contains(t, err.Error(), "no identity found")
}
+22
View File
@@ -0,0 +1,22 @@
package user
import (
"context"
"strings"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
obj.Spec.Email = strings.ToLower(obj.Spec.Email)
obj.Spec.Login = strings.ToLower(obj.Spec.Login)
if obj.Spec.Login == "" {
obj.Spec.Login = obj.Spec.Email
}
if obj.Spec.Email == "" {
obj.Spec.Email = obj.Spec.Login
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package user
import (
"context"
"testing"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/stretchr/testify/require"
)
func TestMutateOnCreate_LoginEmail(t *testing.T) {
testCases := []struct {
name string
inputUser *iamv0alpha1.User
expectedLogin string
expectedEmail string
}{
{
name: "login and email provided with mixed case",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "Test.User",
Email: "Test.User@example.com",
},
},
expectedLogin: "test.user",
expectedEmail: "test.user@example.com",
},
{
name: "only email provided",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Email: "Only.Email@example.com",
},
},
expectedLogin: "only.email@example.com",
expectedEmail: "only.email@example.com",
},
{
name: "only login provided",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "Only.Login",
},
},
expectedLogin: "only.login",
expectedEmail: "only.login",
},
{
name: "login and email already lowercase",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "already.lower",
Email: "already.lower@example.com",
},
},
expectedLogin: "already.lower",
expectedEmail: "already.lower@example.com",
},
{
name: "both login and email are empty",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{},
},
expectedLogin: "",
expectedEmail: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := MutateOnCreate(context.Background(), tc.inputUser)
require.NoError(t, err)
require.Equal(t, tc.expectedLogin, tc.inputUser.Spec.Login)
require.Equal(t, tc.expectedEmail, tc.inputUser.Spec.Email)
})
}
}