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/archive/DESIGN-0.16.0.md

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

[... full content from tool ...]