Feat admin rbac migration (#1)
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / build-and-deploy (push) Has been cancelled
CI/CD / test-sqlite (push) Has been cancelled
CI/CD / test-go-pg (push) Has been cancelled

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-03-26 17:30:36 +00:00
committed by xcaliber
parent b9180d4184
commit 5836ddad21
37 changed files with 381 additions and 547 deletions

View File

@@ -93,9 +93,6 @@ type UserStore interface {
// ── CS2 additions (v0.29.0) ──
// CountByRole returns the number of users with a given role.
CountByRole(ctx context.Context, role string) (int, error)
// CountAll returns the total number of users.
CountAll(ctx context.Context) (int, error)

View File

@@ -43,17 +43,14 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
COALESCE(g.permissions, '[]'::jsonb)
FROM groups g WHERE g.id = $1`, id)
return scanGroup(row)
}
// Update applies a patch to a group.
// The Everyone group (source=system) allows permission/budget edits but not
// name, description, or structural changes — those fields are silently ignored.
// System groups (source=system) allow permission edits but not name,
// description, or structural changes — those fields are silently ignored.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
@@ -76,25 +73,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
}
b.Set("permissions", permsJSON)
}
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", modelsJSON)
}
if !b.HasSets() {
return nil
}
@@ -138,10 +116,7 @@ const groupSelectCols = `
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
COALESCE(g.permissions, '[]'::jsonb)`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `SELECT`+groupSelectCols+`
@@ -250,28 +225,17 @@ func scanGroup(row groupScanner) (*models.Group, error) {
var teamID sql.NullString
var createdBy sql.NullString
var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := row.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
&permsJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
return &g, nil
}
@@ -288,28 +252,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
var teamID sql.NullString
var createdBy sql.NullString
var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
&permsJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
result = append(result, g)
}
return result, rows.Err()

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at,
is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, is_active,
const userListCols = `id, username, email, display_name, avatar_url, is_active,
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
@@ -27,10 +27,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error {
u.AuthSource = "builtin"
}
return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO users (username, email, password_hash, display_name, is_active, settings, auth_source, external_id, handle)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at`,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
@@ -100,7 +100,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var extID, hdl sql.NullString
var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err
}
@@ -173,7 +173,7 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl,
)
if err != nil {
@@ -233,13 +233,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count)
return count, err
}
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, `
UPDATE users SET settings = (

View File

@@ -48,22 +48,17 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
var teamID sql.NullString
var createdBy sql.NullString
var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
COALESCE(g.permissions, '[]')
FROM groups g WHERE g.id = ?`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
&permsStr,
)
if err != nil {
return nil, err
@@ -71,20 +66,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
return &g, nil
}
// Update applies a patch to a group.
// system groups allow permission/budget edits but not structural field changes.
// System groups allow permission edits but not structural field changes.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
@@ -107,25 +93,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
}
b.Set("permissions", string(permsJSON))
}
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", string(modelsJSON))
}
if !b.HasSets() {
return nil
}
@@ -169,10 +136,7 @@ const groupSelectCols = `
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
COALESCE(g.permissions, '[]')`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `SELECT `+groupSelectCols+`
@@ -285,28 +249,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
var teamID sql.NullString
var createdBy sql.NullString
var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
&permsStr,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
result = append(result, g)
}
return result, rows.Err()

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at,
is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, is_active,
const userListCols = `id, username, email, display_name, avatar_url, is_active,
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
@@ -31,10 +31,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error {
u.AuthSource = "builtin"
}
_, err := DB.ExecContext(ctx, `
INSERT INTO users (id, username, email, password_hash, display_name, role, is_active,
INSERT INTO users (id, username, email, password_hash, display_name, is_active,
settings, created_at, updated_at, auth_source, external_id, handle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt),
u.AuthSource, u.ExternalID, u.Handle,
)
@@ -106,7 +106,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var extID, hdl sql.NullString
var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
&u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err
}
@@ -180,7 +180,7 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface
var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av,
&u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.AuthSource, &extID, &hdl,
)
if err != nil {
@@ -240,13 +240,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
return count, err
}
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, `
UPDATE users SET settings = json_patch(