Changeset 0.16.0 (#74)

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

795
docs/DESIGN-0.16.0.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -1,12 +1,22 @@
-- ==========================================
-- Chat Switchboard — v0.9.0 Consolidated Schema
-- Chat Switchboard — v0.16.0 Consolidated Schema
-- ==========================================
-- Clean-slate schema. Replaces all 001021 migrations.
-- Clean-slate schema. Replaces all 001009 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 001009 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
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;
EXCEPTION WHEN duplicate_object THEN 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)
);

View File

@@ -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));

View File

@@ -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';

View File

@@ -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)
);

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -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);

View File

@@ -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

365
server/handlers/groups.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
// =========================================

View File

@@ -0,0 +1,206 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── GroupStore ──────────────────────────────
type GroupStore struct{}
func NewGroupStore() *GroupStore { return &GroupStore{} }
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
return DB.QueryRowContext(ctx, `
INSERT INTO groups (name, description, scope, team_id, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), g.CreatedBy,
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
}
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
FROM groups g WHERE g.id = $1`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount,
)
if err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
return &g, nil
}
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
b := NewUpdate("groups")
if name != nil {
b.Set("name", *name)
}
if description != nil {
b.Set("description", *description)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
res, err := b.Exec(DB)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *GroupStore) Delete(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// ── Scoped Listing ──────────────────────────
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
ORDER BY g.scope, g.name`)
}
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
WHERE g.scope = 'team' AND g.team_id = $1
ORDER BY g.name`, teamID)
}
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
ORDER BY g.scope, g.name`, userID)
}
// ── Members ─────────────────────────────────
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO group_members (group_id, user_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, user_id) DO NOTHING`,
groupID, userID, addedBy)
return err
}
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
res, err := DB.ExecContext(ctx,
"DELETE FROM group_members WHERE group_id = $1 AND user_id = $2",
groupID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at,
u.username, u.email, COALESCE(u.display_name, '')
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY u.username`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.GroupMember
for rows.Next() {
var m models.GroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, &m.AddedAt,
&m.Username, &m.Email, &m.DisplayName); err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`,
groupID, userID).Scan(&exists)
return exists, err
}
func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT group_id FROM group_members WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Scanners ────────────────────────────────
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("queryGroups: %w", err)
}
defer rows.Close()
var result []models.Group
for rows.Next() {
var g models.Group
var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
result = append(result, g)
}
return result, rows.Err()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -516,6 +516,7 @@
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
<button class="admin-tab" data-ttab="knowledge" onclick="UI.switchTeamTab('knowledge')">Knowledge</button>
<button class="admin-tab" data-ttab="groups" onclick="UI.switchTeamTab('groups')">Groups</button>
</div>
<div class="modal-body">
<!-- Members tab -->
@@ -578,6 +579,11 @@
<div class="team-tab-content" id="teamTabKnowledge" style="display:none">
<div id="teamKBContent"></div>
</div>
<!-- Groups tab -->
<div class="team-tab-content" id="teamTabGroups" style="display:none">
<p class="text-muted" style="font-size:12px;margin:0 0 8px">Groups assigned to this team. Contact a system admin to create or modify groups.</p>
<div id="settingsTeamGroups"></div>
</div>
</div>
</div>
</div>
@@ -651,6 +657,43 @@
<div id="adminMemberList"></div>
</div>
</div>
<div class="admin-section-content" id="adminGroupsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddGroupBtn">+ New Group</button>
</div>
<div id="adminAddGroupForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Group Name</label><input type="text" id="adminGroupName" placeholder="Engineering"></div>
<div class="form-group"><label>Scope</label>
<select id="adminGroupScope">
<option value="global">Global</option>
<option value="team">Team</option>
</select>
</div>
</div>
<div class="form-group" id="adminGroupTeamRow" style="display:none">
<label>Team</label><select id="adminGroupTeam"><option value="">Select team...</option></select>
</div>
<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Access group for engineering resources"></div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateGroupBtn">Create</button><button class="btn-small" id="adminCancelGroupBtn">Cancel</button></div>
</div>
<div id="adminGroupList"></div>
<div id="adminGroupDetail" style="display:none">
<button class="notes-back-btn" id="adminGroupBackBtn">← Back to groups</button>
<h3 id="adminGroupDetailName" style="margin:8px 0 4px;font-size:15px"></h3>
<p id="adminGroupDetailMeta" class="text-muted" style="font-size:12px;margin:0 0 12px"></p>
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddGroupMemberBtn">+ Add Member</button>
</div>
<div id="adminAddGroupMemberForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="adminGroupMemberUser"></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminAddGroupMemberSubmit">Add</button><button class="btn-small" id="adminCancelGroupMemberBtn">Cancel</button></div>
</div>
<div id="adminGroupMemberList"></div>
</div>
</div>
<div class="admin-section-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">

View File

@@ -400,6 +400,42 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
// ── Group Actions ───────────────────────────
async function deleteGroup(id, name) {
if (!await showConfirm(`Delete group "${name}"? Members will lose access to any resources granted via this group.`)) return;
try { await API.adminDeleteGroup(id); UI.toast('Group deleted'); await UI.loadAdminGroups(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function removeGroupMember(groupId, userId, label) {
if (!await showConfirm(`Remove ${label} from group?`)) return;
try {
await API.adminRemoveGroupMember(groupId, userId);
UI.toast('Member removed');
await UI.loadGroupMembers(groupId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function saveGrant(resourceType, resourceId) {
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
if (!scopeSel) return;
const scope = scopeSel.value;
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
const selectedGroups = [...(groupsDiv?.querySelectorAll('.grant-group-cb:checked') || [])].map(cb => cb.value);
if (scope === 'groups' && selectedGroups.length === 0) {
return UI.toast('Select at least one group', 'error');
}
try {
await API.adminSetGrant(resourceType, resourceId, scope, selectedGroups);
// Remove the picker
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
UI.toast(scope === 'team_only' ? 'Access reverted to team only' : `Access set to ${scope}`);
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Listeners (extracted from initListeners) ──
function _initAdminListeners() {
@@ -509,6 +545,66 @@ function _initAdminListeners() {
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Group management ────────────────────────
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupForm').style.display = '';
document.getElementById('adminGroupName').focus();
// Populate team dropdown
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
const sel = document.getElementById('adminGroupTeam');
sel.innerHTML = '<option value="">Select team...</option>' +
teams.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
} catch (e) { /* proceed without teams */ }
});
document.getElementById('adminGroupScope')?.addEventListener('change', function() {
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
});
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupForm').style.display = 'none';
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
});
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminGroupName').value.trim();
const desc = document.getElementById('adminGroupDesc').value.trim();
const scope = document.getElementById('adminGroupScope').value;
const teamId = scope === 'team' ? document.getElementById('adminGroupTeam').value : null;
if (!name) return UI.toast('Group name required', 'error');
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
try {
await API.adminCreateGroup(name, desc, scope, teamId);
document.getElementById('adminAddGroupForm').style.display = 'none';
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
UI.toast('Group created');
await UI.loadAdminGroups(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminGroupBackBtn')?.addEventListener('click', () => {
document.getElementById('adminGroupDetail').style.display = 'none';
document.getElementById('adminGroupList').style.display = '';
UI.loadAdminGroups(true);
});
document.getElementById('adminAddGroupMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupMemberForm').style.display = '';
await UI.loadGroupMemberDropdown(UI._groupEditId);
});
document.getElementById('adminCancelGroupMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
});
document.getElementById('adminAddGroupMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminGroupMemberUser').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddGroupMember(UI._groupEditId, userId);
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadGroupMembers(UI._groupEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Extension management ────────────────────
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
document.getElementById('adminInstallExtForm').style.display = '';

View File

@@ -413,6 +413,30 @@ const API = {
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── Admin Groups ────────────────────────
adminListGroups() { return this._get('/api/v1/admin/groups'); },
adminCreateGroup(name, description, scope, teamId) {
const body = { name, description, scope };
if (teamId) body.team_id = teamId;
return this._post('/api/v1/admin/groups', body);
},
adminGetGroup(id) { return this._get(`/api/v1/admin/groups/${id}`); },
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
// ── Admin Resource Grants ───────────────
adminGetGrant(type, id) { return this._get(`/api/v1/admin/grants/${type}/${id}`); },
adminSetGrant(type, id, grantScope, grantedGroups) {
return this._put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: grantScope, granted_groups: grantedGroups || [] });
},
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
// ── User Groups ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
@@ -425,6 +449,7 @@ const API = {
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },

View File

@@ -182,13 +182,14 @@ const KnowledgeUI = (() => {
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}">
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${_esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
@@ -211,6 +212,14 @@ const KnowledgeUI = (() => {
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
});
// Wire grant access buttons (admin only, non-personal KBs)
panel.querySelectorAll('[data-kb-grant]').forEach(btn => {
btn.addEventListener('click', () => {
if (typeof UI !== 'undefined' && UI.openGrantPicker) {
UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`);
}
});
});
}
// ── Create KB Dialog ─────────────────────

View File

@@ -6,14 +6,14 @@
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams'],
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams',
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
@@ -23,6 +23,7 @@ const ADMIN_LABELS = {
const ADMIN_LOADERS = {
users: () => UI.loadAdminUsers(),
teams: () => UI.loadAdminTeams(),
groups: () => UI.loadAdminGroups(),
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
@@ -187,6 +188,7 @@ Object.assign(UI, {
KnowledgeUI.openTeamPanel(teamId);
}
}
if (tab === 'groups') UI.loadTeamManageGroups(teamId);
},
async loadAdminUsers(quiet) {
@@ -612,13 +614,14 @@ Object.assign(UI, {
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
return `<div class="admin-preset-row" data-grant-persona="${p.id}">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona=&quot;${p.id}&quot;]')" title="Manage access">🔒 Access</button>
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
@@ -725,6 +728,146 @@ Object.assign(UI, {
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Groups Admin ────────────────────────
_groupEditId: null,
async loadAdminGroups(quiet) {
const el = document.getElementById('adminGroupList');
const detail = document.getElementById('adminGroupDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddGroupForm').style.display = 'none';
}
try {
const resp = await API.adminListGroups();
const groups = resp.data || [];
el.innerHTML = groups.map(g => {
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>'
: `<span class="badge-team">team</span>`;
return `<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong> ${scopeBadge}</div>
<div class="text-muted">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openGroupDetail('${g.id}', '${esc(g.name)}')">Members</button>
<button class="btn-delete" onclick="deleteGroup('${g.id}', '${esc(g.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openGroupDetail(groupId, groupName) {
this._groupEditId = groupId;
document.getElementById('adminGroupList').style.display = 'none';
document.getElementById('adminAddGroupForm').style.display = 'none';
const detail = document.getElementById('adminGroupDetail');
detail.style.display = '';
document.getElementById('adminGroupDetailName').textContent = groupName;
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
// Load group details for meta line
try {
const g = await API.adminGetGroup(groupId);
const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group';
document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`;
} catch (e) { /* proceed */ }
await this.loadGroupMembers(groupId);
},
async loadGroupMembers(groupId) {
const el = document.getElementById('adminGroupMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListGroupMembers(groupId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.username || m.email)}</strong></div>
<div class="text-muted">${esc(m.email)} · added ${new Date(m.added_at).toLocaleDateString()}</div>
</div>
<div class="admin-user-actions">
<button class="btn-delete" onclick="removeGroupMember('${groupId}', '${m.user_id}', '${esc(m.username || m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this group</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadGroupMemberDropdown(groupId) {
const sel = document.getElementById('adminGroupMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Exclude existing members
const memberResp = await API.adminListGroupMembers(groupId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Grant Picker (shared for Personas + KBs) ──
async openGrantPicker(resourceType, resourceId, resourceName, anchorSelector) {
// Fetch current grant + all groups for the picker
const [grantResp, groupsResp] = await Promise.all([
API.adminGetGrant(resourceType, resourceId),
API.adminListGroups(),
]);
const grant = grantResp;
const groups = (groupsResp.data || []);
const currentScope = grant.grant_scope || 'team_only';
const currentGroups = new Set(grant.granted_groups || []);
const html = `<div class="admin-inline-form" style="margin:8px 0;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<div style="font-size:13px;font-weight:600;margin-bottom:8px">Access: ${esc(resourceName)}</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px">Scope</label>
<select id="grantScope_${resourceId}" class="inline-select" style="width:auto">
<option value="team_only" ${currentScope === 'team_only' ? 'selected' : ''}>Team Only (default)</option>
<option value="global" ${currentScope === 'global' ? 'selected' : ''}>Global (all users)</option>
<option value="groups" ${currentScope === 'groups' ? 'selected' : ''}>Specific Groups</option>
</select>
</div>
<div id="grantGroups_${resourceId}" style="${currentScope === 'groups' ? '' : 'display:none'}">
${groups.length ? groups.map(g => `<label class="checkbox-label" style="font-size:12px">
<input type="checkbox" class="grant-group-cb" value="${g.id}" ${currentGroups.has(g.id) ? 'checked' : ''}> ${esc(g.name)}
<span class="text-muted" style="font-size:11px">${g.scope === 'global' ? '(global)' : '(team)'}</span>
</label>`).join('') : '<span class="text-muted" style="font-size:12px">No groups exist yet — create one in People → Groups</span>'}
</div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="saveGrant('${resourceType}', '${resourceId}')">Save</button>
<button class="btn-small" onclick="this.closest('.admin-inline-form').remove()">Cancel</button>
</div>
</div>`;
// Remove any existing picker, then insert after the anchor row
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
const row = document.querySelector(anchorSelector);
if (row) {
const wrapper = document.createElement('div');
wrapper.className = 'grant-picker-active';
wrapper.innerHTML = html;
row.after(wrapper);
// Wire scope toggle
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
scopeSel?.addEventListener('change', () => {
groupsDiv.style.display = scopeSel.value === 'groups' ? '' : 'none';
});
}
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();

View File

@@ -301,6 +301,24 @@ Object.assign(UI, {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageGroups(teamId) {
const el = document.getElementById('settingsTeamGroups');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
el.innerHTML = groups.map(g => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong></div>
<div class="text-muted" style="font-size:11px">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
</div>
`).join('') || '<div class="empty-hint">No groups assigned to this team yet</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;