A person seated under a name is re-keyed to their IAM subject

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]>
This commit is contained in:
zooqueen
2026-09-24 05:58:43 -07:00
co-authored by Hanzo Dev
parent e400efbbd3
commit 44c7574c28
7 changed files with 403 additions and 8 deletions
+16 -6
View File
@@ -75,9 +75,11 @@ type UserResolver interface {
}
// UserCreator writes an o11y user row and grants it roles — the Hanzo IAM grant
// AND the local user_role entries, in that one call. Satisfied by user.Setter.
// AND the local user_role entries, in that one call — and re-keys a row seated
// under an earlier key onto the subject. Satisfied by user.Setter.
type UserCreator interface {
CreateUser(context.Context, *types.User, ...user.CreateUserOption) error
RekeyUser(context.Context, valuer.UUID, valuer.Email, valuer.UUID) error
}
type provider struct {
@@ -189,8 +191,12 @@ func (p *provider) provision(ctx context.Context, orgID valuer.UUID, orgSlug str
// a subject but no address cannot be seated, and says so rather than seating a
// half-built identity that fails later, further away.
//
// A lost create race resolves to success — the row the winner wrote is the same
// row, because both derive its id from the same IAM subject.
// A create that finds the email already seated in the org re-keys that row onto
// the subject. The row is this person's either way: a concurrent request won the
// create (already keyed to the subject, so the re-key leaves it alone), or an
// earlier guard asserted a name and the row took uuid5 of it. Returning success
// on the second left GET /users/me and every row keyed to the person pointing at
// an id no session asserts any more.
func (p *provider) ensureUser(ctx context.Context, orgID, userID valuer.UUID, email valuer.Email) error {
if _, err := p.users.GetUserByOrgIDAndID(ctx, orgID, userID); err == nil {
return nil
@@ -208,10 +214,14 @@ func (p *provider) ensureUser(ctx context.Context, orgID, userID valuer.UUID, em
}
if err := p.userMaker.CreateUser(ctx, u, user.WithRoleNames([]string{authtypes.O11yAdminRoleName})); err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil
if !errors.Ast(err, errors.TypeAlreadyExists) {
return err
}
return err
if err := p.userMaker.RekeyUser(ctx, orgID, email, userID); err != nil {
return err
}
p.settings.Logger().InfoContext(ctx, "re-keyed o11y user to its Hanzo IAM subject", "org_id", orgID.String(), "user_id", userID.String())
return nil
}
p.settings.Logger().InfoContext(ctx, "seated Hanzo IAM user in o11y org", "org_id", orgID.String(), "user_id", userID.String())
+65 -2
View File
@@ -75,8 +75,9 @@ func (f *fakeAuthorizer) CreateManagedRoles(_ context.Context, _ valuer.UUID, _
// rows in the one call — so what this records IS the founding grant.
type fakeUserStore struct {
mu sync.Mutex
users map[string]*types.User // by "<orgID>/<userID>", the key GET /users/me reads
seats []seatCall
users map[string]*types.User // by "<orgID>/<userID>", the key GET /users/me reads
seats []seatCall
rekeys int
}
type seatCall struct {
@@ -101,6 +102,8 @@ func (f *fakeUserStore) GetUserByOrgIDAndID(_ context.Context, orgID, userID val
return nil, errors.NewNotFoundf(types.ErrCodeUserNotFound, "user not found")
}
// CreateUser refuses a second row for an (org, email) as well as for an
// (org, id), as the partial unique index on users(email, org_id) does.
func (f *fakeUserStore) CreateUser(_ context.Context, u *types.User, opts ...user.CreateUserOption) error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -108,11 +111,35 @@ func (f *fakeUserStore) CreateUser(_ context.Context, u *types.User, opts ...use
if _, ok := f.users[k]; ok {
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "already exists")
}
for _, existing := range f.users {
if existing.OrgID == u.OrgID && existing.Email == u.Email {
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "email already exists in org")
}
}
f.users[k] = u
f.seats = append(f.seats, seatCall{orgID: u.OrgID, userID: u.ID, email: u.Email.String(), roles: user.NewCreateUserOptions(opts...).RoleNames})
return nil
}
func (f *fakeUserStore) RekeyUser(_ context.Context, orgID valuer.UUID, email valuer.Email, userID valuer.UUID) error {
f.mu.Lock()
defer f.mu.Unlock()
for k, existing := range f.users {
if existing.OrgID != orgID || existing.Email != email {
continue
}
if existing.ID == userID {
return nil
}
delete(f.users, k)
existing.ID = userID
f.users[userKey(orgID, userID)] = existing
f.rekeys++
return nil
}
return errors.NewNotFoundf(types.ErrCodeUserNotFound, "user not found")
}
func newProvider(t *testing.T, store *fakeOrgStore, authorizer *fakeAuthorizer, users *fakeUserStore) *provider {
t.Helper()
p, err := New(instrumentationtest.New().ToProviderSettings(), store, store, authorizer, users, users)
@@ -341,3 +368,39 @@ func TestGetIdentity_ExistingSeatReused(t *testing.T) {
t.Fatalf("display name = %q, want the untouched %q", got, "Seeded Human")
}
}
// A person seated before the guard asserted the IAM subject has a row keyed by
// uuid5 of their NAME. The subject then misses that row, and the create it falls
// through to collides on the email. That collision must re-key the row onto the
// subject: answering it with success left the row under the old key, so every
// lookup by the subject the session now asserts came back user_not_found.
func TestGetIdentity_RekeysARowSeatedUnderAName(t *testing.T) {
store := newFakeOrgStore()
authorizer := &fakeAuthorizer{}
users := newFakeUserStore()
p := newProvider(t, store, authorizer, users)
orgID := toUUID("org", "hanzo")
byName := toUUID("user", "z")
seeded, err := types.NewUserWithID(byName, "z", valuer.MustNewEmail("[email protected]"), orgID, types.UserStatusActive)
if err != nil {
t.Fatalf("NewUserWithID: %v", err)
}
users.users[userKey(orgID, byName)] = seeded
const subject = "2d4d67ab-30f1-474e-b81f-f60461852259"
identity, err := p.GetIdentity(requestWithSession("hanzo", subject, "[email protected]"))
if err != nil {
t.Fatalf("GetIdentity: %v", err)
}
if _, err := users.GetUserByOrgIDAndID(context.Background(), orgID, identity.UserID); err != nil {
t.Fatalf("the row is not keyed by the asserted subject %s: %v", identity.UserID, err)
}
if _, err := users.GetUserByOrgIDAndID(context.Background(), orgID, byName); err == nil {
t.Fatalf("a row is still keyed by the name-derived id %s", byName)
}
if users.rekeys != 1 || len(users.seats) != 0 {
t.Fatalf("rekeys=%d seats=%d, want the one existing row re-keyed and none created", users.rekeys, len(users.seats))
}
}
+44
View File
@@ -83,6 +83,50 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
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 {
+185
View File
@@ -0,0 +1,185 @@
package impluser
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/o11y/pkg/analytics/analyticstest"
"github.com/hanzoai/o11y/pkg/authz"
"github.com/hanzoai/o11y/pkg/factory/factorytest"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/sqlstore/sqlitesqlstore"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// grantRecorder is the authz the setter moves grants through. Only Grant and
// Revoke are reached by RekeyUser; any other call panics on the nil interface.
type grantRecorder struct {
authz.AuthZ
granted map[string][]string
revoked map[string][]string
}
func (g *grantRecorder) Grant(_ context.Context, _ valuer.UUID, names []string, subject string) error {
g.granted[subject] = append(g.granted[subject], names...)
return nil
}
func (g *grantRecorder) Revoke(_ context.Context, _ valuer.UUID, names []string, subject string) error {
g.revoked[subject] = append(g.revoked[subject], names...)
return nil
}
// The schema the migrations leave these tables in: users unique on (email,
// org_id) among the live rows (076), and user_role (071) and user_preference
// (055) pointing at users.id with no ON UPDATE action — so a plain UPDATE of the
// key is refused while either child still points at it.
const rekeySchema = `
CREATE TABLE users (
id TEXT PRIMARY KEY, display_name TEXT NOT NULL, email TEXT NOT NULL, org_id TEXT NOT NULL,
is_root BOOLEAN NOT NULL DEFAULT false, status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL);
CREATE UNIQUE INDEX uq_users_email_org_id ON users (email, org_id) WHERE status != 'deleted';
CREATE TABLE role (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, type TEXT NOT NULL, org_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL);
CREATE TABLE user_role (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users (id), role_id TEXT NOT NULL REFERENCES role (id),
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL);
CREATE UNIQUE INDEX uq_user_role_user_id_role_id ON user_role (user_id, role_id);
CREATE TABLE user_preference (
id TEXT PRIMARY KEY, name TEXT NOT NULL, value TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users (id));
`
type rekeyFixture struct {
sql sqlstore.SQLStore
setter *setter
grants *grantRecorder
orgID valuer.UUID
email valuer.Email
byName valuer.UUID
subject valuer.UUID
}
func newRekeyFixture(t *testing.T) *rekeyFixture {
t.Helper()
ctx := context.Background()
sql, err := sqlitesqlstore.New(ctx, factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 1},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "o11y.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = sql.SQLDB().ExecContext(ctx, rekeySchema)
require.NoError(t, err)
f := &rekeyFixture{
sql: sql,
grants: &grantRecorder{granted: map[string][]string{}, revoked: map[string][]string{}},
orgID: valuer.GenerateUUID(),
email: valuer.MustNewEmail("[email protected]"),
byName: valuer.MustNewUUID("7b05517d-c5bc-553a-9250-b4831ebd8266"),
subject: valuer.MustNewUUID("2d4d67ab-30f1-474e-b81f-f60461852259"),
}
f.setter = NewSetter(NewStore(sql, factorytest.NewSettings()), factorytest.NewSettings(), f.grants, analyticstest.New(), NewUserRoleStore(sql, factorytest.NewSettings())).(*setter)
now := time.Now()
u, err := types.NewUserWithID(f.byName, "z", f.email, f.orgID, types.UserStatusActive)
require.NoError(t, err)
_, err = sql.BunDB().NewInsert().Model(u).Exec(ctx)
require.NoError(t, err)
role := &authtypes.Role{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
Name: authtypes.O11yAdminRoleName,
Type: valuer.NewString("managed"),
OrgID: f.orgID,
}
_, err = sql.BunDB().NewInsert().Model(role).Exec(ctx)
require.NoError(t, err)
_, err = sql.BunDB().NewInsert().Model(&authtypes.UserRole{
ID: valuer.GenerateUUID(), UserID: f.byName, RoleID: role.ID, CreatedAt: now, UpdatedAt: now,
}).Exec(ctx)
require.NoError(t, err)
_, err = sql.SQLDB().ExecContext(ctx, `INSERT INTO user_preference (id, name, value, user_id) VALUES (?, 'theme', 'dark', ?)`, valuer.GenerateUUID().StringValue(), f.byName.StringValue())
require.NoError(t, err)
return f
}
func (f *rekeyFixture) count(t *testing.T, query string, args ...any) int {
t.Helper()
var n int
require.NoError(t, f.sql.SQLDB().QueryRowContext(context.Background(), query, args...).Scan(&n))
return n
}
func subjectOf(orgID, userID valuer.UUID) string {
return authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.StringValue(), orgID, nil)
}
// The row, its user_role row and every other row keyed to the person move to the
// IAM subject together, and the role grant moves with them.
func TestRekeyUser_MovesTheRowAndEverythingKeyedToIt(t *testing.T) {
f := newRekeyFixture(t)
require.NoError(t, f.setter.RekeyUser(context.Background(), f.orgID, f.email, f.subject))
got, err := f.setter.store.GetByOrgIDAndID(context.Background(), f.orgID, f.subject)
require.NoError(t, err)
assert.Equal(t, f.email, got.Email)
for _, table := range []string{"users", "user_role", "user_preference"} {
column := "user_id"
if table == "users" {
column = "id"
}
assert.Equal(t, 0, f.count(t, `SELECT COUNT(*) FROM `+table+` WHERE `+column+` = ?`, f.byName.StringValue()), table+" still keyed by the old id")
assert.Equal(t, 1, f.count(t, `SELECT COUNT(*) FROM `+table+` WHERE `+column+` = ?`, f.subject.StringValue()), table+" not keyed by the subject")
}
assert.Equal(t, 0, f.count(t, `SELECT COUNT(*) FROM pragma_foreign_key_check`))
assert.Equal(t, []string{authtypes.O11yAdminRoleName}, f.grants.granted[subjectOf(f.orgID, f.subject)])
assert.Equal(t, []string{authtypes.O11yAdminRoleName}, f.grants.revoked[subjectOf(f.orgID, f.byName)])
}
// A row already keyed by the subject — a concurrent request won the create — is
// left as it is, and no grant moves.
func TestRekeyUser_RowAlreadyKeyedBySubjectIsLeftAlone(t *testing.T) {
f := newRekeyFixture(t)
require.NoError(t, f.setter.RekeyUser(context.Background(), f.orgID, f.email, f.byName))
assert.Equal(t, 1, f.count(t, `SELECT COUNT(*) FROM users WHERE id = ?`, f.byName.StringValue()))
assert.Empty(t, f.grants.granted)
assert.Empty(t, f.grants.revoked)
}
// One transaction: when the move cannot finish, nothing has moved.
func TestRekeyUser_IsOneTransaction(t *testing.T) {
f := newRekeyFixture(t)
other, err := types.NewUserWithID(f.subject, "someone else", valuer.MustNewEmail("[email protected]"), f.orgID, types.UserStatusActive)
require.NoError(t, err)
_, err = f.sql.BunDB().NewInsert().Model(other).Exec(context.Background())
require.NoError(t, err)
require.Error(t, f.setter.RekeyUser(context.Background(), f.orgID, f.email, f.subject))
assert.Equal(t, 1, f.count(t, `SELECT COUNT(*) FROM users WHERE id = ? AND email = ?`, f.byName.StringValue(), f.email.String()))
assert.Equal(t, 1, f.count(t, `SELECT COUNT(*) FROM user_role WHERE user_id = ?`, f.byName.StringValue()))
assert.Equal(t, 1, f.count(t, `SELECT COUNT(*) FROM user_preference WHERE user_id = ?`, f.byName.StringValue()))
assert.Empty(t, f.grants.granted)
}
+80
View File
@@ -3,6 +3,7 @@ package impluser
import (
"context"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/types"
@@ -50,6 +51,85 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
return user, nil
}
func (store *store) GetByOrgIDAndEmail(ctx context.Context, orgID valuer.UUID, email valuer.Email) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Where("org_id = ?", orgID).
Where("email = ?", email).
Where("status != ?", types.UserStatusDeleted.StringValue()).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s does not exist in org %s", email, orgID)
}
return user, nil
}
// Rekey renames a primary key that other tables point at. SQLite checks a
// foreign key at the end of every statement, and no single statement can move
// a parent and its children together, so the checks wait for COMMIT
// (defer_foreign_keys, which SQLite clears when the transaction ends). The
// children are found in the schema rather than listed here: a table that
// references users.id and is not moved leaves COMMIT with a dangling key, and
// the whole transaction is refused instead of committing half a person.
func (store *store) Rekey(ctx context.Context, orgID valuer.UUID, from valuer.UUID, to valuer.UUID) error {
db := store.sqlstore.BunDBCtx(ctx)
if _, err := db.ExecContext(ctx, "PRAGMA defer_foreign_keys = ON"); err != nil {
return err
}
result, err := db.
NewUpdate().
Model((*types.User)(nil)).
Set("id = ?", to).
Where("org_id = ?", orgID).
Where("id = ?", from).
Exec(ctx)
if err != nil {
return err
}
moved, err := result.RowsAffected()
if err != nil {
return err
}
if moved != 1 {
return errors.Newf(errors.TypeNotFound, types.ErrCodeUserNotFound, "user with id %s does not exist in org %s", from, orgID)
}
var references []struct {
Table string `bun:"table_name"`
Column string `bun:"column_name"`
}
err = db.
NewRaw(`SELECT m.name AS table_name, f."from" AS column_name
FROM sqlite_master AS m, pragma_foreign_key_list(m.name) AS f
WHERE m.type = 'table' AND f."table" = 'users' AND (f."to" = 'id' OR f."to" IS NULL)`).
Scan(ctx, &references)
if err != nil {
return err
}
for _, reference := range references {
if _, err := db.
NewUpdate().
Table(reference.Table).
Set("? = ?", bun.Ident(reference.Column), to).
Where("? = ?", bun.Ident(reference.Column), from).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
users := []*types.User{}
+5
View File
@@ -31,6 +31,11 @@ type Setter interface {
// is what localauthz rehydrates from after a restart.
CreateUser(ctx context.Context, user *types.User, opts ...CreateUserOption) error
// RekeyUser moves the org's row for an email — with its user_role rows and
// every other row keyed to it — onto userID, in one transaction, and moves
// its role grants with it. A row already keyed userID is left as it is.
RekeyUser(ctx context.Context, orgID valuer.UUID, email valuer.Email, userID valuer.UUID) error
statsreporter.StatsCollector
}
+8
View File
@@ -265,6 +265,14 @@ type UserStore interface {
// 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)