mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
The guard once asserted a name where it now asserts the IAM subject, so a row seated then is keyed by uuid5 of the name. The subject misses that row, the create collides on the email, and ensureUser answered the collision with success, leaving GET /users/me and every row keyed to the person on an id no session asserts. On that collision ensureUser now calls user.Setter.RekeyUser: in one transaction it finds the org's row by email and moves it, its user_role rows and every other row that references users.id onto the subject. SQLite checks the foreign keys at COMMIT (defer_foreign_keys), so a reference left behind refuses the whole move. The role grants follow the rows once they commit. Co-authored-by: Hanzo Dev <[email protected]>
291 lines
9.8 KiB
Go
291 lines
9.8 KiB
Go
package types
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y/pkg/errors"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
"github.com/uptrace/bun"
|
|
)
|
|
|
|
var (
|
|
ErrCodeUserNotFound = errors.MustNewCode("user_not_found")
|
|
ErrCodeAmbiguousUser = errors.MustNewCode("ambiguous_user")
|
|
ErrUserAlreadyExists = errors.MustNewCode("user_already_exists")
|
|
ErrPasswordAlreadyExists = errors.MustNewCode("password_already_exists")
|
|
ErrResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists")
|
|
ErrPasswordNotFound = errors.MustNewCode("password_not_found")
|
|
ErrResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found")
|
|
ErrAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
|
|
ErrAPIKeyNotFound = errors.MustNewCode("api_key_not_found")
|
|
ErrCodeRootUserOperationUnsupported = errors.MustNewCode("root_user_operation_unsupported")
|
|
ErrCodeUserStatusDeleted = errors.MustNewCode("user_status_deleted")
|
|
ErrCodeUserStatusPendingInvite = errors.MustNewCode("user_status_pending_invite")
|
|
ErrCodeUserStatusNotPendingInvite = errors.MustNewCode("user_status_not_pending_invite")
|
|
)
|
|
|
|
var (
|
|
UserStatusPendingInvite = valuer.NewString("pending_invite")
|
|
UserStatusActive = valuer.NewString("active")
|
|
UserStatusDeleted = valuer.NewString("deleted")
|
|
ValidUserStatus = []valuer.String{UserStatusPendingInvite, UserStatusActive, UserStatusDeleted}
|
|
)
|
|
|
|
type User struct {
|
|
bun.BaseModel `bun:"table:users,alias:users"`
|
|
|
|
Identifiable
|
|
DisplayName string `bun:"display_name" json:"displayName"`
|
|
Email valuer.Email `bun:"email" json:"email"`
|
|
OrgID valuer.UUID `bun:"org_id" json:"orgId"`
|
|
IsRoot bool `bun:"is_root" json:"isRoot"`
|
|
Status valuer.String `bun:"status" json:"status"`
|
|
TimeAuditable
|
|
}
|
|
|
|
type DeprecatedUser struct {
|
|
*User
|
|
Role Role `json:"role"`
|
|
}
|
|
|
|
type UpdatableUser struct {
|
|
DisplayName string `json:"displayName" required:"true"`
|
|
}
|
|
|
|
type PostableRole struct {
|
|
Name string `json:"name" required:"true"`
|
|
}
|
|
|
|
func NewUser(displayName string, email valuer.Email, orgID valuer.UUID, status valuer.String) (*User, error) {
|
|
if email.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
|
}
|
|
|
|
if orgID.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
|
}
|
|
|
|
if !slices.Contains(ValidUserStatus, status) {
|
|
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid status: %s, allowed status are: %v", status, ValidUserStatus)
|
|
}
|
|
|
|
return NewUserWithID(valuer.GenerateUUID(), displayName, email, orgID, status)
|
|
}
|
|
|
|
// NewUserWithID builds a user whose id is GIVEN rather than generated — the twin
|
|
// of NewOrganizationWithID, and for the same reason. When identity is asserted by
|
|
// Hanzo IAM the person already HAS an id (the IAM subject); a row minted with a
|
|
// fresh one is a second identity for the same human, and every later lookup keyed
|
|
// on the asserted subject misses it. Same validation, one constructor's worth of
|
|
// rules, the id being the only thing the caller supplies.
|
|
func NewUserWithID(id valuer.UUID, displayName string, email valuer.Email, orgID valuer.UUID, status valuer.String) (*User, error) {
|
|
if email.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
|
}
|
|
|
|
if orgID.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
|
}
|
|
|
|
if !slices.Contains(ValidUserStatus, status) {
|
|
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid status: %s, allowed status are: %v", status, ValidUserStatus)
|
|
}
|
|
|
|
return &User{
|
|
Identifiable: Identifiable{
|
|
ID: id,
|
|
},
|
|
DisplayName: displayName,
|
|
Email: email,
|
|
OrgID: orgID,
|
|
IsRoot: false,
|
|
Status: status,
|
|
TimeAuditable: TimeAuditable{
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID) (*User, error) {
|
|
if email.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
|
}
|
|
|
|
if orgID.IsZero() {
|
|
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
|
}
|
|
|
|
return &User{
|
|
Identifiable: Identifiable{
|
|
ID: valuer.GenerateUUID(),
|
|
},
|
|
DisplayName: displayName,
|
|
Email: email,
|
|
OrgID: orgID,
|
|
IsRoot: true,
|
|
Status: UserStatusActive,
|
|
TimeAuditable: TimeAuditable{
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func NewDeprecatedUserFromUserAndRole(user *User, role Role) *DeprecatedUser {
|
|
return &DeprecatedUser{
|
|
user,
|
|
role,
|
|
}
|
|
}
|
|
|
|
func NewUserFromDeprecatedUser(deprecatedUser *DeprecatedUser) *User {
|
|
return &User{
|
|
Identifiable: deprecatedUser.Identifiable,
|
|
DisplayName: deprecatedUser.DisplayName,
|
|
Email: deprecatedUser.Email,
|
|
OrgID: deprecatedUser.OrgID,
|
|
IsRoot: deprecatedUser.IsRoot,
|
|
Status: deprecatedUser.Status,
|
|
TimeAuditable: deprecatedUser.TimeAuditable,
|
|
}
|
|
}
|
|
|
|
// Update applies mutable fields from the input to the user. Immutable fields
|
|
// (email, is_root, org_id, id) are preserved. Only non-zero input fields are applied.
|
|
func (u *User) Update(displayName string) {
|
|
if displayName != "" {
|
|
u.DisplayName = displayName
|
|
}
|
|
u.UpdatedAt = time.Now()
|
|
}
|
|
|
|
func (u *DeprecatedUser) Update(displayName string, role Role) {
|
|
if displayName != "" {
|
|
u.DisplayName = displayName
|
|
}
|
|
if role != "" {
|
|
u.Role = role
|
|
}
|
|
u.UpdatedAt = time.Now()
|
|
}
|
|
|
|
func (u *User) UpdateStatus(status valuer.String) error {
|
|
// no updates allowed if user is in delete state
|
|
if err := u.ErrIfDeleted(); err != nil {
|
|
return errors.WithAdditionalf(err, "cannot update status of a deleted user")
|
|
}
|
|
|
|
// not udpates allowed from active to pending state
|
|
if status == UserStatusPendingInvite && u.Status == UserStatusActive {
|
|
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cannot move user to pending state from active state")
|
|
}
|
|
|
|
u.Status = status
|
|
u.UpdatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
|
|
// PromoteToRoot promotes the user to a root user with admin role.
|
|
func (u *User) PromoteToRoot() {
|
|
u.IsRoot = true
|
|
u.UpdatedAt = time.Now()
|
|
}
|
|
|
|
// UpdateEmail updates the email of the user.
|
|
func (u *User) UpdateEmail(email valuer.Email) {
|
|
u.Email = email
|
|
u.UpdatedAt = time.Now()
|
|
}
|
|
|
|
// ErrIfRoot returns an error if the user is a root user. The caller should
|
|
// enrich the error with the specific operation using errors.WithAdditionalf.
|
|
func (u *User) ErrIfRoot() error {
|
|
if u.IsRoot {
|
|
return errors.New(errors.TypeUnsupported, ErrCodeRootUserOperationUnsupported, "this operation is not supported for the root user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrIfDeleted returns an error if the user is in deleted state.
|
|
// This error can be enriched with specific operation by the called using errors.WithAdditionalf.
|
|
func (u *User) ErrIfDeleted() error {
|
|
if u.Status == UserStatusDeleted {
|
|
return errors.New(errors.TypeUnsupported, ErrCodeUserStatusDeleted, "unsupported operation for deleted user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrIfPending returns an error if the user is in pending invite state.
|
|
// This error can be enriched with specific operation by the called using errors.WithAdditionalf.
|
|
func (u *User) ErrIfPending() error {
|
|
if u.Status == UserStatusPendingInvite {
|
|
return errors.New(errors.TypeUnsupported, ErrCodeUserStatusPendingInvite, "unsupported operation for pending user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrIfNotPending returns an error if the user is not in pending invite state.
|
|
// This error can be enriched with specific operation by the called using errors.WithAdditionalf.
|
|
func (u *User) ErrIfNotPending() error {
|
|
if u.Status != UserStatusPendingInvite {
|
|
return errors.New(errors.TypeInvalidInput, ErrCodeUserStatusNotPendingInvite, "operation is only supported for pending invite user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewTraitsFromUser(user *User) map[string]any {
|
|
return map[string]any{
|
|
"name": user.DisplayName,
|
|
"email": user.Email.String(),
|
|
"display_name": user.DisplayName,
|
|
"status": user.Status,
|
|
"created_at": user.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func NewTraitsFromDeprecatedUser(user *DeprecatedUser) map[string]any {
|
|
return map[string]any{
|
|
"name": user.DisplayName,
|
|
"role": user.Role,
|
|
"email": user.Email.String(),
|
|
"display_name": user.DisplayName,
|
|
"status": user.Status,
|
|
"created_at": user.CreatedAt,
|
|
}
|
|
}
|
|
|
|
type UserStore interface {
|
|
// Creates a user.
|
|
CreateUser(ctx context.Context, user *User) error
|
|
|
|
// Get user by orgID and id.
|
|
GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*User, error)
|
|
|
|
// Get the org's live (not deleted) user holding an email — the row the
|
|
// partial unique index on (email, org_id) admits one of.
|
|
GetByOrgIDAndEmail(ctx context.Context, orgID valuer.UUID, email valuer.Email) (*User, error)
|
|
|
|
// Rekey moves the user (orgID, from) to the id `to`, together with every row
|
|
// that references users.id by foreign key. Run it inside RunInTx.
|
|
Rekey(ctx context.Context, orgID valuer.UUID, from valuer.UUID, to valuer.UUID) error
|
|
|
|
// List users by org.
|
|
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*User, error)
|
|
|
|
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
|
|
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)
|
|
|
|
// Get root user by org.
|
|
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*User, error)
|
|
|
|
// Get users having role by org id and role id
|
|
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*User, error)
|
|
|
|
// Transaction
|
|
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
|
}
|