Changeset 0.16.0 (#74)

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

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

@@ -0,0 +1,795 @@
# DESIGN-0.16.0 — User Groups + Resource Grants
## Overview
The missing access-control primitive. Teams define organizational structure
(horizontal visibility). Roles define vertical permissions (admin/user).
**Groups** are pure access-control lists that decouple resource access from
team membership.
Today, resource visibility follows a rigid scope model: `global` (everyone),
`team` (team members), `personal` (owner only). This breaks down when:
- A KB should be visible to members from two different teams.
- A Persona should be accessible by a cross-functional working group.
- A future Project needs participants from multiple teams.
Groups solve this by adding a fourth access path — grant-by-group — without
changing the existing scope model. Existing `global/team/personal` access
continues to work unchanged. Groups are additive.
Depends on: teams (v0.10.0), knowledge bases (v0.14.0).
**Design principle: don't break the scope model.** Groups extend it. Every
existing query that filters by `scope + owner_id + team_id` remains valid.
Group membership is a parallel access path checked alongside scope, not a
replacement for it.
---
## 1. Schema
Migration: `010_groups.sql`
### 1.1 Groups Table
```sql
-- =========================================
-- USER GROUPS
-- =========================================
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope VARCHAR(20) NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team')),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Global groups: team_id must be NULL
-- Team groups: team_id must be set
CONSTRAINT groups_scope_team CHECK (
(scope = 'global' AND team_id IS NULL) OR
(scope = 'team' AND team_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
-- Unique name within scope (global names unique globally,
-- team names unique within team)
COMMENT ON TABLE groups IS
'Access-control groups. Decouple resource visibility from team membership.';
COMMENT ON COLUMN groups.scope IS
'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
```
### 1.2 Group Members Table
```sql
CREATE TABLE IF NOT EXISTS group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_by UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(group_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
```
### 1.3 Resource Grants Table
```sql
-- =========================================
-- RESOURCE GRANTS
-- =========================================
CREATE TABLE IF NOT EXISTS resource_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_type VARCHAR(30) NOT NULL
CHECK (resource_type IN ('persona', 'knowledge_base')),
resource_id UUID NOT NULL,
grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
granted_groups UUID[] NOT NULL DEFAULT '{}',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Only one grant row per resource
UNIQUE(resource_type, resource_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
ON resource_grants(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
ON resource_grants USING gin(granted_groups);
-- GIN index for UUID[] containment queries (&&, @>)
COMMENT ON TABLE resource_grants IS
'Controls cross-team access to resources (personas, KBs, future: projects).';
COMMENT ON COLUMN resource_grants.grant_scope IS
'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.';
COMMENT ON COLUMN resource_grants.granted_groups IS
'UUID array of group IDs. Only meaningful when grant_scope = groups.';
```
### 1.4 Triggers
```sql
-- Auto-update updated_at
CREATE TRIGGER groups_updated_at
BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER resource_grants_updated_at
BEFORE UPDATE ON resource_grants
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
### Design Decision: UUID[] vs Junction Table
The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is
the right call for this use case:
- A resource has exactly one grant row (UNIQUE constraint).
- The group list is always read/written as a unit (replace-all semantics).
- Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for
"does this resource grant overlap with the user's groups?" queries.
- Avoids a many-to-many junction table that would complicate the common
path (checking access) for marginal normalization benefit.
A junction table (`resource_grant_groups`) would be warranted if we needed
per-group config on grants (e.g., read-only vs read-write per group). We
don't — the grant is binary (access or no access). If that changes, we
can migrate the array to a junction table later.
---
## 2. Models
### 2.1 Group Models
```go
// ── Group Constants ────────────────────────
const (
GroupScopeGlobal = "global"
GroupScopeTeam = "team"
)
// ── Groups ─────────────────────────────────
type Group struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Computed fields (not stored in groups table)
MemberCount int `json:"member_count,omitempty"`
TeamName string `json:"team_name,omitempty"`
}
type GroupPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type GroupMember struct {
ID string `json:"id" db:"id"`
GroupID string `json:"group_id" db:"group_id"`
UserID string `json:"user_id" db:"user_id"`
AddedBy string `json:"added_by" db:"added_by"`
AddedAt string `json:"added_at" db:"added_at"`
// Joined from users table
Username string `json:"username,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Email string `json:"email,omitempty"`
}
```
### 2.2 Resource Grant Models
```go
// ── Resource Grant Constants ───────────────
const (
ResourceTypePersona = "persona"
ResourceTypeKnowledgeBase = "knowledge_base"
// Future: "project"
GrantScopeTeamOnly = "team_only"
GrantScopeGlobal = "global"
GrantScopeGroups = "groups"
)
// ResourceGrant controls cross-team access to a resource.
type ResourceGrant struct {
ID string `json:"id" db:"id"`
ResourceType string `json:"resource_type" db:"resource_type"`
ResourceID string `json:"resource_id" db:"resource_id"`
GrantScope string `json:"grant_scope" db:"grant_scope"`
GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID[]
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
```
---
## 3. Store Interfaces
### 3.1 GroupStore
```go
// =========================================
// GROUP STORE
// =========================================
type GroupStore interface {
// CRUD
Create(ctx context.Context, g *models.Group) error
GetByID(ctx context.Context, id string) (*models.Group, error)
Update(ctx context.Context, id string, patch models.GroupPatch) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Group, error) // admin view
ListForTeam(ctx context.Context, teamID string) ([]models.Group, error)
ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I'm in
// Members
AddMember(ctx context.Context, groupID, userID, addedBy string) error
RemoveMember(ctx context.Context, groupID, userID string) error
ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error)
IsMember(ctx context.Context, groupID, userID string) (bool, error)
// Bulk lookups (for access resolution)
GetUserGroupIDs(ctx context.Context, userID string) ([]string, error)
}
```
### 3.2 ResourceGrantStore
```go
// =========================================
// RESOURCE GRANT STORE
// =========================================
type ResourceGrantStore interface {
// Upsert: one grant row per resource (replace semantics)
Set(ctx context.Context, grant *models.ResourceGrant) error
Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error)
Delete(ctx context.Context, resourceType, resourceID string) error
// Access check: does user have group-based access to this resource?
UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error)
}
```
### 3.3 Wire into Stores Bundle
```go
type Stores struct {
// ... existing fields ...
Groups GroupStore
ResourceGrants ResourceGrantStore
}
```
---
## 4. Access Resolution
### 4.1 The Problem
Today, access checks look like this (Persona example):
```sql
-- PersonaStore.ListForUser
WHERE scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $1))
OR (scope = 'personal' AND is_shared = true)
```
KB access follows the same pattern in `KnowledgeBaseStore.ListForUser` and
the handler's `userCanAccess()` method.
Groups add a fourth access path: "I'm in a group that has been granted
access to this resource."
### 4.2 Design: Parallel Check, Not Query Rewrite
We could embed the group check into every `ListForUser` query. That would
mean every listing query joins against `resource_grants` and `group_members`,
adding complexity to every scope-aware query in the system.
Instead: **keep existing scope queries as-is, add group-granted resources
as a separate UNION.** This is cleaner, backward-compatible, and doesn't
slow down the common path for users without group-based access.
### 4.3 Updated Persona Listing
```go
// PersonaStore.ListForUser — updated
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
-- Existing scope-based access (unchanged)
SELECT %s FROM personas WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
)
UNION
-- Group-based access (new)
SELECT %s FROM personas p
WHERE p.is_active = true
AND p.id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
WHERE gm.user_id = $1
)::uuid[])
)
)
ORDER BY scope, name
`, personaCols, personaCols), userID)
// ...
}
```
The `&&` operator is the Postgres array overlap operator — returns true if
the two arrays share any element. The GIN index on `granted_groups` makes
this efficient.
### 4.4 Updated KB Listing
Same pattern applied to `KnowledgeBaseStore.ListForUser`:
```go
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
// ... existing scope-based query ...
// Add UNION for group-granted KBs
q += `
UNION
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, created_at, updated_at
FROM knowledge_bases kb
WHERE kb.id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
WHERE gm.user_id = $1
)::uuid[])
)
)`
q += ` ORDER BY name`
return queryKBs(ctx, q, args...)
}
```
### 4.5 Updated UserCanAccess
The KB handler's `userCanAccess()` method also needs updating:
```go
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
// Existing scope-based check (unchanged)
switch kb.Scope {
case "global":
return true
case "personal":
if kb.OwnerID != nil && *kb.OwnerID == userID {
return true
}
case "team":
if kb.TeamID != nil {
for _, tid := range teamIDs {
if tid == *kb.TeamID {
return true
}
}
}
}
// New: check group-based grants
hasAccess, _ := h.stores.ResourceGrants.UserHasGroupAccess(
context.Background(), userID, models.ResourceTypeKnowledgeBase, kb.ID)
return hasAccess
}
```
The `PersonaStore.UserCanAccess` method gets the same treatment.
### 4.6 GetActiveKBIDs (Channel KB Access)
The `GetActiveKBIDs` query (used by the completion handler to determine which
KBs to search) also needs the group path:
```sql
-- Add to the WHERE clause:
OR kb.id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
WHERE gm.user_id = $2
)::uuid[])
)
)
```
### 4.7 Performance Note: User Group IDs Cache
The `ARRAY_AGG(gm.group_id)` subquery appears in every access check. For
hot paths (listing, access checks), we can pre-fetch the user's group IDs
once per request and pass them through:
```go
// In middleware or handler setup:
groupIDs, _ := stores.Groups.GetUserGroupIDs(ctx, userID)
// Then in queries, pass as a parameter instead of subquery:
// ... AND rg.granted_groups && $N::uuid[]
```
This trades a subquery for a pre-fetched array parameter. Worth doing in
phase 2 if access checks show up in profiling. For v0.16.0, the subquery
approach is simpler and correct.
---
## 5. Handlers
### 5.1 GroupHandler
```go
type GroupHandler struct {
stores store.Stores
}
func NewGroupHandler(s store.Stores) *GroupHandler {
return &GroupHandler{stores: s}
}
```
**Admin endpoints (global groups):**
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/admin/groups` | ListAllGroups | admin |
| POST | `/admin/groups` | CreateGlobalGroup | admin |
| PUT | `/admin/groups/:id` | UpdateGroup | admin |
| DELETE | `/admin/groups/:id` | DeleteGroup | admin |
| GET | `/admin/groups/:id/members` | ListGroupMembers | admin |
| POST | `/admin/groups/:id/members` | AddGroupMember | admin |
| DELETE | `/admin/groups/:id/members/:userId` | RemoveGroupMember | admin |
**Team endpoints (team-scoped groups):**
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/teams/:teamId/groups` | ListTeamGroups | team member |
| POST | `/teams/:teamId/groups` | CreateTeamGroup | team admin |
| PUT | `/teams/:teamId/groups/:id` | UpdateTeamGroup | team admin |
| DELETE | `/teams/:teamId/groups/:id` | DeleteTeamGroup | team admin |
| GET | `/teams/:teamId/groups/:id/members` | ListTeamGroupMembers | team member |
| POST | `/teams/:teamId/groups/:id/members` | AddTeamGroupMember | team admin |
| DELETE | `/teams/:teamId/groups/:id/members/:userId` | RemoveTeamGroupMember | team admin |
**User endpoint:**
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/groups/mine` | MyGroups | authenticated |
### 5.2 Resource Grant Endpoints
Grant management is inline on the resource endpoints, not a separate handler.
**On Personas:**
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/presets/:id/grants` | GetPersonaGrants | owner or admin |
| PUT | `/presets/:id/grants` | SetPersonaGrants | owner or admin |
**On Knowledge Bases:**
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/knowledge-bases/:id/grants` | GetKBGrants | owner or admin |
| PUT | `/knowledge-bases/:id/grants` | SetKBGrants | owner or admin |
**Request/response format:**
```json
// PUT /api/v1/presets/:id/grants
{
"grant_scope": "groups",
"granted_groups": ["uuid-1", "uuid-2"]
}
// GET /api/v1/presets/:id/grants
{
"grant_scope": "groups",
"granted_groups": [
{ "id": "uuid-1", "name": "Engineering" },
{ "id": "uuid-2", "name": "Design" }
]
}
```
The GET response enriches group UUIDs with names for display. The PUT
accepts bare UUIDs.
---
## 6. Routes (main.go)
```go
// ── Groups ──────────────────────────────
// User: my groups
protected.GET("/groups/mine", groupH.MyGroups)
// Team-scoped groups
teamScoped.GET("/groups", groupH.ListTeamGroups)
teamScoped.POST("/groups", groupH.CreateTeamGroup) // RequireTeamAdmin
teamScoped.PUT("/groups/:groupId", groupH.UpdateTeamGroup) // RequireTeamAdmin
teamScoped.DELETE("/groups/:groupId", groupH.DeleteTeamGroup) // RequireTeamAdmin
teamScoped.GET("/groups/:groupId/members", groupH.ListTeamGroupMembers)
teamScoped.POST("/groups/:groupId/members", groupH.AddTeamGroupMember) // RequireTeamAdmin
teamScoped.DELETE("/groups/:groupId/members/:userId", groupH.RemoveTeamGroupMember) // RequireTeamAdmin
// Admin: global groups
admin.GET("/groups", groupH.ListAllGroups)
admin.POST("/groups", groupH.CreateGlobalGroup)
admin.PUT("/groups/:id", groupH.UpdateGroup)
admin.DELETE("/groups/:id", groupH.DeleteGroup)
admin.GET("/groups/:id/members", groupH.ListGroupMembers)
admin.POST("/groups/:id/members", groupH.AddGroupMember)
admin.DELETE("/groups/:id/members/:userId", groupH.RemoveGroupMember)
// ── Resource Grants ─────────────────────
// Persona grants (add to existing preset routes)
protected.GET("/presets/:id/grants", personaH.GetGrants)
protected.PUT("/presets/:id/grants", personaH.SetGrants)
// KB grants (add to existing KB routes)
protected.GET("/knowledge-bases/:id/grants", kbH.GetGrants)
protected.PUT("/knowledge-bases/:id/grants", kbH.SetGrants)
```
---
## 7. Audit Logging
All group and grant operations log to the existing audit system:
| Action | Resource Type | Details |
|--------|--------------|---------|
| `group.create` | `group` | name, scope, team_id |
| `group.update` | `group` | changed fields |
| `group.delete` | `group` | name |
| `group.member.add` | `group` | user_id |
| `group.member.remove` | `group` | user_id |
| `resource_grant.set` | `persona` / `knowledge_base` | grant_scope, group count |
| `resource_grant.delete` | `persona` / `knowledge_base` | — |
Uses the existing `AuditStore.Log` method — no new infrastructure.
---
## 8. Frontend
### 8.1 Admin Panel: Groups Section
New tab in the admin panel (alongside Users, Teams, Models, Settings, etc.):
**Groups list view:**
- Table: Name, Scope, Team (if team-scoped), Members count, Actions
- "Create Group" button → modal with name, description, scope selector
- Click row → member management (add/remove users, search by username)
**Team admin panel:**
- New "Groups" tab under team settings
- Same list/CRUD pattern, scoped to team
### 8.2 Grant Picker Component
Reusable component for Persona and KB create/edit forms:
```
┌─ Access ──────────────────────────────────┐
│ ○ Team Only (default) │
│ ○ All Users │
│ ○ Specific Groups │
│ ┌────────────────────────────────────┐ │
│ │ [x] Engineering │ │
│ │ [x] Design │ │
│ │ [ ] Marketing │ │
│ │ [ ] Leadership │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────────┘
```
- Only shown for team-scoped and global-scoped resources (personal resources
are always owner-only).
- "Team Only" = existing behavior (no `resource_grants` row, or row with
`grant_scope = 'team_only'`).
- "All Users" = `grant_scope = 'global'`.
- "Specific Groups" = `grant_scope = 'groups'` + multi-select group picker.
- Group list loaded from `/api/v1/groups/mine` (user's groups) for team
admins, `/api/v1/admin/groups` for system admins.
### 8.3 User's Group Badge
The model selector and KB list already show scope badges (`global`, `team`,
`personal`). For group-granted resources, show a "group" badge to distinguish
from team-native access.
---
## 9. Backward Compatibility
### 9.1 No Breaking Changes
- All existing scope-based queries remain valid.
- Resources without a `resource_grants` row behave exactly as before.
- The `resource_grants` table is opt-in: only populated when an admin or
team admin explicitly sets grants on a resource.
- `persona_grants` (tool/KB/API grants ON a persona) is unrelated to
`resource_grants` (access grants TO a persona). Different concepts,
different tables. No rename needed.
### 9.2 Migration Safety
- New tables only (no ALTER on existing tables).
- No data migration required.
- Rollback: drop the three new tables.
### 9.3 Naming Clarity
To avoid confusion between the existing `persona_grants` table (which
controls what tools/KBs a Persona has access TO) and the new
`resource_grants` table (which controls which users have access TO the
Persona):
| Table | Purpose | Example |
|-------|---------|---------|
| `persona_grants` | What a Persona can do (tools, KBs, APIs) | "CodeBot can use calculator" |
| `resource_grants` | Who can use a resource (users via groups) | "Engineering group can use CodeBot" |
The naming is intentionally distinct. No rename of `persona_grants` is needed.
---
## 10. Implementation Phases
### Phase 1: Groups Entity (backend)
- [ ] Migration `010_groups.sql`
- [ ] Models: `Group`, `GroupPatch`, `GroupMember`
- [ ] Store: `GroupStore` interface + Postgres implementation
- [ ] Handler: `GroupHandler` with admin + team admin + user endpoints
- [ ] Routes: wire into `main.go`
- [ ] Audit logging for group operations
- [ ] Integration tests: CRUD, membership, scoping
### Phase 2: Resource Grants (backend)
- [ ] Models: `ResourceGrant` + constants
- [ ] Store: `ResourceGrantStore` interface + Postgres implementation
- [ ] Grant endpoints on PersonaHandler and KBHandler
- [ ] Update `PersonaStore.ListForUser` with UNION for group access
- [ ] Update `PersonaStore.UserCanAccess` with group check
- [ ] Update `KnowledgeBaseStore.ListForUser` with UNION for group access
- [ ] Update `KBHandler.userCanAccess` with group check
- [ ] Update `KnowledgeBaseStore.GetActiveKBIDs` with group path
- [ ] Audit logging for grant operations
- [ ] Integration tests: grant CRUD, access resolution, cross-team visibility
### Phase 3: Frontend
- [ ] Admin panel: Groups section (CRUD, member management)
- [ ] Team admin panel: Groups tab
- [ ] Grant picker component (shared between Persona and KB forms)
- [ ] `/groups/mine` endpoint for user's group list
- [ ] "Group" scope badge on model selector / KB list
- [ ] Update Persona create/edit form with grant picker
- [ ] Update KB create/edit form with grant picker
---
## 11. Testing Plan
### Integration Tests (extend `handlers/integration_test.go`)
**Group lifecycle:**
1. Admin creates global group → verify in list
2. Admin adds users to group → verify membership
3. Team admin creates team group → verify scoping (not visible to other teams)
4. Remove member → verify no longer in group
5. Delete group → verify CASCADE cleans up members
**Resource grants:**
1. Team admin creates a KB with `grant_scope = 'groups'`, grants to a group
2. User in the group can see the KB in `ListForUser` → verify access
3. User NOT in the group cannot see the KB → verify denied
4. User in group can search the KB via `kb_search` tool → verify access
5. Remove user from group → verify KB no longer visible
6. Change grant from `groups` to `global` → verify all users can access
7. Delete grant → verify KB reverts to team-only visibility
**Backward compatibility:**
1. Resources with no `resource_grants` row → same behavior as before
2. Existing scope queries → unchanged results
3. Personal resources → never grant-accessible (grant picker hidden)
### Edge Cases
- User belongs to 0 groups → `ARRAY_AGG` returns NULL → no match (safe)
- Group with 0 members → grant exists but no one matches (correct)
- Delete a group that's referenced in `granted_groups` → array still contains
the UUID but no `group_members` rows match → effectively revoked (correct).
Cleanup: periodic job or ON DELETE trigger to scrub stale UUIDs from arrays.
For v0.16.0, stale UUIDs are harmless (they just don't match anything).
---
## 12. Future Considerations
### v0.17.0 — Persona-KB Binding
Resource grants enable the access control layer for Persona-KB binding.
When a Persona has bound KBs, the Persona's grants implicitly grant search
access to those KBs. The `resource_grants` table already supports this —
the Persona grant controls who can use the Persona, and Persona-KB binding
controls which KBs are searchable through it.
### v0.19.0 — Projects
Projects will use the same `resource_grants` pattern:
`resource_type = 'project'`. No schema changes needed.
### v0.24.0 — Full RBAC
OIDC claim → group mapping will auto-sync external IdP groups into the
`groups` table. The infrastructure built here becomes the foundation for
enterprise RBAC.
### Stale Group UUID Cleanup
When a group is deleted, its UUID may linger in `resource_grants.granted_groups`
arrays. This is harmless (dead UUIDs never match any `group_members` rows)
but untidy. Options for a future cleanup:
- Trigger on `groups` DELETE that scrubs the UUID from all `granted_groups` arrays.
- Periodic background job.
- Cleanup on next grant SET (when the admin edits the grant).
For v0.16.0, we'll add a note in the delete handler response if the group
is referenced in any grants, warning the admin.