diff --git a/docs/DESIGN-0.16.0.md b/docs/DESIGN-0.16.0.md new file mode 100644 index 0000000..1be0703 --- /dev/null +++ b/docs/DESIGN-0.16.0.md @@ -0,0 +1,795 @@ +# DESIGN-0.16.0 — User Groups + Resource Grants + +## Overview + +The missing access-control primitive. Teams define organizational structure +(horizontal visibility). Roles define vertical permissions (admin/user). +**Groups** are pure access-control lists that decouple resource access from +team membership. + +Today, resource visibility follows a rigid scope model: `global` (everyone), +`team` (team members), `personal` (owner only). This breaks down when: +- A KB should be visible to members from two different teams. +- A Persona should be accessible by a cross-functional working group. +- A future Project needs participants from multiple teams. + +Groups solve this by adding a fourth access path — grant-by-group — without +changing the existing scope model. Existing `global/team/personal` access +continues to work unchanged. Groups are additive. + +Depends on: teams (v0.10.0), knowledge bases (v0.14.0). + +**Design principle: don't break the scope model.** Groups extend it. Every +existing query that filters by `scope + owner_id + team_id` remains valid. +Group membership is a parallel access path checked alongside scope, not a +replacement for it. + +--- + +## 1. Schema + +Migration: `010_groups.sql` + +### 1.1 Groups Table + +```sql +-- ========================================= +-- USER GROUPS +-- ========================================= + +CREATE TABLE IF NOT EXISTS groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + scope VARCHAR(20) NOT NULL DEFAULT 'global' + CHECK (scope IN ('global', 'team')), + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Global groups: team_id must be NULL + -- Team groups: team_id must be set + CONSTRAINT groups_scope_team CHECK ( + (scope = 'global' AND team_id IS NULL) OR + (scope = 'team' AND team_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope + ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000')); + -- Unique name within scope (global names unique globally, + -- team names unique within team) + +COMMENT ON TABLE groups IS + 'Access-control groups. Decouple resource visibility from team membership.'; +COMMENT ON COLUMN groups.scope IS + 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.'; +``` + +### 1.2 Group Members Table + +```sql +CREATE TABLE IF NOT EXISTS group_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_by UUID NOT NULL REFERENCES users(id), + added_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(group_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); +CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); +``` + +### 1.3 Resource Grants Table + +```sql +-- ========================================= +-- RESOURCE GRANTS +-- ========================================= + +CREATE TABLE IF NOT EXISTS resource_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_type VARCHAR(30) NOT NULL + CHECK (resource_type IN ('persona', 'knowledge_base')), + resource_id UUID NOT NULL, + grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only' + CHECK (grant_scope IN ('team_only', 'global', 'groups')), + granted_groups UUID[] NOT NULL DEFAULT '{}', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Only one grant row per resource + UNIQUE(resource_type, resource_id) +); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_resource + ON resource_grants(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_resource_grants_groups + ON resource_grants USING gin(granted_groups); + -- GIN index for UUID[] containment queries (&&, @>) + +COMMENT ON TABLE resource_grants IS + 'Controls cross-team access to resources (personas, KBs, future: projects).'; +COMMENT ON COLUMN resource_grants.grant_scope IS + 'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.'; +COMMENT ON COLUMN resource_grants.granted_groups IS + 'UUID array of group IDs. Only meaningful when grant_scope = groups.'; +``` + +### 1.4 Triggers + +```sql +-- Auto-update updated_at +CREATE TRIGGER groups_updated_at + BEFORE UPDATE ON groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER resource_grants_updated_at + BEFORE UPDATE ON resource_grants + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +``` + +### Design Decision: UUID[] vs Junction Table + +The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is +the right call for this use case: + +- A resource has exactly one grant row (UNIQUE constraint). +- The group list is always read/written as a unit (replace-all semantics). +- Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for + "does this resource grant overlap with the user's groups?" queries. +- Avoids a many-to-many junction table that would complicate the common + path (checking access) for marginal normalization benefit. + +A junction table (`resource_grant_groups`) would be warranted if we needed +per-group config on grants (e.g., read-only vs read-write per group). We +don't — the grant is binary (access or no access). If that changes, we +can migrate the array to a junction table later. + +--- + +## 2. Models + +### 2.1 Group Models + +```go +// ── Group Constants ──────────────────────── + +const ( + GroupScopeGlobal = "global" + GroupScopeTeam = "team" +) + +// ── Groups ───────────────────────────────── + +type Group struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Scope string `json:"scope" db:"scope"` + TeamID *string `json:"team_id,omitempty" db:"team_id"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + + // Computed fields (not stored in groups table) + MemberCount int `json:"member_count,omitempty"` + TeamName string `json:"team_name,omitempty"` +} + +type GroupPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +type GroupMember struct { + ID string `json:"id" db:"id"` + GroupID string `json:"group_id" db:"group_id"` + UserID string `json:"user_id" db:"user_id"` + AddedBy string `json:"added_by" db:"added_by"` + AddedAt string `json:"added_at" db:"added_at"` + // Joined from users table + Username string `json:"username,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` +} +``` + +### 2.2 Resource Grant Models + +```go +// ── Resource Grant Constants ─────────────── + +const ( + ResourceTypePersona = "persona" + ResourceTypeKnowledgeBase = "knowledge_base" + // Future: "project" + + GrantScopeTeamOnly = "team_only" + GrantScopeGlobal = "global" + GrantScopeGroups = "groups" +) + +// ResourceGrant controls cross-team access to a resource. +type ResourceGrant struct { + ID string `json:"id" db:"id"` + ResourceType string `json:"resource_type" db:"resource_type"` + ResourceID string `json:"resource_id" db:"resource_id"` + GrantScope string `json:"grant_scope" db:"grant_scope"` + GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID[] + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} +``` + +--- + +## 3. Store Interfaces + +### 3.1 GroupStore + +```go +// ========================================= +// GROUP STORE +// ========================================= + +type GroupStore interface { + // CRUD + Create(ctx context.Context, g *models.Group) error + GetByID(ctx context.Context, id string) (*models.Group, error) + Update(ctx context.Context, id string, patch models.GroupPatch) error + Delete(ctx context.Context, id string) error + + // Listing + ListAll(ctx context.Context) ([]models.Group, error) // admin view + ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) + ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I'm in + + // Members + AddMember(ctx context.Context, groupID, userID, addedBy string) error + RemoveMember(ctx context.Context, groupID, userID string) error + ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) + IsMember(ctx context.Context, groupID, userID string) (bool, error) + + // Bulk lookups (for access resolution) + GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) +} +``` + +### 3.2 ResourceGrantStore + +```go +// ========================================= +// RESOURCE GRANT STORE +// ========================================= + +type ResourceGrantStore interface { + // Upsert: one grant row per resource (replace semantics) + Set(ctx context.Context, grant *models.ResourceGrant) error + Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) + Delete(ctx context.Context, resourceType, resourceID string) error + + // Access check: does user have group-based access to this resource? + UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) +} +``` + +### 3.3 Wire into Stores Bundle + +```go +type Stores struct { + // ... existing fields ... + Groups GroupStore + ResourceGrants ResourceGrantStore +} +``` + +--- + +## 4. Access Resolution + +### 4.1 The Problem + +Today, access checks look like this (Persona example): + +```sql +-- PersonaStore.ListForUser +WHERE scope = 'global' + OR (scope = 'personal' AND created_by = $1) + OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $1)) + OR (scope = 'personal' AND is_shared = true) +``` + +KB access follows the same pattern in `KnowledgeBaseStore.ListForUser` and +the handler's `userCanAccess()` method. + +Groups add a fourth access path: "I'm in a group that has been granted +access to this resource." + +### 4.2 Design: Parallel Check, Not Query Rewrite + +We could embed the group check into every `ListForUser` query. That would +mean every listing query joins against `resource_grants` and `group_members`, +adding complexity to every scope-aware query in the system. + +Instead: **keep existing scope queries as-is, add group-granted resources +as a separate UNION.** This is cleaner, backward-compatible, and doesn't +slow down the common path for users without group-based access. + +### 4.3 Updated Persona Listing + +```go +// PersonaStore.ListForUser — updated +func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) { + rows, err := DB.QueryContext(ctx, fmt.Sprintf(` + -- Existing scope-based access (unchanged) + SELECT %s FROM personas WHERE is_active = true AND ( + scope = 'global' + OR (scope = 'personal' AND created_by = $1) + OR (scope = 'team' AND owner_id IN ( + SELECT team_id FROM team_members WHERE user_id = $1 + )) + OR (scope = 'personal' AND is_shared = true) + ) + + UNION + + -- Group-based access (new) + SELECT %s FROM personas p + WHERE p.is_active = true + AND p.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 rg.granted_groups && ( + SELECT ARRAY_AGG(gm.group_id) FROM group_members gm + WHERE gm.user_id = $1 + )::uuid[]) + ) + ) + + ORDER BY scope, name + `, personaCols, personaCols), userID) + // ... +} +``` + +The `&&` operator is the Postgres array overlap operator — returns true if +the two arrays share any element. The GIN index on `granted_groups` makes +this efficient. + +### 4.4 Updated KB Listing + +Same pattern applied to `KnowledgeBaseStore.ListForUser`: + +```go +func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) { + // ... existing scope-based query ... + + // Add UNION for group-granted KBs + q += ` + UNION + SELECT id, name, description, scope, owner_id, team_id, embedding_config, + document_count, chunk_count, total_bytes, status, created_at, updated_at + FROM knowledge_bases kb + WHERE kb.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 rg.granted_groups && ( + SELECT ARRAY_AGG(gm.group_id) FROM group_members gm + WHERE gm.user_id = $1 + )::uuid[]) + ) + )` + + q += ` ORDER BY name` + return queryKBs(ctx, q, args...) +} +``` + +### 4.5 Updated UserCanAccess + +The KB handler's `userCanAccess()` method also needs updating: + +```go +func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool { + // Existing scope-based check (unchanged) + switch kb.Scope { + case "global": + return true + case "personal": + if kb.OwnerID != nil && *kb.OwnerID == userID { + return true + } + case "team": + if kb.TeamID != nil { + for _, tid := range teamIDs { + if tid == *kb.TeamID { + return true + } + } + } + } + + // New: check group-based grants + hasAccess, _ := h.stores.ResourceGrants.UserHasGroupAccess( + context.Background(), userID, models.ResourceTypeKnowledgeBase, kb.ID) + return hasAccess +} +``` + +The `PersonaStore.UserCanAccess` method gets the same treatment. + +### 4.6 GetActiveKBIDs (Channel KB Access) + +The `GetActiveKBIDs` query (used by the completion handler to determine which +KBs to search) also needs the group path: + +```sql +-- Add to the WHERE clause: +OR kb.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 rg.granted_groups && ( + SELECT ARRAY_AGG(gm.group_id) FROM group_members gm + WHERE gm.user_id = $2 + )::uuid[]) + ) +) +``` + +### 4.7 Performance Note: User Group IDs Cache + +The `ARRAY_AGG(gm.group_id)` subquery appears in every access check. For +hot paths (listing, access checks), we can pre-fetch the user's group IDs +once per request and pass them through: + +```go +// In middleware or handler setup: +groupIDs, _ := stores.Groups.GetUserGroupIDs(ctx, userID) + +// Then in queries, pass as a parameter instead of subquery: +// ... AND rg.granted_groups && $N::uuid[] +``` + +This trades a subquery for a pre-fetched array parameter. Worth doing in +phase 2 if access checks show up in profiling. For v0.16.0, the subquery +approach is simpler and correct. + +--- + +## 5. Handlers + +### 5.1 GroupHandler + +```go +type GroupHandler struct { + stores store.Stores +} + +func NewGroupHandler(s store.Stores) *GroupHandler { + return &GroupHandler{stores: s} +} +``` + +**Admin endpoints (global groups):** + +| Method | Path | Handler | Auth | +|--------|------|---------|------| +| GET | `/admin/groups` | ListAllGroups | admin | +| POST | `/admin/groups` | CreateGlobalGroup | admin | +| PUT | `/admin/groups/:id` | UpdateGroup | admin | +| DELETE | `/admin/groups/:id` | DeleteGroup | admin | +| GET | `/admin/groups/:id/members` | ListGroupMembers | admin | +| POST | `/admin/groups/:id/members` | AddGroupMember | admin | +| DELETE | `/admin/groups/:id/members/:userId` | RemoveGroupMember | admin | + +**Team endpoints (team-scoped groups):** + +| Method | Path | Handler | Auth | +|--------|------|---------|------| +| GET | `/teams/:teamId/groups` | ListTeamGroups | team member | +| POST | `/teams/:teamId/groups` | CreateTeamGroup | team admin | +| PUT | `/teams/:teamId/groups/:id` | UpdateTeamGroup | team admin | +| DELETE | `/teams/:teamId/groups/:id` | DeleteTeamGroup | team admin | +| GET | `/teams/:teamId/groups/:id/members` | ListTeamGroupMembers | team member | +| POST | `/teams/:teamId/groups/:id/members` | AddTeamGroupMember | team admin | +| DELETE | `/teams/:teamId/groups/:id/members/:userId` | RemoveTeamGroupMember | team admin | + +**User endpoint:** + +| Method | Path | Handler | Auth | +|--------|------|---------|------| +| GET | `/groups/mine` | MyGroups | authenticated | + +### 5.2 Resource Grant Endpoints + +Grant management is inline on the resource endpoints, not a separate handler. + +**On Personas:** + +| Method | Path | Handler | Auth | +|--------|------|---------|------| +| GET | `/presets/:id/grants` | GetPersonaGrants | owner or admin | +| PUT | `/presets/:id/grants` | SetPersonaGrants | owner or admin | + +**On Knowledge Bases:** + +| Method | Path | Handler | Auth | +|--------|------|---------|------| +| GET | `/knowledge-bases/:id/grants` | GetKBGrants | owner or admin | +| PUT | `/knowledge-bases/:id/grants` | SetKBGrants | owner or admin | + +**Request/response format:** + +```json +// PUT /api/v1/presets/:id/grants +{ + "grant_scope": "groups", + "granted_groups": ["uuid-1", "uuid-2"] +} + +// GET /api/v1/presets/:id/grants +{ + "grant_scope": "groups", + "granted_groups": [ + { "id": "uuid-1", "name": "Engineering" }, + { "id": "uuid-2", "name": "Design" } + ] +} +``` + +The GET response enriches group UUIDs with names for display. The PUT +accepts bare UUIDs. + +--- + +## 6. Routes (main.go) + +```go +// ── Groups ────────────────────────────── + +// User: my groups +protected.GET("/groups/mine", groupH.MyGroups) + +// Team-scoped groups +teamScoped.GET("/groups", groupH.ListTeamGroups) +teamScoped.POST("/groups", groupH.CreateTeamGroup) // RequireTeamAdmin +teamScoped.PUT("/groups/:groupId", groupH.UpdateTeamGroup) // RequireTeamAdmin +teamScoped.DELETE("/groups/:groupId", groupH.DeleteTeamGroup) // RequireTeamAdmin +teamScoped.GET("/groups/:groupId/members", groupH.ListTeamGroupMembers) +teamScoped.POST("/groups/:groupId/members", groupH.AddTeamGroupMember) // RequireTeamAdmin +teamScoped.DELETE("/groups/:groupId/members/:userId", groupH.RemoveTeamGroupMember) // RequireTeamAdmin + +// Admin: global groups +admin.GET("/groups", groupH.ListAllGroups) +admin.POST("/groups", groupH.CreateGlobalGroup) +admin.PUT("/groups/:id", groupH.UpdateGroup) +admin.DELETE("/groups/:id", groupH.DeleteGroup) +admin.GET("/groups/:id/members", groupH.ListGroupMembers) +admin.POST("/groups/:id/members", groupH.AddGroupMember) +admin.DELETE("/groups/:id/members/:userId", groupH.RemoveGroupMember) + +// ── Resource Grants ───────────────────── + +// Persona grants (add to existing preset routes) +protected.GET("/presets/:id/grants", personaH.GetGrants) +protected.PUT("/presets/:id/grants", personaH.SetGrants) + +// KB grants (add to existing KB routes) +protected.GET("/knowledge-bases/:id/grants", kbH.GetGrants) +protected.PUT("/knowledge-bases/:id/grants", kbH.SetGrants) +``` + +--- + +## 7. Audit Logging + +All group and grant operations log to the existing audit system: + +| Action | Resource Type | Details | +|--------|--------------|---------| +| `group.create` | `group` | name, scope, team_id | +| `group.update` | `group` | changed fields | +| `group.delete` | `group` | name | +| `group.member.add` | `group` | user_id | +| `group.member.remove` | `group` | user_id | +| `resource_grant.set` | `persona` / `knowledge_base` | grant_scope, group count | +| `resource_grant.delete` | `persona` / `knowledge_base` | — | + +Uses the existing `AuditStore.Log` method — no new infrastructure. + +--- + +## 8. Frontend + +### 8.1 Admin Panel: Groups Section + +New tab in the admin panel (alongside Users, Teams, Models, Settings, etc.): + +**Groups list view:** +- Table: Name, Scope, Team (if team-scoped), Members count, Actions +- "Create Group" button → modal with name, description, scope selector +- Click row → member management (add/remove users, search by username) + +**Team admin panel:** +- New "Groups" tab under team settings +- Same list/CRUD pattern, scoped to team + +### 8.2 Grant Picker Component + +Reusable component for Persona and KB create/edit forms: + +``` +┌─ Access ──────────────────────────────────┐ +│ ○ Team Only (default) │ +│ ○ All Users │ +│ ○ Specific Groups │ +│ ┌────────────────────────────────────┐ │ +│ │ [x] Engineering │ │ +│ │ [x] Design │ │ +│ │ [ ] Marketing │ │ +│ │ [ ] Leadership │ │ +│ └────────────────────────────────────┘ │ +└────────────────────────────────────────────┘ +``` + +- Only shown for team-scoped and global-scoped resources (personal resources + are always owner-only). +- "Team Only" = existing behavior (no `resource_grants` row, or row with + `grant_scope = 'team_only'`). +- "All Users" = `grant_scope = 'global'`. +- "Specific Groups" = `grant_scope = 'groups'` + multi-select group picker. +- Group list loaded from `/api/v1/groups/mine` (user's groups) for team + admins, `/api/v1/admin/groups` for system admins. + +### 8.3 User's Group Badge + +The model selector and KB list already show scope badges (`global`, `team`, +`personal`). For group-granted resources, show a "group" badge to distinguish +from team-native access. + +--- + +## 9. Backward Compatibility + +### 9.1 No Breaking Changes + +- All existing scope-based queries remain valid. +- Resources without a `resource_grants` row behave exactly as before. +- The `resource_grants` table is opt-in: only populated when an admin or + team admin explicitly sets grants on a resource. +- `persona_grants` (tool/KB/API grants ON a persona) is unrelated to + `resource_grants` (access grants TO a persona). Different concepts, + different tables. No rename needed. + +### 9.2 Migration Safety + +- New tables only (no ALTER on existing tables). +- No data migration required. +- Rollback: drop the three new tables. + +### 9.3 Naming Clarity + +To avoid confusion between the existing `persona_grants` table (which +controls what tools/KBs a Persona has access TO) and the new +`resource_grants` table (which controls which users have access TO the +Persona): + +| Table | Purpose | Example | +|-------|---------|---------| +| `persona_grants` | What a Persona can do (tools, KBs, APIs) | "CodeBot can use calculator" | +| `resource_grants` | Who can use a resource (users via groups) | "Engineering group can use CodeBot" | + +The naming is intentionally distinct. No rename of `persona_grants` is needed. + +--- + +## 10. Implementation Phases + +### Phase 1: Groups Entity (backend) +- [ ] Migration `010_groups.sql` +- [ ] Models: `Group`, `GroupPatch`, `GroupMember` +- [ ] Store: `GroupStore` interface + Postgres implementation +- [ ] Handler: `GroupHandler` with admin + team admin + user endpoints +- [ ] Routes: wire into `main.go` +- [ ] Audit logging for group operations +- [ ] Integration tests: CRUD, membership, scoping + +### Phase 2: Resource Grants (backend) +- [ ] Models: `ResourceGrant` + constants +- [ ] Store: `ResourceGrantStore` interface + Postgres implementation +- [ ] Grant endpoints on PersonaHandler and KBHandler +- [ ] Update `PersonaStore.ListForUser` with UNION for group access +- [ ] Update `PersonaStore.UserCanAccess` with group check +- [ ] Update `KnowledgeBaseStore.ListForUser` with UNION for group access +- [ ] Update `KBHandler.userCanAccess` with group check +- [ ] Update `KnowledgeBaseStore.GetActiveKBIDs` with group path +- [ ] Audit logging for grant operations +- [ ] Integration tests: grant CRUD, access resolution, cross-team visibility + +### Phase 3: Frontend +- [ ] Admin panel: Groups section (CRUD, member management) +- [ ] Team admin panel: Groups tab +- [ ] Grant picker component (shared between Persona and KB forms) +- [ ] `/groups/mine` endpoint for user's group list +- [ ] "Group" scope badge on model selector / KB list +- [ ] Update Persona create/edit form with grant picker +- [ ] Update KB create/edit form with grant picker + +--- + +## 11. Testing Plan + +### Integration Tests (extend `handlers/integration_test.go`) + +**Group lifecycle:** +1. Admin creates global group → verify in list +2. Admin adds users to group → verify membership +3. Team admin creates team group → verify scoping (not visible to other teams) +4. Remove member → verify no longer in group +5. Delete group → verify CASCADE cleans up members + +**Resource grants:** +1. Team admin creates a KB with `grant_scope = 'groups'`, grants to a group +2. User in the group can see the KB in `ListForUser` → verify access +3. User NOT in the group cannot see the KB → verify denied +4. User in group can search the KB via `kb_search` tool → verify access +5. Remove user from group → verify KB no longer visible +6. Change grant from `groups` to `global` → verify all users can access +7. Delete grant → verify KB reverts to team-only visibility + +**Backward compatibility:** +1. Resources with no `resource_grants` row → same behavior as before +2. Existing scope queries → unchanged results +3. Personal resources → never grant-accessible (grant picker hidden) + +### Edge Cases +- User belongs to 0 groups → `ARRAY_AGG` returns NULL → no match (safe) +- Group with 0 members → grant exists but no one matches (correct) +- Delete a group that's referenced in `granted_groups` → array still contains + the UUID but no `group_members` rows match → effectively revoked (correct). + Cleanup: periodic job or ON DELETE trigger to scrub stale UUIDs from arrays. + For v0.16.0, stale UUIDs are harmless (they just don't match anything). + +--- + +## 12. Future Considerations + +### v0.17.0 — Persona-KB Binding +Resource grants enable the access control layer for Persona-KB binding. +When a Persona has bound KBs, the Persona's grants implicitly grant search +access to those KBs. The `resource_grants` table already supports this — +the Persona grant controls who can use the Persona, and Persona-KB binding +controls which KBs are searchable through it. + +### v0.19.0 — Projects +Projects will use the same `resource_grants` pattern: +`resource_type = 'project'`. No schema changes needed. + +### v0.24.0 — Full RBAC +OIDC claim → group mapping will auto-sync external IdP groups into the +`groups` table. The infrastructure built here becomes the foundation for +enterprise RBAC. + +### Stale Group UUID Cleanup +When a group is deleted, its UUID may linger in `resource_grants.granted_groups` +arrays. This is harmless (dead UUIDs never match any `group_members` rows) +but untidy. Options for a future cleanup: +- Trigger on `groups` DELETE that scrubs the UUID from all `granted_groups` arrays. +- Periodic background job. +- Cleanup on next grant SET (when the admin edits the grant). + +For v0.16.0, we'll add a note in the delete handler response if the group +is referenced in any grants, warning the admin. diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index cc329ab..a1b4efe 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -1,10 +1,11 @@ #!/bin/bash # ============================================ -# Chat Switchboard - Schema Validation (v0.9) +# Chat Switchboard - Schema Validation (v0.16) # ============================================ # Verifies the database schema is correct after # migration. Checks expected tables, key columns, -# and constraints. Exits non-zero on any failure. +# indexes, triggers, and confirms dropped artifacts. +# Exits non-zero on any failure. # # Required env vars: # PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE @@ -20,10 +21,12 @@ echo "Schema validation: ${PGDATABASE}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ERRORS=0 +CHECKS=0 # ── Helper: check table exists ─────────────── check_table() { local table="$1" + CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';") if [[ "${exists}" == "1" ]]; then @@ -34,10 +37,25 @@ check_table() { fi } +# ── Helper: check table does NOT exist ─────── +check_table_absent() { + local table="$1" + CHECKS=$((CHECKS + 1)) + local exists + exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';") + if [[ "${exists}" == "1" ]]; then + echo " ✗ UNEXPECTED table: ${table} (should have been dropped)" + ERRORS=$((ERRORS + 1)) + else + echo " ✓ absent: ${table}" + fi +} + # ── Helper: check column exists ────────────── check_column() { local table="$1" local column="$2" + CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") if [[ "${exists}" == "1" ]]; then @@ -48,9 +66,70 @@ check_column() { fi } +# ── Helper: check column does NOT exist ────── +check_column_absent() { + local table="$1" + local column="$2" + CHECKS=$((CHECKS + 1)) + local exists + exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") + if [[ "${exists}" == "1" ]]; then + echo " ✗ UNEXPECTED column: ${table}.${column} (should have been dropped)" + ERRORS=$((ERRORS + 1)) + else + echo " ✓ absent: ${table}.${column}" + fi +} + +# ── Helper: check column type ──────────────── +check_column_type() { + local table="$1" + local column="$2" + local expected_type="$3" + CHECKS=$((CHECKS + 1)) + local actual_type + actual_type=$(psql -tAc "SELECT data_type FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") + if [[ "${actual_type}" == "${expected_type}" ]]; then + echo " ✓ type: ${table}.${column} = ${expected_type}" + else + echo " ✗ WRONG type: ${table}.${column} = ${actual_type} (expected ${expected_type})" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── Helper: check index exists ─────────────── +check_index() { + local index="$1" + CHECKS=$((CHECKS + 1)) + local exists + exists=$(psql -tAc "SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='${index}';") + if [[ "${exists}" == "1" ]]; then + echo " ✓ index: ${index}" + else + echo " ✗ MISSING index: ${index}" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── Helper: check trigger exists ───────────── +check_trigger() { + local trigger="$1" + local table="$2" + CHECKS=$((CHECKS + 1)) + local exists + exists=$(psql -tAc "SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid WHERE t.tgname='${trigger}' AND c.relname='${table}' AND NOT t.tgisinternal;") + if [[ "${exists}" == "1" ]]; then + echo " ✓ trigger: ${trigger} on ${table}" + else + echo " ✗ MISSING trigger: ${trigger} on ${table}" + ERRORS=$((ERRORS + 1)) + fi +} + # ── Helper: check extension ────────────────── check_extension() { local ext="$1" + CHECKS=$((CHECKS + 1)) local exists exists=$(psql -tAc "SELECT 1 FROM pg_extension WHERE extname='${ext}';") if [[ "${exists}" == "1" ]]; then @@ -61,12 +140,17 @@ check_extension() { fi } -# ── 1. Extensions ──────────────────────────── -echo "" -echo "Extensions:" -check_extension "pgcrypto" +# ═══════════════════════════════════════════ +# CHECKS +# ═══════════════════════════════════════════ -# ── 2. Migration tracking ─────────────────── +# ── Extensions ──────────────────────────── +echo "" +echo "PostgreSQL Extensions:" +check_extension "pgcrypto" +check_extension "vector" + +# ── Migration tracking ─────────────────── echo "" echo "Migration tracking:" check_table "schema_migrations" @@ -76,111 +160,177 @@ echo " ✓ ${MIGRATION_COUNT} migrations applied" LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none") echo " ✓ latest: ${LATEST}" -# ── 3. Core tables ────────────────────────── +# ── Core tables ────────────────────────── echo "" echo "Core tables:" check_table "users" -check_table "provider_configs" -check_table "channels" -check_table "messages" +check_table "refresh_tokens" check_table "teams" check_table "team_members" -check_table "refresh_tokens" -check_table "global_settings" - -# ── 4. Users ──────────────────────────────── -echo "" -echo "Users:" -check_column "users" "id" -check_column "users" "username" -check_column "users" "email" -check_column "users" "role" -check_column "users" "is_active" -check_column "users" "avatar_url" -check_column "users" "display_name" -check_column "users" "settings" - -# ── 5. Provider Configs (replaces api_configs) ─ -echo "" -echo "Provider Configs:" -check_column "provider_configs" "scope" -check_column "provider_configs" "owner_id" -check_column "provider_configs" "provider" -check_column "provider_configs" "endpoint" -check_column "provider_configs" "api_key_enc" -check_column "provider_configs" "headers" -check_column "provider_configs" "settings" -check_column "provider_configs" "is_active" - -# ── 6. Model Catalog (replaces model_configs) ─ -echo "" -echo "Model Catalog:" +check_table "groups" +check_table "group_members" +check_table "provider_configs" check_table "model_catalog" -check_column "model_catalog" "provider_config_id" -check_column "model_catalog" "model_id" -check_column "model_catalog" "display_name" -check_column "model_catalog" "capabilities" -check_column "model_catalog" "pricing" -check_column "model_catalog" "visibility" - -# ── 7. Personas (replaces model_presets) ──── -echo "" -echo "Personas:" check_table "personas" -check_column "personas" "scope" -check_column "personas" "owner_id" -check_column "personas" "name" -check_column "personas" "base_model_id" -check_column "personas" "provider_config_id" -check_column "personas" "system_prompt" -check_column "personas" "created_by" - -# ── 8. Channels ───────────────────────────── -echo "" -echo "Channels:" -check_column "channels" "user_id" -check_column "channels" "type" -check_column "channels" "provider_config_id" -check_column "channels" "team_id" -check_column "channels" "settings" +check_table "persona_grants" +check_table "resource_grants" +check_table "platform_policies" +check_table "global_settings" +check_table "user_model_settings" +check_table "channels" +check_table "messages" check_table "channel_members" check_table "channel_models" check_table "channel_cursors" +check_table "folders" +check_table "notes" +check_table "audit_log" +check_table "usage_log" +check_table "model_pricing" +check_table "extensions" +check_table "extension_user_settings" +check_table "attachments" +check_table "knowledge_bases" +check_table "kb_documents" +check_table "kb_chunks" +check_table "channel_knowledge_bases" -# ── 9. Messages ───────────────────────────── +# ── Dropped tables (v0.16.0 cleanup) ──── echo "" -echo "Messages:" -check_column "messages" "channel_id" -check_column "messages" "role" +echo "Dropped tables (v0.16.0 cleanup):" +check_table_absent "projects" +check_table_absent "project_channels" + +# ── Users (vault) ──────────────────────── +echo "" +echo "Users (vault):" +check_column "users" "encrypted_uek" +check_column "users" "uek_salt" +check_column "users" "uek_nonce" +check_column "users" "vault_set" + +# ── Provider Configs (encryption) ──────── +echo "" +echo "Provider Configs (encryption):" +check_column "provider_configs" "api_key_enc" +check_column_type "provider_configs" "api_key_enc" "bytea" +check_column "provider_configs" "key_nonce" +check_column_type "provider_configs" "key_nonce" "bytea" +check_column "provider_configs" "key_scope" +check_column_absent "provider_configs" "api_key_plain" + +# ── Model Catalog ──────────────────────── +echo "" +echo "Model Catalog:" +check_column "model_catalog" "model_type" +check_column "model_catalog" "visibility" +check_column "model_catalog" "capabilities" + +# ── Groups (v0.16.0) ──────────────────── +echo "" +echo "Groups (v0.16.0):" +check_column "groups" "name" +check_column "groups" "scope" +check_column "groups" "team_id" +check_column "groups" "created_by" +check_column "group_members" "group_id" +check_column "group_members" "user_id" +check_column "group_members" "added_by" +check_index "idx_group_members_user" +check_index "idx_group_members_group" +check_index "idx_groups_name_scope" + +# ── Resource Grants (v0.16.0) ─────────── +echo "" +echo "Resource Grants (v0.16.0):" +check_column "resource_grants" "resource_type" +check_column "resource_grants" "resource_id" +check_column "resource_grants" "grant_scope" +check_column "resource_grants" "granted_groups" +check_column "resource_grants" "created_by" +check_index "idx_resource_grants_resource" +check_index "idx_resource_grants_groups" + +# ── Personas ───────────────────────────── +echo "" +echo "Personas:" +check_column "personas" "scope" +check_column "personas" "owner_id" +check_column "personas" "base_model_id" +check_column "personas" "thinking_budget" +check_column "personas" "is_shared" + +# ── Channels & Messages ────────────────── +echo "" +echo "Channels & Messages:" +check_column "channels" "settings" +check_column "channels" "folder_id" +check_column "channels" "team_id" check_column "messages" "parent_id" check_column "messages" "sibling_index" -check_column "messages" "deleted_at" -check_column "messages" "participant_type" +check_column "messages" "tool_calls" +check_column "messages" "metadata" -# ── 10. Organization ──────────────────────── +# ── Notes ──────────────────────────────── echo "" -echo "Organization:" -check_table "folders" -check_table "projects" -check_table "notes" +echo "Notes:" check_column "notes" "search_vector" +check_column "notes" "embedding" +check_column "notes" "team_id" +check_index "idx_notes_search" -# ── 11. User Model Settings ──────────────── +# ── Extensions ─────────────────────────── echo "" -echo "User Model Settings:" -check_table "user_model_settings" -check_column "user_model_settings" "user_id" -check_column "user_model_settings" "model_id" -check_column "user_model_settings" "hidden" -check_column "user_model_settings" "sort_order" +echo "Extensions:" +check_column "extensions" "ext_id" +check_column "extensions" "tier" +check_column "extensions" "manifest" +check_column "extensions" "is_system" +check_column "extensions" "scope" -# ── 12. Audit Log ─────────────────────────── +# ── Attachments ────────────────────────── echo "" -echo "Audit Log:" -check_table "audit_log" -check_column "audit_log" "actor_id" -check_column "audit_log" "action" -check_column "audit_log" "resource_type" +echo "Attachments:" +check_column "attachments" "channel_id" +check_column "attachments" "storage_key" +check_column "attachments" "extracted_text" +check_index "idx_attachments_channel" +check_index "idx_attachments_orphan" + +# ── Knowledge Bases ────────────────────── +echo "" +echo "Knowledge Bases:" +check_column "knowledge_bases" "scope" +check_column "knowledge_bases" "embedding_config" +check_column "knowledge_bases" "document_count" +check_table "kb_documents" +check_table "kb_chunks" +check_column "kb_chunks" "embedding" +check_table "channel_knowledge_bases" + +# ── Usage & Pricing ────────────────────── +echo "" +echo "Usage & Pricing:" +check_column "usage_log" "provider_scope" +check_column "usage_log" "role" +check_column "usage_log" "cache_creation_tokens" +check_column "model_pricing" "cache_create_per_m" + +# ── Key triggers ───────────────────────── +echo "" +echo "Key triggers:" +check_trigger "users_updated_at" "users" +check_trigger "channels_updated_at" "channels" +check_trigger "personas_updated_at" "personas" +check_trigger "groups_updated_at" "groups" +check_trigger "resource_grants_updated_at" "resource_grants" +check_trigger "notes_search_update" "notes" + +# ── CI Username Indexes ────────────────── +echo "" +echo "CI Username Indexes:" +check_index "users_username_ci" +check_index "users_email_ci" # ═══════════════════════════════════════════ # ADD NEW MIGRATION CHECKS ABOVE THIS LINE @@ -190,11 +340,11 @@ check_column "audit_log" "resource_type" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ ${ERRORS} -eq 0 ]]; then - echo "✅ Schema validation passed (${MIGRATION_COUNT} migrations)" + echo "✅ Schema validation passed: ${CHECKS}/${CHECKS} checks OK" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 else - echo "❌ Schema validation FAILED: ${ERRORS} errors" + echo "❌ Schema validation FAILED: ${ERRORS}/${CHECKS} checks failed" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 1 fi diff --git a/server/database/migrations/001_v09_schema.sql b/server/database/migrations/001_v016_schema.sql similarity index 57% rename from server/database/migrations/001_v09_schema.sql rename to server/database/migrations/001_v016_schema.sql index 564caff..07a3ec4 100644 --- a/server/database/migrations/001_v09_schema.sql +++ b/server/database/migrations/001_v016_schema.sql @@ -1,12 +1,22 @@ -- ========================================== --- Chat Switchboard — v0.9.0 Consolidated Schema +-- Chat Switchboard — v0.16.0 Consolidated Schema -- ========================================== --- Clean-slate schema. Replaces all 001–021 migrations. +-- Clean-slate schema. Replaces all 001–009 migrations. -- Drop DB and re-create before applying. -- +-- Changes from v0.15.1 (9-file) schema: +-- • Folded all incremental migrations into base CREATEs +-- • Dropped api_key_plain (vault backfill complete) +-- • Dropped projects, project_channels (unused, v0.19.0 redesigns) +-- • Dropped 'moderator' from users.role CHECK (dead code) +-- • CI usernames from the start (LOWER() unique indexes) +-- • Added: groups, group_members, resource_grants (v0.16.0) +-- • Added: pgvector extension declaration +-- -- Design principles: -- • Explicit scope enums over nullable column tri-states -- • Personas as trust boundaries with extensible grants +-- • Groups as pure access-control lists (decouple from teams) -- • Secure by default (models hidden until admin enables) -- • Only tables with active handlers — no placeholder tables -- ========================================== @@ -14,6 +24,23 @@ -- ── Extensions ────────────────────────────── CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings) + +-- ── Upgrade-path cleanup ─────────────────── +-- These objects existed in the 001–009 migration chain but were removed +-- in the v0.16.0 consolidation. Safe no-ops on fresh installs. + +DROP TABLE IF EXISTS project_channels CASCADE; +DROP TABLE IF EXISTS projects CASCADE; + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain' + ) THEN + ALTER TABLE provider_configs DROP COLUMN api_key_plain; + END IF; +END $$; -- ── Utility: auto-update updated_at ───────── @@ -32,26 +59,43 @@ $$ LANGUAGE plpgsql; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(50) NOT NULL, + email VARCHAR(255) NOT NULL, password_hash TEXT NOT NULL, display_name VARCHAR(100), avatar_url TEXT, role VARCHAR(20) DEFAULT 'user' - CHECK (role IN ('user', 'admin', 'moderator')), + CHECK (role IN ('user', 'admin')), is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, + + -- Vault: per-user encryption key (UEK) for BYOK API keys + encrypted_uek BYTEA, + uek_salt BYTEA, + uek_nonce BYTEA, + vault_set BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), last_login_at TIMESTAMPTZ ); +-- Case-insensitive unique indexes (no bare UNIQUE constraint) +CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username)); +CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email)); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + DROP TRIGGER IF EXISTS users_updated_at ON users; CREATE TRIGGER users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key'; +COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation'; +COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping'; +COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped'; + -- ========================================= -- 2. AUTH @@ -107,9 +151,59 @@ CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id); -- ========================================= --- 4. PROVIDER CONFIGS (replaces api_configs) +-- 4. GROUPS (v0.16.0) +-- ========================================= +-- Pure access-control lists. Decouple resource visibility from team membership. +-- Teams = organizational structure. Roles = vertical permissions. +-- Groups = who can access what (cross-cutting). + +CREATE TABLE IF NOT EXISTS groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + scope VARCHAR(20) NOT NULL DEFAULT 'global' + CHECK (scope IN ('global', 'team')), + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Global groups: team_id must be NULL + -- Team groups: team_id must be set + CONSTRAINT groups_scope_team CHECK ( + (scope = 'global' AND team_id IS NULL) OR + (scope = 'team' AND team_id IS NOT NULL) + ) +); + +-- Unique name within scope (global names globally unique, +-- team names unique within team) +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope + ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000')); + +DROP TRIGGER IF EXISTS groups_updated_at ON groups; +CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.'; +COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.'; + +CREATE TABLE IF NOT EXISTS group_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_by UUID NOT NULL REFERENCES users(id), + added_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(group_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); +CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); + + +-- ========================================= +-- 5. PROVIDER CONFIGS -- ========================================= --- Explicit scope enum replaces nullable column tri-state. -- scope='global': admin-managed, visible to all users (owner_id IS NULL) -- scope='team': team admin-managed (owner_id = teams.id) -- scope='personal': user's own keys (owner_id = users.id) @@ -122,7 +216,12 @@ CREATE TABLE IF NOT EXISTS provider_configs ( name VARCHAR(100) NOT NULL, provider VARCHAR(50) NOT NULL, endpoint TEXT NOT NULL, - api_key_enc TEXT, + + -- Encrypted API key (AES-256-GCM) + api_key_enc BYTEA, + key_nonce BYTEA, + key_scope TEXT NOT NULL DEFAULT 'global', + model_default VARCHAR(100), config JSONB DEFAULT '{}'::jsonb, headers JSONB DEFAULT '{}'::jsonb, @@ -142,22 +241,26 @@ CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK'; COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope'; +COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)'; +COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc'; +COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK'; COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)'; COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)'; COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)'; -- ========================================= --- 5. MODEL CATALOG (replaces model_configs) +-- 6. MODEL CATALOG -- ========================================= -- Intrinsic capabilities for models the system knows about. --- Hidden by default — admin must enable. +-- Disabled by default — admin must enable. CREATE TABLE IF NOT EXISTS model_catalog ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, model_id TEXT NOT NULL, display_name TEXT, + model_type VARCHAR(20) DEFAULT 'chat', capabilities JSONB NOT NULL DEFAULT '{}', pricing JSONB, visibility VARCHAR(10) DEFAULT 'disabled' @@ -170,28 +273,18 @@ CREATE TABLE IF NOT EXISTS model_catalog ( CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id); CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled'; - --- Fix CHECK constraint and default for existing databases (idempotent) -DO $$ BEGIN - ALTER TABLE model_catalog DROP CONSTRAINT IF EXISTS model_catalog_visibility_check; - -- Remap old values before adding new constraint - UPDATE model_catalog SET visibility = 'enabled' WHERE visibility = 'visible'; - UPDATE model_catalog SET visibility = 'disabled' WHERE visibility = 'hidden'; - ALTER TABLE model_catalog ADD CONSTRAINT model_catalog_visibility_check - CHECK (visibility IN ('enabled', 'disabled', 'team')); - ALTER TABLE model_catalog ALTER COLUMN visibility SET DEFAULT 'disabled'; -EXCEPTION WHEN OTHERS THEN NULL; -END $$; +CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type); DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog; CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -COMMENT ON COLUMN model_catalog.visibility IS 'hidden by default — admin must enable for global models'; +COMMENT ON COLUMN model_catalog.visibility IS 'disabled by default — admin must enable for global models'; +COMMENT ON COLUMN model_catalog.model_type IS 'chat, embedding, image — from provider API sync'; COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}'; -- ========================================= --- 6. PERSONAS (replaces model_presets) +-- 7. PERSONAS -- ========================================= CREATE TABLE IF NOT EXISTS personas ( @@ -239,8 +332,10 @@ COMMENT ON COLUMN personas.is_shared IS 'Personal Personas shared with others (r -- ========================================= --- 7. PERSONA GRANTS (extensible resource binding) +-- 8. PERSONA GRANTS (what a Persona can do) -- ========================================= +-- Controls tools, KBs, and API endpoints that a Persona has access TO. +-- NOT to be confused with resource_grants (who can USE a resource). CREATE TABLE IF NOT EXISTS persona_grants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -255,13 +350,53 @@ CREATE TABLE IF NOT EXISTS persona_grants ( CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id); CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type); -COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base (future), api_endpoint (future)'; +COMMENT ON TABLE persona_grants IS 'What a Persona can do: tools it can call, KBs it can search, etc.'; +COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base, api_endpoint'; COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.'; COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants'; -- ========================================= --- 8. PLATFORM POLICIES (replaces scattered global_settings checks) +-- 9. RESOURCE GRANTS (v0.16.0 — who can USE a resource) +-- ========================================= +-- Controls which users (via groups) can access a resource. +-- NOT to be confused with persona_grants (what a Persona can do). +-- +-- persona_grants: "CodeBot can use calculator" (Persona → tool) +-- resource_grants: "Engineering can use CodeBot" (group → Persona) + +CREATE TABLE IF NOT EXISTS resource_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_type VARCHAR(30) NOT NULL + CHECK (resource_type IN ('persona', 'knowledge_base')), + resource_id UUID NOT NULL, + grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only' + CHECK (grant_scope IN ('team_only', 'global', 'groups')), + granted_groups UUID[] NOT NULL DEFAULT '{}', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- One grant row per resource + UNIQUE(resource_type, resource_id) +); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_resource + ON resource_grants(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_resource_grants_groups + ON resource_grants USING gin(granted_groups); + +DROP TRIGGER IF EXISTS resource_grants_updated_at ON resource_grants; +CREATE TRIGGER resource_grants_updated_at BEFORE UPDATE ON resource_grants + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +COMMENT ON TABLE resource_grants IS 'Who can USE a resource (personas, KBs). Cross-team access via groups.'; +COMMENT ON COLUMN resource_grants.grant_scope IS 'team_only: existing team behavior. global: all users. groups: specific group list.'; +COMMENT ON COLUMN resource_grants.granted_groups IS 'UUID array of group IDs. Only meaningful when grant_scope = groups.'; + + +-- ========================================= +-- 10. PLATFORM POLICIES -- ========================================= CREATE TABLE IF NOT EXISTS platform_policies ( @@ -285,10 +420,8 @@ COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platfor -- ========================================= --- 9. GLOBAL SETTINGS (non-policy config) +-- 11. GLOBAL SETTINGS (non-policy config) -- ========================================= --- Retained for banner config, site branding, etc. --- Policy-like keys move to platform_policies. CREATE TABLE IF NOT EXISTS global_settings ( key VARCHAR(100) PRIMARY KEY, @@ -297,7 +430,6 @@ CREATE TABLE IF NOT EXISTS global_settings ( updated_by UUID REFERENCES users(id) ); --- Seed defaults INSERT INTO global_settings (key, value) VALUES ('registration', '{"enabled": true}'::jsonb), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb), @@ -315,12 +447,17 @@ INSERT INTO global_settings (key, value) VALUES "production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" }, "training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" }, "demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" } + }'::jsonb), + ('model_roles', '{ + "utility": { "primary": null, "fallback": null }, + "embedding": { "primary": null, "fallback": null }, + "generation": { "primary": null, "fallback": null } }'::jsonb) ON CONFLICT (key) DO NOTHING; -- ========================================= --- 10. USER MODEL SETTINGS (replaces user_model_preferences) +-- 12. USER MODEL SETTINGS -- ========================================= CREATE TABLE IF NOT EXISTS user_model_settings ( @@ -343,7 +480,7 @@ CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settin -- ========================================= --- 11. CHANNELS (unified chats) +-- 13. CHANNELS -- ========================================= CREATE TABLE IF NOT EXISTS channels ( @@ -358,8 +495,8 @@ CREATE TABLE IF NOT EXISTS channels ( provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, is_archived BOOLEAN DEFAULT false, is_pinned BOOLEAN DEFAULT false, - folder_id UUID, -- FK added after folders table - folder TEXT, -- backward compat: simple text folder name + folder_id UUID, -- FK added after folders table created + folder TEXT, -- simple text folder name (frontend-managed) team_id UUID REFERENCES teams(id) ON DELETE SET NULL, settings JSONB DEFAULT '{}'::jsonb, tags TEXT[], @@ -380,7 +517,7 @@ COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, chann -- ========================================= --- 12. MESSAGES (with tree/forking support) +-- 14. MESSAGES (with tree/forking support) -- ========================================= CREATE TABLE IF NOT EXISTS messages ( @@ -415,7 +552,7 @@ COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier stri -- ========================================= --- 13. CHANNEL MEMBERS & MODELS +-- 15. CHANNEL MEMBERS & MODELS -- ========================================= CREATE TABLE IF NOT EXISTS channel_members ( @@ -448,7 +585,7 @@ CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_ -- ========================================= --- 14. CHANNEL CURSORS (forking navigation) +-- 16. CHANNEL CURSORS (forking navigation) -- ========================================= CREATE TABLE IF NOT EXISTS channel_cursors ( @@ -465,8 +602,9 @@ CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id); -- ========================================= --- 15. FOLDERS & PROJECTS +-- 17. FOLDERS -- ========================================= +-- Channel folders. Note: projects table deferred to v0.19.0. CREATE TABLE IF NOT EXISTS folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -485,41 +623,20 @@ DROP TRIGGER IF EXISTS folders_updated_at ON folders; CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders FOR EACH ROW EXECUTE FUNCTION update_updated_at(); --- Now add the FK from channels +-- Deferred FK: channels.folder_id → folders.id DO $$ BEGIN - ALTER TABLE channels ADD CONSTRAINT fk_channels_folder - FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL; -EXCEPTION WHEN duplicate_object THEN NULL; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder' + ) THEN + ALTER TABLE channels ADD CONSTRAINT fk_channels_folder + FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL; + END IF; END $$; CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; -CREATE TABLE IF NOT EXISTS projects ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(200) NOT NULL, - description TEXT, - color VARCHAR(7), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_projects_user ON projects(user_id); -DROP TRIGGER IF EXISTS projects_updated_at ON projects; -CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -CREATE TABLE IF NOT EXISTS project_channels ( - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - added_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (project_id, channel_id) -); - -CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id); - -- ========================================= --- 16. NOTES +-- 18. NOTES -- ========================================= CREATE TABLE IF NOT EXISTS notes ( @@ -533,6 +650,7 @@ CREATE TABLE IF NOT EXISTS notes ( source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, team_id UUID REFERENCES teams(id) ON DELETE SET NULL, search_vector TSVECTOR, + embedding VECTOR(3072), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -562,7 +680,7 @@ CREATE TRIGGER notes_search_update -- ========================================= --- 17. AUDIT LOG +-- 19. AUDIT LOG -- ========================================= CREATE TABLE IF NOT EXISTS audit_log ( @@ -583,3 +701,184 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, re CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations'; + + +-- ========================================= +-- 20. USAGE TRACKING +-- ========================================= + +CREATE TABLE IF NOT EXISTS usage_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, + provider_scope TEXT NOT NULL DEFAULT 'global', + model_id TEXT NOT NULL, + role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation' + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + cache_creation_tokens INT NOT NULL DEFAULT 0, + cache_read_tokens INT NOT NULL DEFAULT 0, + cost_input NUMERIC(12,6), + cost_output NUMERIC(12,6), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id); +CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at); +CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id); +CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id); +CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope); + + +-- ========================================= +-- 21. MODEL PRICING +-- ========================================= + +CREATE TABLE IF NOT EXISTS model_pricing ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, + model_id TEXT NOT NULL, + input_per_m NUMERIC(10,6), + output_per_m NUMERIC(10,6), + cache_create_per_m NUMERIC(10,6), + cache_read_per_m NUMERIC(10,6), + currency TEXT NOT NULL DEFAULT 'USD', + source TEXT NOT NULL DEFAULT 'manual', + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES users(id), + UNIQUE(provider_config_id, model_id) +); + + +-- ========================================= +-- 22. EXTENSIONS +-- ========================================= + +CREATE TABLE IF NOT EXISTS extensions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ext_id VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + version VARCHAR(50) NOT NULL DEFAULT '0.0.0', + tier VARCHAR(20) NOT NULL DEFAULT 'browser', + description TEXT NOT NULL DEFAULT '', + author VARCHAR(200) NOT NULL DEFAULT '', + manifest JSONB NOT NULL DEFAULT '{}', + is_system BOOLEAN NOT NULL DEFAULT false, + is_enabled BOOLEAN NOT NULL DEFAULT true, + scope VARCHAR(20) NOT NULL DEFAULT 'global', + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + installed_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier); +CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true; + +CREATE TABLE IF NOT EXISTS extension_user_settings ( + extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + settings JSONB NOT NULL DEFAULT '{}', + is_enabled BOOLEAN NOT NULL DEFAULT true, + PRIMARY KEY (extension_id, user_id) +); + + +-- ========================================= +-- 23. ATTACHMENTS +-- ========================================= + +CREATE TABLE IF NOT EXISTS attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(127) NOT NULL, + size_bytes BIGINT NOT NULL, + storage_key TEXT NOT NULL, + extracted_text TEXT, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id); +CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id); +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL; + + +-- ========================================= +-- 24. KNOWLEDGE BASES +-- ========================================= + +CREATE TABLE IF NOT EXISTS knowledge_bases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'global', + owner_id UUID REFERENCES users(id) ON DELETE CASCADE, + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + embedding_config JSONB NOT NULL DEFAULT '{}', + document_count INT NOT NULL DEFAULT 0, + chunk_count INT NOT NULL DEFAULT 0, + total_bytes BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT kb_scope_check CHECK ( + (scope = 'global' AND owner_id IS NULL) OR + (scope = 'team' AND team_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope); +CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS kb_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + storage_key TEXT NOT NULL, + extracted_text TEXT, + chunk_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + uploaded_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id); + +CREATE TABLE IF NOT EXISTS kb_chunks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE, + chunk_index INT NOT NULL, + content TEXT NOT NULL, + token_count INT NOT NULL DEFAULT 0, + embedding VECTOR(3072), + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id); +CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id); + +-- NOTE: IVFFlat index for similarity search created after first data load +-- (needs rows to train). The ingest handler creates it. + +CREATE TABLE IF NOT EXISTS channel_knowledge_bases ( + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, kb_id) +); diff --git a/server/database/migrations/002_ci_username.sql b/server/database/migrations/002_ci_username.sql deleted file mode 100644 index 4f18dc9..0000000 --- a/server/database/migrations/002_ci_username.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 002_ci_username.sql --- Case-insensitive unique indexes for username and email. --- Replaces the default UNIQUE constraint (which is case-sensitive). - --- Normalize existing rows to lowercase first -UPDATE users SET username = LOWER(username) WHERE username != LOWER(username); -UPDATE users SET email = LOWER(email) WHERE email != LOWER(email); - --- Drop old case-sensitive unique constraints and add case-insensitive indexes -ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key; -ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; - -CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username)); -CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email)); diff --git a/server/database/migrations/003_vault.sql b/server/database/migrations/003_vault.sql deleted file mode 100644 index bba289d..0000000 --- a/server/database/migrations/003_vault.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 003_vault.sql — API Key Encryption + User Vault --- v0.9.4: Two-tier encryption for API keys --- --- Phase 1 (this migration): Add new columns alongside existing plaintext. --- Phase 2 (Go startup): Backfill — encrypt plaintext keys, generate UEKs. --- Phase 3 (future): Drop api_key_plain after confirmed stable. - --- ========================================= --- 1. USERS — Vault support --- ========================================= --- Per-user encryption key (UEK) wrapped with password-derived key (Argon2id). --- UEK protects personal BYOK API keys. Platform admin cannot recover without --- the user's password. - -ALTER TABLE users ADD COLUMN IF NOT EXISTS encrypted_uek BYTEA; -ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_salt BYTEA; -ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_nonce BYTEA; -ALTER TABLE users ADD COLUMN IF NOT EXISTS vault_set BOOLEAN NOT NULL DEFAULT false; - -COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key'; -COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation'; -COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping'; -COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped'; - --- ========================================= --- 2. PROVIDER_CONFIGS — Encrypted key storage --- ========================================= --- Rename current plaintext column, add encrypted BYTEA columns. --- Backfill happens in Go (needs ENCRYPTION_KEY env var). - --- Keep plaintext temporarily for backfill; Go startup reads it, encrypts, --- writes to new columns, then NULLs it out. -ALTER TABLE provider_configs RENAME COLUMN api_key_enc TO api_key_plain; -ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS api_key_enc BYTEA; -ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_nonce BYTEA; -ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_scope TEXT; - --- Backfill key_scope from existing scope column (safe default) -UPDATE provider_configs SET key_scope = scope WHERE key_scope IS NULL; - --- Now make it NOT NULL with default -ALTER TABLE provider_configs ALTER COLUMN key_scope SET NOT NULL; -ALTER TABLE provider_configs ALTER COLUMN key_scope SET DEFAULT 'global'; - -COMMENT ON COLUMN provider_configs.api_key_plain IS 'DEPRECATED: plaintext key, cleared after vault backfill'; -COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)'; -COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc'; -COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK'; diff --git a/server/database/migrations/004_roles_usage.sql b/server/database/migrations/004_roles_usage.sql deleted file mode 100644 index a77fec7..0000000 --- a/server/database/migrations/004_roles_usage.sql +++ /dev/null @@ -1,64 +0,0 @@ --- 004_roles_usage.sql --- v0.10.0: Model Roles + Usage Tracking --- --- New tables: usage_log, model_pricing --- Seed: model_roles in global_settings - --- ── Model roles seed ────────────────────────── --- Uses existing global_settings table. No new tables for roles. --- Team overrides go in teams.settings JSONB (existing column). - -INSERT INTO global_settings (key, value) VALUES - ('model_roles', '{ - "utility": { "primary": null, "fallback": null }, - "embedding": { "primary": null, "fallback": null }, - "generation": { "primary": null, "fallback": null } - }'::jsonb) -ON CONFLICT (key) DO NOTHING; - --- ── Usage tracking ──────────────────────────── --- Every completion (streaming and non-streaming) logs token counts and cost. --- Cost is calculated at insert time from model_pricing. --- provider_scope is denormalized for efficient admin filtering (excludes BYOK). - -CREATE TABLE IF NOT EXISTS usage_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, - provider_scope TEXT NOT NULL DEFAULT 'global', - model_id TEXT NOT NULL, - role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation' - prompt_tokens INT NOT NULL DEFAULT 0, - completion_tokens INT NOT NULL DEFAULT 0, - cache_creation_tokens INT NOT NULL DEFAULT 0, - cache_read_tokens INT NOT NULL DEFAULT 0, - cost_input NUMERIC(12,6), - cost_output NUMERIC(12,6), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id); -CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at); -CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id); -CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id); -CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope); - --- ── Model pricing ───────────────────────────── --- Two sources: 'catalog' (auto-populated from provider API sync) and --- 'manual' (admin override). Manual entries are never overwritten by sync. - -CREATE TABLE IF NOT EXISTS model_pricing ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, - model_id TEXT NOT NULL, - input_per_m NUMERIC(10,6), - output_per_m NUMERIC(10,6), - cache_create_per_m NUMERIC(10,6), - cache_read_per_m NUMERIC(10,6), - currency TEXT NOT NULL DEFAULT 'USD', - source TEXT NOT NULL DEFAULT 'manual', - updated_at TIMESTAMPTZ DEFAULT NOW(), - updated_by UUID REFERENCES users(id), - UNIQUE(provider_config_id, model_id) -); diff --git a/server/database/migrations/005_model_type.sql b/server/database/migrations/005_model_type.sql deleted file mode 100644 index 9945e3f..0000000 --- a/server/database/migrations/005_model_type.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Migration 005: Add model_type to model_catalog --- --- Providers like Venice return a "type" field per model (e.g. "text", "embedding", "image"). --- This column captures that classification so role dropdowns can filter appropriately: --- - "embedding" role → only embedding models --- - "utility" role → only chat/text models --- --- Default is "chat" (the overwhelming majority of models). Provider sync will --- populate from the wire response when available — NO hardcoding of model types. - -ALTER TABLE model_catalog ADD COLUMN IF NOT EXISTS model_type VARCHAR(20) DEFAULT 'chat'; - --- Index for role UI filtering (e.g. "show me all embedding models for this provider") -CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type); diff --git a/server/database/migrations/006_extensions.sql b/server/database/migrations/006_extensions.sql deleted file mode 100644 index 918e5e7..0000000 --- a/server/database/migrations/006_extensions.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Migration 006: Extension system tables --- --- Extensions are installable plugins that add renderers, tools, surfaces, --- and event handlers. Three tiers: browser (JS), starlark (sandbox), --- sidecar (container). Browser tier ships first (v0.11.0). --- --- See EXTENSIONS.md for full spec. - --- ── Extension registry ────────────────────────── - -CREATE TABLE IF NOT EXISTS extensions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id (e.g. "collapsible-code") - name VARCHAR(200) NOT NULL, -- human label - version VARCHAR(50) NOT NULL DEFAULT '0.0.0', - tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser | starlark | sidecar - description TEXT NOT NULL DEFAULT '', - author VARCHAR(200) NOT NULL DEFAULT '', - manifest JSONB NOT NULL DEFAULT '{}', -- full manifest JSON - is_system BOOLEAN NOT NULL DEFAULT false, -- admin-pushed, users can't disable - is_enabled BOOLEAN NOT NULL DEFAULT true, -- global enable/disable - scope VARCHAR(20) NOT NULL DEFAULT 'global', -- global | team | personal - team_id UUID REFERENCES teams(id) ON DELETE CASCADE, - installed_by UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- For bundled extensions that ship with core -CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier); -CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true; - --- ── Per-user extension settings ───────────────── - -CREATE TABLE IF NOT EXISTS extension_user_settings ( - extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - settings JSONB NOT NULL DEFAULT '{}', -- user's config values - is_enabled BOOLEAN NOT NULL DEFAULT true, -- per-user toggle - PRIMARY KEY (extension_id, user_id) -); diff --git a/server/database/migrations/007_attachments.sql b/server/database/migrations/007_attachments.sql deleted file mode 100644 index b13227a..0000000 --- a/server/database/migrations/007_attachments.sql +++ /dev/null @@ -1,32 +0,0 @@ --- 007_attachments.sql --- File attachments for chat messages (v0.12.0) --- --- Blobs live in object storage (PVC / S3). This table holds metadata. --- Access control: always join through channels — attachments inherit --- channel membership as their access boundary. - -CREATE TABLE IF NOT EXISTS attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id), - message_id UUID REFERENCES messages(id) ON DELETE SET NULL, - filename VARCHAR(255) NOT NULL, - content_type VARCHAR(127) NOT NULL, - size_bytes BIGINT NOT NULL, - storage_key TEXT NOT NULL, - extracted_text TEXT, - metadata JSONB DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Primary access pattern: find attachments for a channel (auth check joins here) -CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id); - --- Quota calculation: SUM(size_bytes) WHERE user_id = $1 -CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id); - --- Find attachments for a specific message -CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL; - --- Orphan cleanup: find unlinked attachments older than threshold -CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL; diff --git a/server/database/migrations/008_knowledge_bases.sql b/server/database/migrations/008_knowledge_bases.sql deleted file mode 100644 index 6981438..0000000 --- a/server/database/migrations/008_knowledge_bases.sql +++ /dev/null @@ -1,99 +0,0 @@ --- 008_knowledge_bases.sql --- Knowledge Bases: KB metadata, documents, chunks, channel links. --- Requires: pgvector extension already installed (via db-bootstrap.sh). - --- ── Knowledge Bases ────────────────────────── - -CREATE TABLE IF NOT EXISTS knowledge_bases ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT 'global', -- global, team, personal - owner_id UUID REFERENCES users(id) ON DELETE CASCADE, - team_id UUID REFERENCES teams(id) ON DELETE CASCADE, - - -- Snapshot of embedding config at creation time. - -- Changing the model requires a full re-embed (rebuild endpoint). - -- { "provider_config_id": "...", "model_id": "...", "dimensions": 1536 } - embedding_config JSONB NOT NULL DEFAULT '{}', - - -- Denormalized stats, updated by ingest pipeline. - document_count INT NOT NULL DEFAULT 0, - chunk_count INT NOT NULL DEFAULT 0, - total_bytes BIGINT NOT NULL DEFAULT 0, - - status TEXT NOT NULL DEFAULT 'active', -- active, processing, error - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT kb_scope_check CHECK ( - (scope = 'global' AND owner_id IS NULL) OR - (scope = 'team' AND team_id IS NOT NULL) OR - (scope = 'personal' AND owner_id IS NOT NULL) - ) -); - -CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope); -CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL; - --- ── KB Documents ───────────────────────────── --- One row per uploaded file. Blobs live in ObjectStore under: --- kb/{kb_id}/{doc_id}_{filename} - -CREATE TABLE IF NOT EXISTS kb_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, - filename TEXT NOT NULL, - content_type TEXT NOT NULL, - size_bytes BIGINT NOT NULL, - storage_key TEXT NOT NULL, - extracted_text TEXT, -- full text for re-chunking - chunk_count INT NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'pending', -- pending, chunking, embedding, ready, error - error TEXT, - uploaded_by UUID NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id); - --- ── KB Chunks ──────────────────────────────── --- Chunked text + embedding vector for similarity search. --- vector(3072) accommodates all current embedding models: --- OpenAI ada-002 / text-embedding-3-small = 1536 --- OpenAI text-embedding-3-large = 3072 --- Open-source models = typically 768-1024 --- Smaller vectors are zero-padded at insert time. - -CREATE TABLE IF NOT EXISTS kb_chunks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, - document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE, - chunk_index INT NOT NULL, - content TEXT NOT NULL, - token_count INT NOT NULL DEFAULT 0, - embedding VECTOR(3072), - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id); -CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id); - --- NOTE: IVFFlat index for similarity search is created after first data --- load (needs rows to train). The ingest handler creates it: --- CREATE INDEX idx_kbchunk_embedding ON kb_chunks --- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); - --- ── Channel-KB Links ───────────────────────── --- Which KBs are active for a given channel. - -CREATE TABLE IF NOT EXISTS channel_knowledge_bases ( - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, - enabled BOOLEAN NOT NULL DEFAULT true, - added_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, kb_id) -); \ No newline at end of file diff --git a/server/database/migrations/009_notes_embedding.sql b/server/database/migrations/009_notes_embedding.sql deleted file mode 100644 index 633b6de..0000000 --- a/server/database/migrations/009_notes_embedding.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 009_notes_embedding.sql --- Adds vector embedding column to notes for semantic search. --- Uses the same vector(3072) dimension as kb_chunks for uniformity. - -ALTER TABLE notes ADD COLUMN IF NOT EXISTS embedding vector(3072); - --- NOTE: pgvector HNSW indexes are limited to 2000 dimensions. --- For 3072-dim vectors, IVFFlat is the option but requires training --- data (rows). The notes table is typically small enough that a --- sequential scan is fast. If needed, create after data exists: --- CREATE INDEX idx_notes_embedding ON notes --- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 01410fd..fa30f3c 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -165,6 +165,9 @@ func TruncateAll(t *testing.T) { } // Order matters due to foreign keys — truncate with CASCADE tables := []string{ + "resource_grants", + "group_members", + "groups", "usage_log", "model_pricing", "notes", @@ -256,6 +259,64 @@ func SeedTestChannel(t *testing.T, userID, title string) string { // ── Helpers ───────────────────────────────── +// SeedTestTeam creates a test team and returns the team ID. +func SeedTestTeam(t *testing.T, name, createdBy string) string { + t.Helper() + var id string + err := DB.QueryRow(` + INSERT INTO teams (name, created_by) + VALUES ($1, $2) + RETURNING id + `, name, createdBy).Scan(&id) + if err != nil { + t.Fatalf("SeedTestTeam: %v", err) + } + return id +} + +// SeedTestTeamMember adds a user to a team. +func SeedTestTeamMember(t *testing.T, teamID, userID, role string) { + t.Helper() + _, err := DB.Exec(` + INSERT INTO team_members (team_id, user_id, role) + VALUES ($1, $2, $3) + `, teamID, userID, role) + if err != nil { + t.Fatalf("SeedTestTeamMember: %v", err) + } +} + +// SeedTestGroup creates a test group and returns the group ID. +func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string { + t.Helper() + var id string + var teamArg interface{} + if teamID != nil { + teamArg = *teamID + } + err := DB.QueryRow(` + INSERT INTO groups (name, scope, team_id, created_by) + VALUES ($1, $2, $3, $4) + RETURNING id + `, name, scope, teamArg, createdBy).Scan(&id) + if err != nil { + t.Fatalf("SeedTestGroup: %v", err) + } + return id +} + +// SeedGroupMember adds a user to a group. +func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) { + t.Helper() + _, err := DB.Exec(` + INSERT INTO group_members (group_id, user_id, added_by) + VALUES ($1, $2, $3) + `, groupID, userID, addedBy) + if err != nil { + t.Fatalf("SeedGroupMember: %v", err) + } +} + func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/server/handlers/groups.go b/server/handlers/groups.go new file mode 100644 index 0000000..7ecd36f --- /dev/null +++ b/server/handlers/groups.go @@ -0,0 +1,365 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Request types ─────────────────────────── + +type createGroupRequest struct { + Name string `json:"name" binding:"required,min=1,max=200"` + Description string `json:"description,omitempty"` + Scope string `json:"scope" binding:"required,oneof=global team"` + TeamID *string `json:"team_id,omitempty"` +} + +type updateGroupRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +type addGroupMemberRequest struct { + UserID string `json:"user_id" binding:"required"` +} + +type setResourceGrantRequest struct { + GrantScope string `json:"grant_scope" binding:"required,oneof=team_only global groups"` + GrantedGroups []string `json:"granted_groups,omitempty"` // required when grant_scope=groups +} + +// ── Handler ───────────────────────────────── + +type GroupHandler struct { + stores store.Stores +} + +func NewGroupHandler(s store.Stores) *GroupHandler { + return &GroupHandler{stores: s} +} + +// ── Admin: List All Groups ────────────────── + +func (h *GroupHandler) ListGroups(c *gin.Context) { + groups, err := h.stores.Groups.ListAll(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if groups == nil { + groups = []models.Group{} + } + c.JSON(http.StatusOK, gin.H{"data": groups}) +} + +// ── Admin: Create Group ───────────────────── + +func (h *GroupHandler) CreateGroup(c *gin.Context) { + actorID := getUserID(c) + + var req createGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate scope constraints + if req.Scope == models.ScopeTeam && (req.TeamID == nil || *req.TeamID == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped groups"}) + return + } + if req.Scope == models.ScopeGlobal && req.TeamID != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "team_id must be null for global groups"}) + return + } + + g := &models.Group{ + Name: req.Name, + Description: req.Description, + Scope: req.Scope, + TeamID: req.TeamID, + CreatedBy: actorID, + } + + if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil { + if strings.Contains(err.Error(), "duplicate key") { + c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) + return + } + + c.JSON(http.StatusCreated, g) + AuditLog(c, "group.create", "group", g.ID, map[string]interface{}{ + "name": g.Name, "scope": g.Scope, + }) +} + +// ── Admin: Get Group ──────────────────────── + +func (h *GroupHandler) GetGroup(c *gin.Context) { + id := c.Param("id") + + g, err := h.stores.Groups.GetByID(c.Request.Context(), id) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + c.JSON(http.StatusOK, g) +} + +// ── Admin: Update Group ───────────────────── + +func (h *GroupHandler) UpdateGroup(c *gin.Context) { + id := c.Param("id") + + var req updateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name == nil && req.Description == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "group.update", "group", id, nil) +} + +// ── Admin: Delete Group ───────────────────── + +func (h *GroupHandler) DeleteGroup(c *gin.Context) { + id := c.Param("id") + + err := h.stores.Groups.Delete(c.Request.Context(), id) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "group.delete", "group", id, nil) +} + +// ── Members: List ─────────────────────────── + +func (h *GroupHandler) ListMembers(c *gin.Context) { + groupID := c.Param("id") + + members, err := h.stores.Groups.ListMembers(c.Request.Context(), groupID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if members == nil { + members = []models.GroupMember{} + } + c.JSON(http.StatusOK, gin.H{"data": members}) +} + +// ── Members: Add ──────────────────────────── + +func (h *GroupHandler) AddMember(c *gin.Context) { + groupID := c.Param("id") + actorID := getUserID(c) + + var req addGroupMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify group exists + if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + + if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{ + "user_id": req.UserID, + }) +} + +// ── Members: Remove ───────────────────────── + +func (h *GroupHandler) RemoveMember(c *gin.Context) { + groupID := c.Param("id") + userID := c.Param("userId") + + err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove member"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{ + "user_id": userID, + }) +} + +// ── User: My Groups ───────────────────────── + +func (h *GroupHandler) MyGroups(c *gin.Context) { + userID := getUserID(c) + + groups, err := h.stores.Groups.ListForUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if groups == nil { + groups = []models.Group{} + } + c.JSON(http.StatusOK, gin.H{"data": groups}) +} + +// ── Team Admin: List Team Groups ──────────── + +func (h *GroupHandler) ListTeamGroups(c *gin.Context) { + teamID := getTeamID(c) + + groups, err := h.stores.Groups.ListForTeam(c.Request.Context(), teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if groups == nil { + groups = []models.Group{} + } + c.JSON(http.StatusOK, gin.H{"data": groups}) +} + +// ── Resource Grants ───────────────────────── + +// GetResourceGrant returns the grant for a resource. +// GET /api/v1/admin/grants/:type/:id +func (h *GroupHandler) GetResourceGrant(c *gin.Context) { + resourceType := c.Param("type") + resourceID := c.Param("id") + + grant, err := h.stores.ResourceGrants.Get(c.Request.Context(), resourceType, resourceID) + if err == sql.ErrNoRows { + // No grant = default team_only behavior + c.JSON(http.StatusOK, gin.H{ + "resource_type": resourceType, + "resource_id": resourceID, + "grant_scope": models.GrantScopeTeamOnly, + "granted_groups": []string{}, + }) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + c.JSON(http.StatusOK, grant) +} + +// SetResourceGrant creates or updates a grant for a resource. +// PUT /api/v1/admin/grants/:type/:id +func (h *GroupHandler) SetResourceGrant(c *gin.Context) { + resourceType := c.Param("type") + resourceID := c.Param("id") + actorID := getUserID(c) + + var req setResourceGrantRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate: groups scope requires at least one group + if req.GrantScope == models.GrantScopeGroups && len(req.GrantedGroups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "granted_groups required when grant_scope is 'groups'"}) + return + } + + // team_only = delete any existing grant (reverts to default scope behavior) + if req.GrantScope == models.GrantScopeTeamOnly { + _ = h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID) + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "grant.revoke", resourceType, resourceID, nil) + return + } + + grant := &models.ResourceGrant{ + ResourceType: resourceType, + ResourceID: resourceID, + GrantScope: req.GrantScope, + GrantedGroups: req.GrantedGroups, + CreatedBy: actorID, + } + + if err := h.stores.ResourceGrants.Set(c.Request.Context(), grant); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set grant"}) + return + } + + c.JSON(http.StatusOK, grant) + AuditLog(c, "grant.set", resourceType, resourceID, map[string]interface{}{ + "grant_scope": req.GrantScope, + }) +} + +// DeleteResourceGrant removes a grant for a resource. +// DELETE /api/v1/admin/grants/:type/:id +func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) { + resourceType := c.Param("type") + resourceID := c.Param("id") + + err := h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "grant not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "grant.delete", resourceType, resourceID, nil) +} diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index c05f0f6..1a19ba4 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -16,6 +16,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/roles" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" ) @@ -185,6 +186,25 @@ func setupHarness(t *testing.T) *testHarness { admin.GET("/presets", presets.ListAdminPersonas) admin.POST("/presets", presets.CreateAdminPersona) + // Admin groups (v0.16.0) + groupAdm := NewGroupHandler(stores) + admin.GET("/groups", groupAdm.ListGroups) + admin.POST("/groups", groupAdm.CreateGroup) + admin.GET("/groups/:id", groupAdm.GetGroup) + admin.PUT("/groups/:id", groupAdm.UpdateGroup) + admin.DELETE("/groups/:id", groupAdm.DeleteGroup) + admin.GET("/groups/:id/members", groupAdm.ListMembers) + admin.POST("/groups/:id/members", groupAdm.AddMember) + admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember) + + // Admin resource grants (v0.16.0) + admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant) + admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) + admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) + + // User groups (v0.16.0) + protected.GET("/groups/mine", groupAdm.MyGroups) + // Admin roles rolesH := NewRolesHandler(stores, roleResolver) admin.GET("/roles", rolesH.ListRoles) @@ -2443,3 +2463,397 @@ func TestIntegration_ChannelKBLinking(t *testing.T) { t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData)) } } + +// ══════════════════════════════════════════════ +// GROUPS + RESOURCE GRANTS (v0.16.0) +// ══════════════════════════════════════════════ + +func TestGroupCRUD(t *testing.T) { + h := setupHarness(t) + + _, adminToken := h.createAdminUser("gadmin", "gadmin@test.com") + + // ── Create global group ── + w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Engineering", + "description": "All engineers", + "scope": "global", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created models.Group + decode(w, &created) + if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" { + t.Fatalf("unexpected group: %+v", created) + } + + // ── List groups ── + w = h.request("GET", "/api/v1/admin/groups", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list groups: want 200, got %d", w.Code) + } + var listResp map[string]interface{} + decode(w, &listResp) + data := listResp["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 group, got %d", len(data)) + } + + // ── Get group ── + w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get group: want 200, got %d", w.Code) + } + + // ── Update group ── + w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{ + "name": "Platform Engineering", + }) + if w.Code != http.StatusOK { + t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify update + w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil) + var updated models.Group + decode(w, &updated) + if updated.Name != "Platform Engineering" { + t.Fatalf("name should be updated, got %q", updated.Name) + } + + // ── Duplicate name conflict ── + w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Platform Engineering", + "scope": "global", + }) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate name: want 409, got %d", w.Code) + } + + // ── Delete group ── + w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete group: want 200, got %d", w.Code) + } + + // Verify deleted + w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("deleted group: want 404, got %d", w.Code) + } +} + +func TestGroupMembers(t *testing.T) { + h := setupHarness(t) + + _, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com") + userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com") + database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID) + userToken := makeToken(userID, "gmuser@test.com", "user") + + // Create group + w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Testers", + "scope": "global", + }) + var group models.Group + decode(w, &group) + + // ── Add member ── + w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{ + "user_id": userID, + }) + if w.Code != http.StatusOK { + t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // ── List members ── + w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list members: want 200, got %d", w.Code) + } + var memberResp map[string]interface{} + decode(w, &memberResp) + members := memberResp["data"].([]interface{}) + if len(members) != 1 { + t.Fatalf("expected 1 member, got %d", len(members)) + } + m := members[0].(map[string]interface{}) + if m["username"] != "gmuser" { + t.Fatalf("expected username gmuser, got %v", m["username"]) + } + + // ── My groups (user endpoint) ── + w = h.request("GET", "/api/v1/groups/mine", userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("my groups: want 200, got %d", w.Code) + } + var myResp map[string]interface{} + decode(w, &myResp) + myGroups := myResp["data"].([]interface{}) + if len(myGroups) != 1 { + t.Fatalf("user should be in 1 group, got %d", len(myGroups)) + } + + // ── Idempotent add (no error) ── + w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{ + "user_id": userID, + }) + if w.Code != http.StatusOK { + t.Fatalf("idempotent add: want 200, got %d", w.Code) + } + + // ── Remove member ── + w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify removed + w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil) + decode(w, &memberResp) + members = memberResp["data"].([]interface{}) + if len(members) != 0 { + t.Fatalf("expected 0 members after removal, got %d", len(members)) + } +} + +func TestGroupScopeValidation(t *testing.T) { + h := setupHarness(t) + + _, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com") + + // Team-scoped without team_id → 400 + w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Bad Group", + "scope": "team", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("team scope without team_id: want 400, got %d", w.Code) + } + + // Global with team_id → 400 + w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Bad Group", + "scope": "global", + "team_id": "00000000-0000-0000-0000-000000000001", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("global scope with team_id: want 400, got %d", w.Code) + } +} + +func TestResourceGrants(t *testing.T) { + h := setupHarness(t) + + _, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com") + + // Create a group + w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Grantees", + "scope": "global", + }) + var group models.Group + decode(w, &group) + + // Create a persona (admin) + w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{ + "name": "Test Bot", + "base_model_id": "test-model", + "scope": "global", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String()) + } + var persona map[string]interface{} + decode(w, &persona) + personaID := persona["id"].(string) + + // ── Get grant (no grant yet → default team_only) ── + w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get grant: want 200, got %d", w.Code) + } + var grantResp map[string]interface{} + decode(w, &grantResp) + if grantResp["grant_scope"] != "team_only" { + t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"]) + } + + // ── Set grant: groups scope ── + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{ + "grant_scope": "groups", + "granted_groups": []string{group.ID}, + }) + if w.Code != http.StatusOK { + t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // ── Get grant (now groups) ── + w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil) + decode(w, &grantResp) + if grantResp["grant_scope"] != "groups" { + t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"]) + } + + // ── Set grant: global scope ── + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{ + "grant_scope": "global", + }) + if w.Code != http.StatusOK { + t.Fatalf("set global grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // ── Revoke: set back to team_only (deletes grant row) ── + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{ + "grant_scope": "team_only", + }) + if w.Code != http.StatusOK { + t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // ── Validation: groups scope without groups → 400 ── + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{ + "grant_scope": "groups", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("groups without list: want 400, got %d", w.Code) + } +} + +func TestGroupBasedPersonaAccess(t *testing.T) { + h := setupHarness(t) + + // Setup: admin + regular user (not on any team) + adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com") + userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com") + database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID) + userToken := makeToken(userID, "gpuser@test.com", "user") + + // Create a team + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Secret Team", + }) + var teamResp map[string]interface{} + decode(w, &teamResp) + teamID := teamResp["id"].(string) + + // Create persona scoped to that team (user shouldn't see it without group access) + var personaID string + err := database.DB.QueryRow(` + INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) + VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true) + RETURNING id + `, teamID, adminID).Scan(&personaID) + if err != nil { + t.Fatalf("insert persona: %v", err) + } + if personaID == "" { + t.Fatal("personaID is empty after insert") + } + + // ── User should NOT see team persona ── + w = h.request("GET", "/api/v1/presets", userToken, nil) + var presetsResp map[string]interface{} + decode(w, &presetsResp) + presets, _ := presetsResp["presets"].([]interface{}) + for _, p := range presets { + pm := p.(map[string]interface{}) + if pm["id"] == personaID { + t.Fatalf("user should NOT see team persona without group access") + } + } + + // ── Create group + add user + grant persona ── + w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{ + "name": "Secret Readers", + "scope": "global", + }) + var group models.Group + decode(w, &group) + + w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{ + "user_id": userID, + }) + + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{ + "grant_scope": "groups", + "granted_groups": []string{group.ID}, + }) + if w.Code != http.StatusOK { + t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Diagnostic: verify grant row exists + var grantCount int + database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount) + if grantCount == 0 { + t.Fatal("DIAG: resource_grant row missing after SET") + } + + // Diagnostic: verify group membership + var memberCount int + database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount) + if memberCount == 0 { + t.Fatal("DIAG: group_members row missing") + } + + // Diagnostic: verify persona exists + var personaExists bool + database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists) + if !personaExists { + t.Fatal("DIAG: persona not found or not active") + } + + // Diagnostic: run the subquery directly + var subqCount int + database.DB.QueryRow(` + SELECT COUNT(*) 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) + )) + ) + `, userID).Scan(&subqCount) + t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s", + grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID) + + // ── User should NOW see team persona via group grant ── + w = h.request("GET", "/api/v1/presets", userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String()) + } + decode(w, &presetsResp) + presets, _ = presetsResp["presets"].([]interface{}) + found := false + for _, p := range presets { + pm := p.(map[string]interface{}) + if pm["id"] == personaID { + found = true + break + } + } + if !found { + t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String()) + } + + // ── Remove user from group → should lose access ── + w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil) + + w = h.request("GET", "/api/v1/presets", userToken, nil) + decode(w, &presetsResp) + presets, _ = presetsResp["presets"].([]interface{}) + for _, p := range presets { + pm := p.(map[string]interface{}) + if pm["id"] == personaID { + t.Fatalf("user should NOT see persona after group removal") + } + } +} + diff --git a/server/main.go b/server/main.go index 224556f..5e39c76 100644 --- a/server/main.go +++ b/server/main.go @@ -344,6 +344,10 @@ func main() { teams := handlers.NewTeamHandler(keyResolver) protected.GET("/teams/mine", teams.MyTeams) + // Groups (user: my groups — v0.16.0) + groupH := handlers.NewGroupHandler(stores) + protected.GET("/groups/mine", groupH.MyGroups) + // Team admin self-service teamScoped := protected.Group("/teams/:teamId") teamScoped.Use(middleware.RequireTeamAdmin()) @@ -354,6 +358,9 @@ func main() { teamScoped.DELETE("/members/:memberId", teams.RemoveMember) teamScoped.GET("/models", teams.ListAvailableModels) + // Team groups (team admins manage team-scoped groups) + teamScoped.GET("/groups", groupH.ListTeamGroups) + // Team providers teamScoped.GET("/providers", teams.ListTeamProviders) teamScoped.POST("/providers", teams.CreateTeamProvider) @@ -455,6 +462,22 @@ func main() { admin.GET("/audit", adm.ListAuditLog) admin.GET("/audit/actions", adm.ListAuditActions) + // Groups (admin — v0.16.0) + groupAdm := handlers.NewGroupHandler(stores) + admin.GET("/groups", groupAdm.ListGroups) + admin.POST("/groups", groupAdm.CreateGroup) + admin.GET("/groups/:id", groupAdm.GetGroup) + admin.PUT("/groups/:id", groupAdm.UpdateGroup) + admin.DELETE("/groups/:id", groupAdm.DeleteGroup) + admin.GET("/groups/:id/members", groupAdm.ListMembers) + admin.POST("/groups/:id/members", groupAdm.AddMember) + admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember) + + // Resource Grants (admin — v0.16.0) + admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant) + admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) + admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) + // Model Roles rolesH := handlers.NewRolesHandler(stores, roleResolver) admin.GET("/roles", rolesH.ListRoles) diff --git a/server/models/models.go b/server/models/models.go index 32951a1..7aaa542 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -623,6 +623,60 @@ func Float64Ptr(f float64) *float64 { return &f } func IntPtr(i int) *int { return &i } func BoolPtr(b bool) *bool { return &b } +// ========================================= +// GROUPS (v0.16.0) +// ========================================= + +// Group scope constants (reuses ScopeGlobal, ScopeTeam from above) + +// ResourceType constants for resource_grants +const ( + ResourceTypePersona = "persona" + ResourceTypeKnowledgeBase = "knowledge_base" +) + +// GrantScope constants for resource_grants +const ( + GrantScopeTeamOnly = "team_only" + GrantScopeGlobal = "global" + GrantScopeGroups = "groups" +) + +// Group is an access-control list. Decouples resource visibility from teams. +type Group struct { + BaseModel + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Scope string `json:"scope" db:"scope"` // global, team + TeamID *string `json:"team_id,omitempty" db:"team_id"` + CreatedBy string `json:"created_by" db:"created_by"` + MemberCount int `json:"member_count,omitempty"` // computed, not a DB column +} + +// GroupMember links a user to a group. +type GroupMember struct { + ID string `json:"id" db:"id"` + GroupID string `json:"group_id" db:"group_id"` + UserID string `json:"user_id" db:"user_id"` + AddedBy string `json:"added_by" db:"added_by"` + AddedAt time.Time `json:"added_at" db:"added_at"` + // Joined fields from users table (for list responses) + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +// ResourceGrant controls who can USE a resource (personas, KBs) via groups. +// One row per resource. Not to be confused with persona_grants (what a Persona can DO). +type ResourceGrant struct { + BaseModel + ResourceType string `json:"resource_type" db:"resource_type"` + ResourceID string `json:"resource_id" db:"resource_id"` + GrantScope string `json:"grant_scope" db:"grant_scope"` + GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID array + CreatedBy string `json:"created_by" db:"created_by"` +} + // ── Knowledge Bases ──────────────────────────── // KnowledgeBase is a named collection of documents with vector embeddings. diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 05d9076..fb351af 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -35,6 +35,8 @@ type Stores struct { Extensions ExtensionStore Attachments AttachmentStore KnowledgeBases KnowledgeBaseStore + Groups GroupStore + ResourceGrants ResourceGrantStore } // ========================================= @@ -401,6 +403,44 @@ type KnowledgeBaseStore interface { UpdateStats(ctx context.Context, kbID string) error } +// ========================================= +// GROUP STORE (v0.16.0) +// ========================================= + +type GroupStore interface { + Create(ctx context.Context, g *models.Group) error + GetByID(ctx context.Context, id string) (*models.Group, error) + Update(ctx context.Context, id string, name, description *string) error + Delete(ctx context.Context, id string) error + + // Scoped listing + ListAll(ctx context.Context) ([]models.Group, error) // admin + ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) // team admin + ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I belong to + + // Members + AddMember(ctx context.Context, groupID, userID, addedBy string) error + RemoveMember(ctx context.Context, groupID, userID string) error + ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) + IsMember(ctx context.Context, groupID, userID string) (bool, error) + + // For access resolution: returns group IDs a user belongs to + GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) +} + +// ========================================= +// RESOURCE GRANT STORE (v0.16.0) +// ========================================= + +type ResourceGrantStore interface { + Set(ctx context.Context, grant *models.ResourceGrant) error + Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) + Delete(ctx context.Context, resourceType, resourceID string) error + + // Access check: does userID have group-based access to this resource? + UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) +} + // ========================================= // SHARED TYPES // ========================================= diff --git a/server/store/postgres/groups.go b/server/store/postgres/groups.go new file mode 100644 index 0000000..9d3c22b --- /dev/null +++ b/server/store/postgres/groups.go @@ -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() +} diff --git a/server/store/postgres/knowledge_bases.go b/server/store/postgres/knowledge_bases.go index 0a0ca15..674d892 100644 --- a/server/store/postgres/knowledge_bases.go +++ b/server/store/postgres/knowledge_bases.go @@ -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...) } diff --git a/server/store/postgres/persona.go b/server/store/postgres/persona.go index 6d5a38f..04f14a7 100644 --- a/server/store/postgres/persona.go +++ b/server/store/postgres/persona.go @@ -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 diff --git a/server/store/postgres/resource_grants.go b/server/store/postgres/resource_grants.go new file mode 100644 index 0000000..7f9f20a --- /dev/null +++ b/server/store/postgres/resource_grants.go @@ -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 +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 0089c71..b80804a 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -28,5 +28,7 @@ func NewStores(db *sql.DB) store.Stores { Extensions: NewExtensionStore(), Attachments: NewAttachmentStore(), KnowledgeBases: NewKnowledgeBaseStore(), + Groups: NewGroupStore(), + ResourceGrants: NewResourceGrantStore(), } } diff --git a/src/index.html b/src/index.html index 5929f25..8791be7 100644 --- a/src/index.html +++ b/src/index.html @@ -516,6 +516,7 @@ + @@ -651,6 +657,43 @@
+