All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
278 lines
7.7 KiB
Go
278 lines
7.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"armature/models"
|
|
"armature/store"
|
|
)
|
|
|
|
// ── GroupStore ──────────────────────────────
|
|
|
|
type GroupStore struct{}
|
|
|
|
func NewGroupStore() *GroupStore { return &GroupStore{} }
|
|
|
|
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
|
g.ID = store.NewID()
|
|
now := time.Now().UTC()
|
|
g.CreatedAt = now
|
|
g.UpdatedAt = now
|
|
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)
|
|
}
|
|
_, err = DB.ExecContext(ctx, `
|
|
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, permissions, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
g.ID, g.Name, g.Description, g.Scope,
|
|
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, string(permsJSON),
|
|
now.Format(timeFmt), now.Format(timeFmt),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
|
|
var g models.Group
|
|
var teamID sql.NullString
|
|
var createdBy sql.NullString
|
|
var permsStr 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, '[]')
|
|
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,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g.TeamID = NullableStringPtr(teamID)
|
|
g.CreatedBy = NullableStringPtr(createdBy)
|
|
g.Permissions = unmarshalStringSlice(permsStr.String)
|
|
return &g, nil
|
|
}
|
|
|
|
// Update applies a patch to a group.
|
|
// 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 {
|
|
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", string(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 = ?", 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 = ?", 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, '[]')`
|
|
|
|
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 = ?
|
|
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 = ?)
|
|
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 (id, group_id, user_id, added_by)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (group_id, user_id) DO NOTHING`,
|
|
store.NewID(), 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 = ? AND user_id = ?",
|
|
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 = ?
|
|
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, st(&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 = ? AND user_id = ?)`,
|
|
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 = ?", 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 ────────────────────────────────
|
|
|
|
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 permsStr 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,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
g.TeamID = NullableStringPtr(teamID)
|
|
g.CreatedBy = NullableStringPtr(createdBy)
|
|
g.Permissions = unmarshalStringSlice(permsStr.String)
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func unmarshalStringSlice(s string) []string {
|
|
if s == "" {
|
|
return []string{}
|
|
}
|
|
var out []string
|
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
|
return []string{}
|
|
}
|
|
return out
|
|
}
|