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]>
186 lines
7.5 KiB
Go
186 lines
7.5 KiB
Go
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)
|
|
}
|