This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.16.0.md
2026-02-27 02:38:35 +00:00

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