This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/postgres/groups.go
Jeffrey Smith 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

281 lines
7.8 KiB
Go

package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"armature/models"
"armature/store"
)
// ── GroupStore ──────────────────────────────
type GroupStore struct{}
func NewGroupStore() *GroupStore { return &GroupStore{} }
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
if g.Source == "" {
g.Source = "manual"
}
if g.Permissions == nil {
g.Permissions = []string{}
}
permsJSON, err := json.Marshal(g.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
return DB.QueryRowContext(ctx, `
INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, permsJSON,
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
}
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
row := 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, '[]'::jsonb)
FROM groups g WHERE g.id = $1`, id)
return scanGroup(row)
}
// Update applies a patch to a group.
// 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 {
return err
}
b := NewUpdate("groups")
if source != "system" {
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
}
if patch.Permissions != nil {
permsJSON, err := json.Marshal(*patch.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
b.Set("permissions", permsJSON)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
res, err := b.Exec(DB)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// Delete removes a group. Returns an error if source=system.
func (s *GroupStore) Delete(ctx context.Context, id string) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return err
}
if source == "system" {
return store.ErrSystemGroup
}
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// ── Scoped Listing ──────────────────────────
const groupSelectCols = `
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, '[]'::jsonb)`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
ORDER BY g.scope, g.name`)
}
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
WHERE g.scope = 'team' AND g.team_id = $1
ORDER BY g.name`, teamID)
}
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
ORDER BY g.scope, g.name`, userID)
}
// ── Members ─────────────────────────────────
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO group_members (group_id, user_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, user_id) DO NOTHING`,
groupID, userID, addedBy)
return err
}
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
res, err := DB.ExecContext(ctx,
"DELETE FROM group_members WHERE group_id = $1 AND user_id = $2",
groupID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at,
u.username, u.email, COALESCE(u.display_name, '')
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY u.username`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.GroupMember
for rows.Next() {
var m models.GroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, &m.AddedAt,
&m.Username, &m.Email, &m.DisplayName); err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`,
groupID, userID).Scan(&exists)
return exists, err
}
func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT group_id FROM group_members WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Scanners ────────────────────────────────
type groupScanner interface {
Scan(dest ...any) error
}
func scanGroup(row groupScanner) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
var createdBy sql.NullString
var permsJSON []byte
if err := row.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
return &g, nil
}
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("queryGroups: %w", err)
}
defer rows.Close()
var result []models.Group
for rows.Next() {
var g models.Group
var teamID sql.NullString
var createdBy sql.NullString
var permsJSON []byte
if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
result = append(result, g)
}
return result, rows.Err()
}
func unmarshalStringSlice(data []byte) []string {
if len(data) == 0 {
return []string{}
}
var s []string
if err := json.Unmarshal(data, &s); err != nil {
return []string{}
}
return s
}