mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user