# 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. [... full content from tool ...]