drop users.role column: full RBAC through group membership
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s

The role column was a pre-RBAC artifact. All authorization now flows
through explicit group membership and permission grants:

- Everyone group: all users added on creation (no implicit membership)
- Admins group: grants surface.admin.access + all platform permissions
- JWT claims, login response, profile: role field removed
- OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes)
- Admin UI: role dropdown removed, admin managed through groups
- Middleware cache simplified to isActive only

28 files changed, -79 lines net. Zero magic roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:23:12 +00:00
parent e8e45184f7
commit b10f5bee05
28 changed files with 206 additions and 285 deletions

View File

@@ -23,8 +23,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user'
CHECK (role IN ('user', 'admin')),
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin'

View File

@@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
is_active INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
auth_source TEXT NOT NULL DEFAULT 'builtin',

View File

@@ -375,33 +375,48 @@ func TruncateAll(t *testing.T) {
}
}
// SeedTestUser creates a test user and returns the user ID.
// SeedTestUser creates a test user, adds to Everyone group, and returns the user ID.
func SeedTestUser(t *testing.T, username, email string) string {
t.Helper()
handle := strings.ToLower(strings.ReplaceAll(username, " ", "-"))
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`
INSERT INTO users (id, username, email, password_hash, role, auth_source, handle)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?)
INSERT INTO users (id, username, email, password_hash, auth_source, handle)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', ?)
`, id, username, email, handle)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
SeedEveryoneGroupMember(t, id)
return id
}
var id string
err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, auth_source, handle)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3)
INSERT INTO users (username, email, password_hash, auth_source, handle)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', $3)
RETURNING id
`, username, email, handle).Scan(&id)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
SeedEveryoneGroupMember(t, id)
return id
}
// SeedEveryoneGroupMember adds a user to the Everyone system group in test databases.
func SeedEveryoneGroupMember(t *testing.T, userID string) {
t.Helper()
const everyoneGroupID = "00000000-0000-0000-0000-000000000001"
if IsSQLite() {
DB.Exec(`INSERT OR IGNORE INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
uuid.New().String(), everyoneGroupID, userID, userID)
return
}
DB.Exec(`INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
everyoneGroupID, userID, userID)
}
// SeedAdminsGroupMember adds a user to the Admins system group in test databases.
// Call after creating a user with role='admin' so they receive surface.admin.access
// through the normal permission resolution path.