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]>
151 lines
5.5 KiB
Go
151 lines
5.5 KiB
Go
package impluser
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/hanzoai/o11y/pkg/analytics"
|
|
"github.com/hanzoai/o11y/pkg/authz"
|
|
"github.com/hanzoai/o11y/pkg/factory"
|
|
root "github.com/hanzoai/o11y/pkg/modules/user"
|
|
"github.com/hanzoai/o11y/pkg/types"
|
|
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
|
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
)
|
|
|
|
// The setter writes ONE thing: the projection row for an IAM subject, with the
|
|
// roles it holds. Its only caller is identn/iamidentn.
|
|
//
|
|
// It used to also mint invites, hash and rotate passwords, issue reset tokens,
|
|
// email people links, promote pending invites, edit and delete members. Those
|
|
// are IAM's, and they are deleted rather than left unrouted — so it no longer
|
|
// needs a tokenizer to mint anything, an emailer to send anything, an org
|
|
// setter to bootstrap anything, or delete-cleanup callbacks for a delete it
|
|
// cannot perform.
|
|
type setter struct {
|
|
store types.UserStore
|
|
userRoleStore authtypes.UserRoleStore
|
|
settings factory.ScopedProviderSettings
|
|
authz authz.AuthZ
|
|
analytics analytics.Analytics
|
|
}
|
|
|
|
func NewSetter(store types.UserStore, providerSettings factory.ProviderSettings, authz authz.AuthZ, analytics analytics.Analytics, userRoleStore authtypes.UserRoleStore) root.Setter {
|
|
return &setter{
|
|
store: store,
|
|
userRoleStore: userRoleStore,
|
|
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/modules/user/impluser"),
|
|
authz: authz,
|
|
analytics: analytics,
|
|
}
|
|
}
|
|
|
|
// CreateUser writes the row and the role grant TOGETHER.
|
|
//
|
|
// The order matters and is the reason localauthz can rehydrate: the authz grant
|
|
// (a tuple, in-process for the local provider) and the user_role rows (durable)
|
|
// describe the same fact, and only the rows survive a restart. Writing both here
|
|
// is what makes the rows a complete record of the grant rather than half of it.
|
|
func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...root.CreateUserOption) error {
|
|
createUserOpts := root.NewCreateUserOptions(opts...)
|
|
|
|
// Grant is idempotent, so a retry after a partial failure is safe.
|
|
if len(createUserOpts.RoleNames) > 0 {
|
|
err := module.authz.Grant(
|
|
ctx,
|
|
user.OrgID,
|
|
createUserOpts.RoleNames,
|
|
authtypes.MustNewSubject(coretypes.NewResourceUser(), user.ID.StringValue(), user.OrgID, nil),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
|
if err := module.store.CreateUser(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(createUserOpts.RoleNames) > 0 {
|
|
return module.createUserRoleEntries(ctx, user.OrgID, user.ID, createUserOpts.RoleNames)
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
traitsOrProperties := types.NewTraitsFromUser(user)
|
|
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
|
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Created", traitsOrProperties)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RekeyUser re-seats a person whose row was written under an earlier key — the
|
|
// uuid5 of a name, from a guard that asserted names before it asserted the IAM
|
|
// subject — onto the subject, so the rows keyed to them follow.
|
|
//
|
|
// The rows move in one transaction. The grants follow the rows after it
|
|
// commits: the new subject gains the roles the moved user_role rows name and the
|
|
// old one loses them, which is the state localauthz would rehydrate on restart.
|
|
func (module *setter) RekeyUser(ctx context.Context, orgID valuer.UUID, email valuer.Email, userID valuer.UUID) error {
|
|
var from valuer.UUID
|
|
var roleNames []string
|
|
|
|
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
|
existing, err := module.store.GetByOrgIDAndEmail(ctx, orgID, email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if existing.ID == userID {
|
|
return nil
|
|
}
|
|
|
|
userRoles, err := module.userRoleStore.GetUserRolesByUserID(ctx, existing.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, userRole := range userRoles {
|
|
roleNames = append(roleNames, userRole.Role.Name)
|
|
}
|
|
|
|
from = existing.ID
|
|
return module.store.Rekey(ctx, orgID, existing.ID, userID)
|
|
})
|
|
if err != nil || from.IsZero() || len(roleNames) == 0 {
|
|
return err
|
|
}
|
|
|
|
if err := module.authz.Grant(ctx, orgID, roleNames, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.StringValue(), orgID, nil)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return module.authz.Revoke(ctx, orgID, roleNames, authtypes.MustNewSubject(coretypes.NewResourceUser(), from.StringValue(), orgID, nil))
|
|
}
|
|
|
|
func (module *setter) createUserRoleEntries(ctx context.Context, orgID, userID valuer.UUID, roleNames []string) error {
|
|
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, roleNames)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return module.userRoleStore.CreateUserRoles(ctx, authtypes.NewUserRoles(userID, roles))
|
|
}
|
|
|
|
func (module *setter) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
|
stats := make(map[string]any)
|
|
counts, err := module.store.CountByOrgIDAndStatuses(ctx, orgID, []string{types.UserStatusActive.StringValue(), types.UserStatusDeleted.StringValue(), types.UserStatusPendingInvite.StringValue()})
|
|
if err == nil {
|
|
stats["user.count"] = counts[types.UserStatusActive] + counts[types.UserStatusDeleted] + counts[types.UserStatusPendingInvite]
|
|
stats["user.count.active"] = counts[types.UserStatusActive]
|
|
stats["user.count.deleted"] = counts[types.UserStatusDeleted]
|
|
stats["user.count.pending_invite"] = counts[types.UserStatusPendingInvite]
|
|
}
|
|
|
|
return stats, nil
|
|
}
|