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.
|
||||
Reference in New Issue
Block a user