Changeset 0.16.0 (#74)

This commit is contained in:
2026-02-27 02:38:35 +00:00
parent 1370d701af
commit 8bb77710b9
28 changed files with 3050 additions and 492 deletions

View File

@@ -0,0 +1,206 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── GroupStore ──────────────────────────────
type GroupStore struct{}
func NewGroupStore() *GroupStore { return &GroupStore{} }
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
return DB.QueryRowContext(ctx, `
INSERT INTO groups (name, description, scope, team_id, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), g.CreatedBy,
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
}
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group
var teamID 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
FROM groups g WHERE g.id = $1`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount,
)
if err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
return &g, nil
}
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
b := NewUpdate("groups")
if name != nil {
b.Set("name", *name)
}
if description != nil {
b.Set("description", *description)
}
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
}
func (s *GroupStore) Delete(ctx context.Context, id string) error {
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 ──────────────────────────
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(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)
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 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)
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 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)
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 ────────────────────────────────
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
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
result = append(result, g)
}
return result, rows.Err()
}

View File

@@ -68,7 +68,7 @@ func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
// ── Scoped Listing ──────────────────────────────
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
// User can see: global + their teams + personal.
// User can see: global + their teams + personal + group-granted.
q := `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
@@ -83,6 +83,23 @@ func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, tea
args = append(args, pq.Array(teamIDs))
}
// Group-granted KBs
q += fmt.Sprintf(`
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $%d
AND gm.group_id = ANY(rg.granted_groups)
))
)
)`, len(args)+1)
args = append(args, userID)
q += ` ORDER BY name`
return queryKBs(ctx, q, args...)
}

View File

@@ -100,7 +100,7 @@ func (s *PersonaStore) Delete(ctx context.Context, id string) error {
}
// ListForUser returns all Personas visible to a user:
// global active + team-scoped (for user's teams) + personal + shared.
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
@@ -110,6 +110,19 @@ func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
)
) ORDER BY scope, name`, personaCols), userID)
if err != nil {
return nil, err
@@ -235,6 +248,20 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND rg.resource_id = $2
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
)
)
)`, userID, personaID).Scan(&exists)
return exists, err

View File

@@ -0,0 +1,95 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"github.com/lib/pq"
)
// ── ResourceGrantStore ──────────────────────
type ResourceGrantStore struct{}
func NewResourceGrantStore() *ResourceGrantStore { return &ResourceGrantStore{} }
// Set creates or replaces the grant for a resource (upsert on unique resource_type+resource_id).
func (s *ResourceGrantStore) Set(ctx context.Context, grant *models.ResourceGrant) error {
// Coalesce nil to empty slice — pq.Array(nil) produces SQL NULL, but column expects array.
groups := grant.GrantedGroups
if groups == nil {
groups = []string{}
}
return DB.QueryRowContext(ctx, `
INSERT INTO resource_grants (resource_type, resource_id, grant_scope, granted_groups, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (resource_type, resource_id) DO UPDATE SET
grant_scope = EXCLUDED.grant_scope,
granted_groups = EXCLUDED.granted_groups,
created_by = EXCLUDED.created_by
RETURNING id, created_at, updated_at`,
grant.ResourceType, grant.ResourceID, grant.GrantScope,
pq.Array(groups), grant.CreatedBy,
).Scan(&grant.ID, &grant.CreatedAt, &grant.UpdatedAt)
}
// Get retrieves the grant for a specific resource.
func (s *ResourceGrantStore) Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) {
var rg models.ResourceGrant
err := DB.QueryRowContext(ctx, `
SELECT id, resource_type, resource_id, grant_scope, granted_groups, created_by,
created_at, updated_at
FROM resource_grants
WHERE resource_type = $1 AND resource_id = $2`,
resourceType, resourceID,
).Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID, &rg.GrantScope,
pq.Array(&rg.GrantedGroups), &rg.CreatedBy,
&rg.CreatedAt, &rg.UpdatedAt)
if err != nil {
return nil, err
}
return &rg, nil
}
// Delete removes the grant for a resource.
func (s *ResourceGrantStore) Delete(ctx context.Context, resourceType, resourceID string) error {
res, err := DB.ExecContext(ctx,
"DELETE FROM resource_grants WHERE resource_type = $1 AND resource_id = $2",
resourceType, resourceID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// UserHasGroupAccess checks whether a user has group-based access to a resource.
// Returns true if:
// - The resource has grant_scope='global', OR
// - The resource has grant_scope='groups' and the user is in at least one
// of the granted_groups.
func (s *ResourceGrantStore) UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) {
var has bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM resource_grants rg
WHERE rg.resource_type = $1
AND rg.resource_id = $2
AND (
rg.grant_scope = 'global'
OR (
rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $3
AND gm.group_id = ANY(rg.granted_groups)
)
)
)
)`, resourceType, resourceID, userID).Scan(&has)
return has, err
}

View File

@@ -28,5 +28,7 @@ func NewStores(db *sql.DB) store.Stores {
Extensions: NewExtensionStore(),
Attachments: NewAttachmentStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
}
}