Files
Misi c6a6b9fdd2 IAM: Create and delete user from the legacy store (#107694)
* Add Create for User + DualWriter setup

* Add delete User

* Fix delete + access check

* Add tests for delete user

* Add tests for create user

* Fixes

* Use sqlx session to fix database locked issues

* wip authz checks

* legacyAccessClient

* Update legacyAccessClient, add tests for create user

* Close rows before running other queries

* Use ExecWithReturningId

* Verify deletion in the tests

* Add Validate and Mutate

* Other changes

* Address feedback

* Update tests

---------

Co-authored-by: Gabriel Mabille <gabriel.mabille@grafana.com>
2025-07-17 11:50:40 +02:00

616 lines
14 KiB
Go

package legacy
import (
"context"
"errors"
"fmt"
"text/template"
"time"
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/services/team"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
"github.com/grafana/grafana/pkg/util"
)
type GetUserInternalIDQuery struct {
OrgID int64
UID string
}
type GetUserInternalIDResult struct {
ID int64
}
var sqlQueryUserInternalIDTemplate = mustTemplate("user_internal_id.sql")
func newGetUserInternalID(sql *legacysql.LegacyDatabaseHelper, q *GetUserInternalIDQuery) getUserInternalIDQuery {
return getUserInternalIDQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Query: q,
}
}
type getUserInternalIDQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Query *GetUserInternalIDQuery
}
func (r getUserInternalIDQuery) Validate() error {
return nil // TODO
}
func (s *legacySQLStore) GetUserInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetUserInternalIDQuery) (*GetUserInternalIDResult, error) {
query.OrgID = ns.OrgID
if query.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newGetUserInternalID(sql, &query)
q, err := sqltemplate.Execute(sqlQueryUserInternalIDTemplate, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryUserInternalIDTemplate.Name(), err)
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
if !rows.Next() {
return nil, errors.New("user not found")
}
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
return &GetUserInternalIDResult{
id,
}, nil
}
type ListUserQuery struct {
OrgID int64
UID string
Pagination common.Pagination
}
type ListUserResult struct {
Users []user.User
Continue int64
RV int64
}
var sqlQueryUsersTemplate = mustTemplate("users_query.sql")
func newListUser(sql *legacysql.LegacyDatabaseHelper, q *ListUserQuery) listUsersQuery {
return listUsersQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Query: q,
}
}
type listUsersQuery struct {
sqltemplate.SQLTemplate
Query *ListUserQuery
UserTable string
OrgUserTable string
}
func (r listUsersQuery) Validate() error {
return nil // TODO
}
// ListUsers implements LegacyIdentityStore.
func (s *legacySQLStore) ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error) {
// for continue
limit := int(query.Pagination.Limit)
query.Pagination.Limit += 1
query.OrgID = ns.OrgID
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
res, err := s.queryUsers(ctx, sql, sqlQueryUsersTemplate, newListUser(sql, &query), limit)
if err == nil && query.UID != "" {
res.RV, err = sql.GetResourceVersion(ctx, "user", "updated")
}
return res, err
}
func (s *legacySQLStore) queryUsers(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, t *template.Template, req sqltemplate.Args, limit int) (*ListUserResult, error) {
q, err := sqltemplate.Execute(t, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", t.Name(), err)
}
res := &ListUserResult{}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err == nil {
var lastID int64
for rows.Next() {
u := user.User{}
err = rows.Scan(&u.OrgID, &u.ID, &u.UID, &u.Login, &u.Email, &u.Name,
&u.Created, &u.Updated, &u.IsServiceAccount, &u.IsDisabled, &u.IsAdmin, &u.EmailVerified,
&u.IsProvisioned, &u.LastSeenAt,
)
if err != nil {
return res, err
}
lastID = u.ID
res.Users = append(res.Users, u)
if len(res.Users) > limit {
res.Users = res.Users[0 : len(res.Users)-1]
res.Continue = lastID
break
}
}
}
return res, err
}
type ListUserTeamsQuery struct {
UserUID string
OrgID int64
Pagination common.Pagination
}
type ListUserTeamsResult struct {
Continue int64
Items []UserTeam
}
type UserTeam struct {
ID int64
UID string
Name string
Permission team.PermissionType
}
var sqlQueryUserTeamsTemplate = mustTemplate("user_teams_query.sql")
func newListUserTeams(sql *legacysql.LegacyDatabaseHelper, q *ListUserTeamsQuery) listUserTeamsQuery {
return listUserTeamsQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
TeamTable: sql.Table("team"),
TeamMemberTable: sql.Table("team_member"),
Query: q,
}
}
type listUserTeamsQuery struct {
sqltemplate.SQLTemplate
Query *ListUserTeamsQuery
UserTable string
TeamTable string
TeamMemberTable string
}
func (r listUserTeamsQuery) Validate() error {
return nil
}
func (s *legacySQLStore) ListUserTeams(ctx context.Context, ns claims.NamespaceInfo, query ListUserTeamsQuery) (*ListUserTeamsResult, error) {
query.Pagination.Limit += 1
query.OrgID = ns.OrgID
if query.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newListUserTeams(sql, &query)
q, err := sqltemplate.Execute(sqlQueryUserTeamsTemplate, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeamsTemplate.Name(), err)
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
res := &ListUserTeamsResult{}
var lastID int64
for rows.Next() {
t := UserTeam{}
// regression: team_member.permission has been nulled in some instances
// Team memberships created before the permission column was added will have a NULL value
var nullablePermission *int64
err := rows.Scan(&t.ID, &t.UID, &t.Name, &nullablePermission)
if err != nil {
return nil, err
}
if nullablePermission != nil {
t.Permission = team.PermissionType(*nullablePermission)
} else {
// treat NULL as member permission
t.Permission = team.PermissionType(0)
}
res.Items = append(res.Items, t)
lastID = t.ID
if len(res.Items) > int(query.Pagination.Limit)-1 {
res.Continue = lastID
res.Items = res.Items[0 : len(res.Items)-1]
break
}
}
return res, err
}
type CreateUserCommand struct {
UID string
Email string
Login string
Name string
OrgID int64
IsAdmin bool
IsDisabled bool
EmailVerified bool
IsProvisioned bool
Salt string
Rands string
Created time.Time
Updated time.Time
LastSeenAt time.Time
Role string
}
type CreateUserResult struct {
User user.User
}
type CreateOrgUserCommand struct {
OrgID int64
UserID int64
Role string
Created time.Time
Updated time.Time
}
type DeleteUserCommand struct {
UID string
}
type DeleteUserQuery struct {
OrgID int64
UID string
}
type DeleteUserResult struct {
Success bool
}
var sqlCreateUserTemplate = mustTemplate("create_user.sql")
var sqlCreateOrgUserTemplate = mustTemplate("create_org_user.sql")
var sqlDeleteUserTemplate = mustTemplate("delete_user.sql")
var sqlDeleteOrgUserTemplate = mustTemplate("delete_org_user.sql")
func newCreateUser(sql *legacysql.LegacyDatabaseHelper, cmd *CreateUserCommand) createUserQuery {
return createUserQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Command: cmd,
}
}
type createUserQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Command *CreateUserCommand
}
func (r createUserQuery) Validate() error {
return nil
}
type createOrgUserQuery struct {
sqltemplate.SQLTemplate
OrgUserTable string
Command *CreateOrgUserCommand
}
func (r createOrgUserQuery) Validate() error {
return nil
}
func newCreateOrgUser(sql *legacysql.LegacyDatabaseHelper, cmd *CreateOrgUserCommand) createOrgUserQuery {
return createOrgUserQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
OrgUserTable: sql.Table("org_user"),
Command: cmd,
}
}
// CreateUser implements LegacyIdentityStore.
func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo, cmd CreateUserCommand) (*CreateUserResult, error) {
cmd.OrgID = ns.OrgID
salt, err := util.GetRandomString(10)
if err != nil {
return nil, err
}
rands, err := util.GetRandomString(10)
if err != nil {
return nil, err
}
now := time.Now()
lastSeenAt := now.AddDate(-10, 0, 0) // Set last seen 10 years ago like in user service
cmd.Salt = salt
cmd.Rands = rands
cmd.Created = now
cmd.Updated = now
cmd.LastSeenAt = lastSeenAt
cmd.Role = "Viewer" // TODO: https://github.com/grafana/identity-access-team/issues/1552
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newCreateUser(sql, &cmd)
var createdUser user.User
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
userQuery, err := sqltemplate.Execute(sqlCreateUserTemplate, req)
if err != nil {
return fmt.Errorf("execute user template %q: %w", sqlCreateUserTemplate.Name(), err)
}
userID, err := st.ExecWithReturningId(ctx, userQuery, req.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
orgUserCmd := &CreateOrgUserCommand{
OrgID: cmd.OrgID,
UserID: userID,
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)
}
createdUser = user.User{
ID: userID,
UID: cmd.UID,
Login: cmd.Login,
Email: cmd.Email,
Name: cmd.Name,
OrgID: cmd.OrgID,
IsAdmin: cmd.IsAdmin,
IsDisabled: cmd.IsDisabled,
EmailVerified: cmd.EmailVerified,
IsProvisioned: cmd.IsProvisioned,
Salt: cmd.Salt,
Rands: cmd.Rands,
Created: cmd.Created,
Updated: cmd.Updated,
LastSeenAt: cmd.LastSeenAt,
IsServiceAccount: false,
}
return nil
})
if err != nil {
return nil, err
}
return &CreateUserResult{User: createdUser}, nil
}
func newDeleteUser(sql *legacysql.LegacyDatabaseHelper, q *DeleteUserQuery) deleteUserQuery {
return deleteUserQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Query: q,
}
}
type deleteUserQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Query *DeleteUserQuery
}
type deleteOrgUserQuery struct {
sqltemplate.SQLTemplate
OrgUserTable string
UserID int64
}
func (r deleteOrgUserQuery) Validate() error {
if r.UserID == 0 {
return fmt.Errorf("user ID is required")
}
return nil
}
func (r deleteUserQuery) Validate() error {
if r.Query.UID == "" {
return fmt.Errorf("user UID is required")
}
if r.Query.OrgID == 0 {
return fmt.Errorf("org ID is required")
}
return nil
}
// DeleteUser implements LegacyIdentityStore.
func (s *legacySQLStore) DeleteUser(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteUserCommand) (*DeleteUserResult, error) {
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
if cmd.UID == "" {
return nil, fmt.Errorf("user UID is required")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
query := &DeleteUserQuery{
OrgID: ns.OrgID,
UID: cmd.UID,
}
req := newDeleteUser(sql, query)
if err := req.Validate(); err != nil {
return nil, err
}
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
userLookupReq := newGetUserInternalID(sql, &GetUserInternalIDQuery{
OrgID: ns.OrgID,
UID: req.Query.UID,
})
userQuery, err := sqltemplate.Execute(sqlQueryUserInternalIDTemplate, userLookupReq)
if err != nil {
return fmt.Errorf("execute user lookup template: %w", err)
}
rows, err := st.Query(ctx, userQuery, userLookupReq.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to check if user exists: %w", err)
}
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
var userID int64
if !rows.Next() {
if err := rows.Err(); err != nil {
return fmt.Errorf("failed to read user lookup rows: %w", err)
}
// User not found, nothing to delete
return fmt.Errorf("user not found")
}
if err := rows.Scan(&userID); err != nil {
return fmt.Errorf("failed to scan user ID: %w", err)
}
// Close rows to avoid the bad connection error
if rows != nil {
_ = rows.Close()
}
orgUserReq := deleteOrgUserQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
OrgUserTable: sql.Table("org_user"),
UserID: userID,
}
orgUserDeleteQuery, err := sqltemplate.Execute(sqlDeleteOrgUserTemplate, orgUserReq)
if err != nil {
return fmt.Errorf("execute org_user delete template: %w", err)
}
_, err = st.Exec(ctx, orgUserDeleteQuery, orgUserReq.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to delete from org_user: %w", err)
}
deleteQuery, err := sqltemplate.Execute(sqlDeleteUserTemplate, req)
if err != nil {
return fmt.Errorf("execute delete template %q: %w", sqlDeleteUserTemplate.Name(), err)
}
result, err := st.Exec(ctx, deleteQuery, req.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
})
if err != nil {
return nil, err
}
return &DeleteUserResult{Success: true}, nil
}