28 KiB
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
-- =========================================
-- 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
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
-- =========================================
-- 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
-- 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
// ── 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
// ── 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
// =========================================
// 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
// =========================================
// 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
type Stores struct {
// ... existing fields ...
Groups GroupStore
ResourceGrants ResourceGrantStore
}
4. Access Resolution
4.1 The Problem
Today, access checks look like this (Persona example):
-- 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
// 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:
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:
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:
-- 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:
// 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
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:
// 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)
// ── 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_grantsrow, or row withgrant_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/groupsfor 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_grantsrow behave exactly as before. - The
resource_grantstable 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 toresource_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:
GroupStoreinterface + Postgres implementation - Handler:
GroupHandlerwith 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:
ResourceGrantStoreinterface + Postgres implementation - Grant endpoints on PersonaHandler and KBHandler
- Update
PersonaStore.ListForUserwith UNION for group access - Update
PersonaStore.UserCanAccesswith group check - Update
KnowledgeBaseStore.ListForUserwith UNION for group access - Update
KBHandler.userCanAccesswith group check - Update
KnowledgeBaseStore.GetActiveKBIDswith 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/mineendpoint 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:
- Admin creates global group → verify in list
- Admin adds users to group → verify membership
- Team admin creates team group → verify scoping (not visible to other teams)
- Remove member → verify no longer in group
- Delete group → verify CASCADE cleans up members
Resource grants:
- Team admin creates a KB with
grant_scope = 'groups', grants to a group - User in the group can see the KB in
ListForUser→ verify access - User NOT in the group cannot see the KB → verify denied
- User in group can search the KB via
kb_searchtool → verify access - Remove user from group → verify KB no longer visible
- Change grant from
groupstoglobal→ verify all users can access - Delete grant → verify KB reverts to team-only visibility
Backward compatibility:
- Resources with no
resource_grantsrow → same behavior as before - Existing scope queries → unchanged results
- Personal resources → never grant-accessible (grant picker hidden)
Edge Cases
- User belongs to 0 groups →
ARRAY_AGGreturns 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 nogroup_membersrows 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
groupsDELETE that scrubs the UUID from allgranted_groupsarrays. - 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.