Changeset 0.16.0 (#74)
This commit is contained in:
795
docs/DESIGN-0.16.0.md
Normal file
795
docs/DESIGN-0.16.0.md
Normal 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.
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================
|
# ============================================
|
||||||
# Chat Switchboard - Schema Validation (v0.9)
|
# Chat Switchboard - Schema Validation (v0.16)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Verifies the database schema is correct after
|
# Verifies the database schema is correct after
|
||||||
# migration. Checks expected tables, key columns,
|
# 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:
|
# Required env vars:
|
||||||
# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE
|
# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE
|
||||||
@@ -20,10 +21,12 @@ echo "Schema validation: ${PGDATABASE}"
|
|||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
ERRORS=0
|
ERRORS=0
|
||||||
|
CHECKS=0
|
||||||
|
|
||||||
# ── Helper: check table exists ───────────────
|
# ── Helper: check table exists ───────────────
|
||||||
check_table() {
|
check_table() {
|
||||||
local table="$1"
|
local table="$1"
|
||||||
|
CHECKS=$((CHECKS + 1))
|
||||||
local exists
|
local exists
|
||||||
exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';")
|
exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';")
|
||||||
if [[ "${exists}" == "1" ]]; then
|
if [[ "${exists}" == "1" ]]; then
|
||||||
@@ -34,10 +37,25 @@ check_table() {
|
|||||||
fi
|
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 ──────────────
|
# ── Helper: check column exists ──────────────
|
||||||
check_column() {
|
check_column() {
|
||||||
local table="$1"
|
local table="$1"
|
||||||
local column="$2"
|
local column="$2"
|
||||||
|
CHECKS=$((CHECKS + 1))
|
||||||
local exists
|
local exists
|
||||||
exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';")
|
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
|
if [[ "${exists}" == "1" ]]; then
|
||||||
@@ -48,9 +66,70 @@ check_column() {
|
|||||||
fi
|
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 ──────────────────
|
# ── Helper: check extension ──────────────────
|
||||||
check_extension() {
|
check_extension() {
|
||||||
local ext="$1"
|
local ext="$1"
|
||||||
|
CHECKS=$((CHECKS + 1))
|
||||||
local exists
|
local exists
|
||||||
exists=$(psql -tAc "SELECT 1 FROM pg_extension WHERE extname='${ext}';")
|
exists=$(psql -tAc "SELECT 1 FROM pg_extension WHERE extname='${ext}';")
|
||||||
if [[ "${exists}" == "1" ]]; then
|
if [[ "${exists}" == "1" ]]; then
|
||||||
@@ -61,12 +140,17 @@ check_extension() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 1. Extensions ────────────────────────────
|
# ═══════════════════════════════════════════
|
||||||
echo ""
|
# CHECKS
|
||||||
echo "Extensions:"
|
# ═══════════════════════════════════════════
|
||||||
check_extension "pgcrypto"
|
|
||||||
|
|
||||||
# ── 2. Migration tracking ───────────────────
|
# ── Extensions ────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "PostgreSQL Extensions:"
|
||||||
|
check_extension "pgcrypto"
|
||||||
|
check_extension "vector"
|
||||||
|
|
||||||
|
# ── Migration tracking ───────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Migration tracking:"
|
echo "Migration tracking:"
|
||||||
check_table "schema_migrations"
|
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")
|
LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none")
|
||||||
echo " ✓ latest: ${LATEST}"
|
echo " ✓ latest: ${LATEST}"
|
||||||
|
|
||||||
# ── 3. Core tables ──────────────────────────
|
# ── Core tables ──────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Core tables:"
|
echo "Core tables:"
|
||||||
check_table "users"
|
check_table "users"
|
||||||
check_table "provider_configs"
|
check_table "refresh_tokens"
|
||||||
check_table "channels"
|
|
||||||
check_table "messages"
|
|
||||||
check_table "teams"
|
check_table "teams"
|
||||||
check_table "team_members"
|
check_table "team_members"
|
||||||
check_table "refresh_tokens"
|
check_table "groups"
|
||||||
check_table "global_settings"
|
check_table "group_members"
|
||||||
|
check_table "provider_configs"
|
||||||
# ── 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 "model_catalog"
|
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_table "personas"
|
||||||
check_column "personas" "scope"
|
check_table "persona_grants"
|
||||||
check_column "personas" "owner_id"
|
check_table "resource_grants"
|
||||||
check_column "personas" "name"
|
check_table "platform_policies"
|
||||||
check_column "personas" "base_model_id"
|
check_table "global_settings"
|
||||||
check_column "personas" "provider_config_id"
|
check_table "user_model_settings"
|
||||||
check_column "personas" "system_prompt"
|
check_table "channels"
|
||||||
check_column "personas" "created_by"
|
check_table "messages"
|
||||||
|
|
||||||
# ── 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 "channel_members"
|
check_table "channel_members"
|
||||||
check_table "channel_models"
|
check_table "channel_models"
|
||||||
check_table "channel_cursors"
|
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 ""
|
||||||
echo "Messages:"
|
echo "Dropped tables (v0.16.0 cleanup):"
|
||||||
check_column "messages" "channel_id"
|
check_table_absent "projects"
|
||||||
check_column "messages" "role"
|
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" "parent_id"
|
||||||
check_column "messages" "sibling_index"
|
check_column "messages" "sibling_index"
|
||||||
check_column "messages" "deleted_at"
|
check_column "messages" "tool_calls"
|
||||||
check_column "messages" "participant_type"
|
check_column "messages" "metadata"
|
||||||
|
|
||||||
# ── 10. Organization ────────────────────────
|
# ── Notes ────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Organization:"
|
echo "Notes:"
|
||||||
check_table "folders"
|
|
||||||
check_table "projects"
|
|
||||||
check_table "notes"
|
|
||||||
check_column "notes" "search_vector"
|
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 ""
|
||||||
echo "User Model Settings:"
|
echo "Extensions:"
|
||||||
check_table "user_model_settings"
|
check_column "extensions" "ext_id"
|
||||||
check_column "user_model_settings" "user_id"
|
check_column "extensions" "tier"
|
||||||
check_column "user_model_settings" "model_id"
|
check_column "extensions" "manifest"
|
||||||
check_column "user_model_settings" "hidden"
|
check_column "extensions" "is_system"
|
||||||
check_column "user_model_settings" "sort_order"
|
check_column "extensions" "scope"
|
||||||
|
|
||||||
# ── 12. Audit Log ───────────────────────────
|
# ── Attachments ──────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Audit Log:"
|
echo "Attachments:"
|
||||||
check_table "audit_log"
|
check_column "attachments" "channel_id"
|
||||||
check_column "audit_log" "actor_id"
|
check_column "attachments" "storage_key"
|
||||||
check_column "audit_log" "action"
|
check_column "attachments" "extracted_text"
|
||||||
check_column "audit_log" "resource_type"
|
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
|
# ADD NEW MIGRATION CHECKS ABOVE THIS LINE
|
||||||
@@ -190,11 +340,11 @@ check_column "audit_log" "resource_type"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
if [[ ${ERRORS} -eq 0 ]]; then
|
if [[ ${ERRORS} -eq 0 ]]; then
|
||||||
echo "✅ Schema validation passed (${MIGRATION_COUNT} migrations)"
|
echo "✅ Schema validation passed: ${CHECKS}/${CHECKS} checks OK"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
echo "❌ Schema validation FAILED: ${ERRORS} errors"
|
echo "❌ Schema validation FAILED: ${ERRORS}/${CHECKS} checks failed"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Chat Switchboard — v0.9.0 Consolidated Schema
|
-- Chat Switchboard — v0.16.0 Consolidated Schema
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Clean-slate schema. Replaces all 001–021 migrations.
|
-- Clean-slate schema. Replaces all 001–009 migrations.
|
||||||
-- Drop DB and re-create before applying.
|
-- 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:
|
-- Design principles:
|
||||||
-- • Explicit scope enums over nullable column tri-states
|
-- • Explicit scope enums over nullable column tri-states
|
||||||
-- • Personas as trust boundaries with extensible grants
|
-- • Personas as trust boundaries with extensible grants
|
||||||
|
-- • Groups as pure access-control lists (decouple from teams)
|
||||||
-- • Secure by default (models hidden until admin enables)
|
-- • Secure by default (models hidden until admin enables)
|
||||||
-- • Only tables with active handlers — no placeholder tables
|
-- • Only tables with active handlers — no placeholder tables
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -14,6 +24,23 @@
|
|||||||
-- ── Extensions ──────────────────────────────
|
-- ── Extensions ──────────────────────────────
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings)
|
||||||
|
|
||||||
|
-- ── Upgrade-path cleanup ───────────────────
|
||||||
|
-- These objects existed in the 001–009 migration chain but were removed
|
||||||
|
-- in the v0.16.0 consolidation. Safe no-ops on fresh installs.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS project_channels CASCADE;
|
||||||
|
DROP TABLE IF EXISTS projects CASCADE;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE provider_configs DROP COLUMN api_key_plain;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- ── Utility: auto-update updated_at ─────────
|
-- ── Utility: auto-update updated_at ─────────
|
||||||
|
|
||||||
@@ -32,26 +59,43 @@ $$ LANGUAGE plpgsql;
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
username VARCHAR(50) NOT NULL,
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
display_name VARCHAR(100),
|
display_name VARCHAR(100),
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
role VARCHAR(20) DEFAULT 'user'
|
role VARCHAR(20) DEFAULT 'user'
|
||||||
CHECK (role IN ('user', 'admin', 'moderator')),
|
CHECK (role IN ('user', 'admin')),
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
settings JSONB DEFAULT '{}'::jsonb,
|
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(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
last_login_at TIMESTAMPTZ
|
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_username ON users(username);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
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
|
-- 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='global': admin-managed, visible to all users (owner_id IS NULL)
|
||||||
-- scope='team': team admin-managed (owner_id = teams.id)
|
-- scope='team': team admin-managed (owner_id = teams.id)
|
||||||
-- scope='personal': user's own keys (owner_id = users.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,
|
name VARCHAR(100) NOT NULL,
|
||||||
provider VARCHAR(50) NOT NULL,
|
provider VARCHAR(50) NOT NULL,
|
||||||
endpoint TEXT 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),
|
model_default VARCHAR(100),
|
||||||
config JSONB DEFAULT '{}'::jsonb,
|
config JSONB DEFAULT '{}'::jsonb,
|
||||||
headers 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.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.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.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.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)';
|
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.
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||||
model_id TEXT NOT NULL,
|
model_id TEXT NOT NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
model_type VARCHAR(20) DEFAULT 'chat',
|
||||||
capabilities JSONB NOT NULL DEFAULT '{}',
|
capabilities JSONB NOT NULL DEFAULT '{}',
|
||||||
pricing JSONB,
|
pricing JSONB,
|
||||||
visibility VARCHAR(10) DEFAULT 'disabled'
|
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_provider ON model_catalog(provider_config_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
|
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||||
-- 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 $$;
|
|
||||||
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
|
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
|
||||||
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
|
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
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"}';
|
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 (
|
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 (
|
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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_persona ON persona_grants(persona_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
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.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';
|
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 (
|
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 (
|
CREATE TABLE IF NOT EXISTS global_settings (
|
||||||
key VARCHAR(100) PRIMARY KEY,
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
@@ -297,7 +430,6 @@ CREATE TABLE IF NOT EXISTS global_settings (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Seed defaults
|
|
||||||
INSERT INTO global_settings (key, value) VALUES
|
INSERT INTO global_settings (key, value) VALUES
|
||||||
('registration', '{"enabled": true}'::jsonb),
|
('registration', '{"enabled": true}'::jsonb),
|
||||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::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" },
|
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||||
"demo": { "text": "DEMO", "bg": "#fce83a", "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)
|
}'::jsonb)
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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 (
|
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 (
|
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,
|
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
is_archived BOOLEAN DEFAULT false,
|
is_archived BOOLEAN DEFAULT false,
|
||||||
is_pinned BOOLEAN DEFAULT false,
|
is_pinned BOOLEAN DEFAULT false,
|
||||||
folder_id UUID, -- FK added after folders table
|
folder_id UUID, -- FK added after folders table created
|
||||||
folder TEXT, -- backward compat: simple text folder name
|
folder TEXT, -- simple text folder name (frontend-managed)
|
||||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
settings JSONB DEFAULT '{}'::jsonb,
|
settings JSONB DEFAULT '{}'::jsonb,
|
||||||
tags TEXT[],
|
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 (
|
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 (
|
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 (
|
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 (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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
|
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
-- Now add the FK from channels
|
-- Deferred FK: channels.folder_id → folders.id
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
|
||||||
|
) THEN
|
||||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
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 (
|
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,
|
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
search_vector TSVECTOR,
|
search_vector TSVECTOR,
|
||||||
|
embedding VECTOR(3072),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_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 (
|
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);
|
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';
|
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)
|
||||||
|
);
|
||||||
@@ -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));
|
|
||||||
@@ -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';
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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);
|
|
||||||
@@ -165,6 +165,9 @@ func TruncateAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Order matters due to foreign keys — truncate with CASCADE
|
// Order matters due to foreign keys — truncate with CASCADE
|
||||||
tables := []string{
|
tables := []string{
|
||||||
|
"resource_grants",
|
||||||
|
"group_members",
|
||||||
|
"groups",
|
||||||
"usage_log",
|
"usage_log",
|
||||||
"model_pricing",
|
"model_pricing",
|
||||||
"notes",
|
"notes",
|
||||||
@@ -256,6 +259,64 @@ func SeedTestChannel(t *testing.T, userID, title string) string {
|
|||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
// ── 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 {
|
func envOr(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
365
server/handlers/groups.go
Normal file
365
server/handlers/groups.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
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.GET("/presets", presets.ListAdminPersonas)
|
||||||
admin.POST("/presets", presets.CreateAdminPersona)
|
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
|
// Admin roles
|
||||||
rolesH := NewRolesHandler(stores, roleResolver)
|
rolesH := NewRolesHandler(stores, roleResolver)
|
||||||
admin.GET("/roles", rolesH.ListRoles)
|
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))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,10 @@ func main() {
|
|||||||
teams := handlers.NewTeamHandler(keyResolver)
|
teams := handlers.NewTeamHandler(keyResolver)
|
||||||
protected.GET("/teams/mine", teams.MyTeams)
|
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
|
// Team admin self-service
|
||||||
teamScoped := protected.Group("/teams/:teamId")
|
teamScoped := protected.Group("/teams/:teamId")
|
||||||
teamScoped.Use(middleware.RequireTeamAdmin())
|
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||||
@@ -354,6 +358,9 @@ func main() {
|
|||||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||||
teamScoped.GET("/models", teams.ListAvailableModels)
|
teamScoped.GET("/models", teams.ListAvailableModels)
|
||||||
|
|
||||||
|
// Team groups (team admins manage team-scoped groups)
|
||||||
|
teamScoped.GET("/groups", groupH.ListTeamGroups)
|
||||||
|
|
||||||
// Team providers
|
// Team providers
|
||||||
teamScoped.GET("/providers", teams.ListTeamProviders)
|
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||||
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||||
@@ -455,6 +462,22 @@ func main() {
|
|||||||
admin.GET("/audit", adm.ListAuditLog)
|
admin.GET("/audit", adm.ListAuditLog)
|
||||||
admin.GET("/audit/actions", adm.ListAuditActions)
|
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
|
// Model Roles
|
||||||
rolesH := handlers.NewRolesHandler(stores, roleResolver)
|
rolesH := handlers.NewRolesHandler(stores, roleResolver)
|
||||||
admin.GET("/roles", rolesH.ListRoles)
|
admin.GET("/roles", rolesH.ListRoles)
|
||||||
|
|||||||
@@ -623,6 +623,60 @@ func Float64Ptr(f float64) *float64 { return &f }
|
|||||||
func IntPtr(i int) *int { return &i }
|
func IntPtr(i int) *int { return &i }
|
||||||
func BoolPtr(b bool) *bool { return &b }
|
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 ────────────────────────────
|
// ── Knowledge Bases ────────────────────────────
|
||||||
|
|
||||||
// KnowledgeBase is a named collection of documents with vector embeddings.
|
// KnowledgeBase is a named collection of documents with vector embeddings.
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Stores struct {
|
|||||||
Extensions ExtensionStore
|
Extensions ExtensionStore
|
||||||
Attachments AttachmentStore
|
Attachments AttachmentStore
|
||||||
KnowledgeBases KnowledgeBaseStore
|
KnowledgeBases KnowledgeBaseStore
|
||||||
|
Groups GroupStore
|
||||||
|
ResourceGrants ResourceGrantStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -401,6 +403,44 @@ type KnowledgeBaseStore interface {
|
|||||||
UpdateStats(ctx context.Context, kbID string) error
|
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
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
206
server/store/postgres/groups.go
Normal file
206
server/store/postgres/groups.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
|
|||||||
// ── Scoped Listing ──────────────────────────────
|
// ── Scoped Listing ──────────────────────────────
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
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 := `
|
q := `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
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))
|
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`
|
q += ` ORDER BY name`
|
||||||
return queryKBs(ctx, q, args...)
|
return queryKBs(ctx, q, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (s *PersonaStore) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListForUser returns all Personas visible to a user:
|
// 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) {
|
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
|
||||||
rows, err := DB.QueryContext(ctx,
|
rows, err := DB.QueryContext(ctx,
|
||||||
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
|
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
|
SELECT team_id FROM team_members WHERE user_id = $1
|
||||||
))
|
))
|
||||||
OR (scope = 'personal' AND is_shared = true)
|
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)
|
) ORDER BY scope, name`, personaCols), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
SELECT team_id FROM team_members WHERE user_id = $1
|
||||||
))
|
))
|
||||||
OR (scope = 'personal' AND is_shared = true)
|
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)
|
)`, userID, personaID).Scan(&exists)
|
||||||
return exists, err
|
return exists, err
|
||||||
|
|||||||
95
server/store/postgres/resource_grants.go
Normal file
95
server/store/postgres/resource_grants.go
Normal 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
|
||||||
|
}
|
||||||
@@ -28,5 +28,7 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Extensions: NewExtensionStore(),
|
Extensions: NewExtensionStore(),
|
||||||
Attachments: NewAttachmentStore(),
|
Attachments: NewAttachmentStore(),
|
||||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||||
|
Groups: NewGroupStore(),
|
||||||
|
ResourceGrants: NewResourceGrantStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,6 +516,7 @@
|
|||||||
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
|
<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="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="knowledge" onclick="UI.switchTeamTab('knowledge')">Knowledge</button>
|
||||||
|
<button class="admin-tab" data-ttab="groups" onclick="UI.switchTeamTab('groups')">Groups</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Members tab -->
|
<!-- Members tab -->
|
||||||
@@ -578,6 +579,11 @@
|
|||||||
<div class="team-tab-content" id="teamTabKnowledge" style="display:none">
|
<div class="team-tab-content" id="teamTabKnowledge" style="display:none">
|
||||||
<div id="teamKBContent"></div>
|
<div id="teamKBContent"></div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,6 +657,43 @@
|
|||||||
<div id="adminMemberList"></div>
|
<div id="adminMemberList"></div>
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<div id="adminRolesContent">
|
||||||
|
|||||||
@@ -400,6 +400,42 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
|
|||||||
|
|
||||||
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
|
// ── 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) ──
|
// ── Admin Listeners (extracted from initListeners) ──
|
||||||
|
|
||||||
function _initAdminListeners() {
|
function _initAdminListeners() {
|
||||||
@@ -509,6 +545,66 @@ function _initAdminListeners() {
|
|||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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 ────────────────────
|
// ── Extension management ────────────────────
|
||||||
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
|
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
|
||||||
document.getElementById('adminInstallExtForm').style.display = '';
|
document.getElementById('adminInstallExtForm').style.display = '';
|
||||||
|
|||||||
@@ -413,6 +413,30 @@ const API = {
|
|||||||
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
|
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}`); },
|
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 ──────────────────────────
|
// ── User Teams ──────────────────────────
|
||||||
listMyTeams() { return this._get('/api/v1/teams/mine'); },
|
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); },
|
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||||
|
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
||||||
|
|
||||||
// ── Team Providers ──────────────────────
|
// ── Team Providers ──────────────────────
|
||||||
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
|
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
|
||||||
|
|||||||
@@ -182,13 +182,14 @@ const KnowledgeUI = (() => {
|
|||||||
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
|
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
|
||||||
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
|
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
|
||||||
html += `
|
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">
|
<div class="kb-manage-info">
|
||||||
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
|
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
|
||||||
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
|
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
|
||||||
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
|
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kb-manage-actions">
|
<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" data-kb-docs="${kb.id}" title="Documents">📄</button>
|
||||||
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
|
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +212,14 @@ const KnowledgeUI = (() => {
|
|||||||
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
|
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
|
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 ─────────────────────
|
// ── Create KB Dialog ─────────────────────
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
// ── Category → Section mapping ────────────
|
// ── Category → Section mapping ────────────
|
||||||
const ADMIN_SECTIONS = {
|
const ADMIN_SECTIONS = {
|
||||||
people: ['users', 'teams'],
|
people: ['users', 'teams', 'groups'],
|
||||||
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
|
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
|
||||||
system: ['settings', 'storage', 'extensions'],
|
system: ['settings', 'storage', 'extensions'],
|
||||||
monitoring: ['usage', 'audit', 'stats'],
|
monitoring: ['usage', 'audit', 'stats'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADMIN_LABELS = {
|
const ADMIN_LABELS = {
|
||||||
users: 'Users', teams: 'Teams',
|
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||||
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
|
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
|
||||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||||
@@ -23,6 +23,7 @@ const ADMIN_LABELS = {
|
|||||||
const ADMIN_LOADERS = {
|
const ADMIN_LOADERS = {
|
||||||
users: () => UI.loadAdminUsers(),
|
users: () => UI.loadAdminUsers(),
|
||||||
teams: () => UI.loadAdminTeams(),
|
teams: () => UI.loadAdminTeams(),
|
||||||
|
groups: () => UI.loadAdminGroups(),
|
||||||
roles: () => UI.loadAdminRoles(),
|
roles: () => UI.loadAdminRoles(),
|
||||||
providers: () => UI.loadAdminProviders(),
|
providers: () => UI.loadAdminProviders(),
|
||||||
models: () => UI.loadAdminModels(),
|
models: () => UI.loadAdminModels(),
|
||||||
@@ -187,6 +188,7 @@ Object.assign(UI, {
|
|||||||
KnowledgeUI.openTeamPanel(teamId);
|
KnowledgeUI.openTeamPanel(teamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (tab === 'groups') UI.loadTeamManageGroups(teamId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadAdminUsers(quiet) {
|
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 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>';
|
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">
|
<div class="preset-info">
|
||||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
||||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
<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>` : ''}
|
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="preset-actions">
|
<div class="preset-actions">
|
||||||
|
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona="${p.id}"]')" title="Manage access">🔒 Access</button>
|
||||||
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</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="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>
|
<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>`; }
|
} 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() {
|
async loadAdminSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await API.adminGetSettings();
|
const data = await API.adminGetSettings();
|
||||||
|
|||||||
@@ -301,6 +301,24 @@ Object.assign(UI, {
|
|||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} 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() {
|
async loadMyUsage() {
|
||||||
const el = document.getElementById('myUsageTotals')?.parentElement;
|
const el = document.getElementById('myUsageTotals')?.parentElement;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user