docs: ROADMAP full restore + #98-143 tech debt links + archive historical DESIGN/CHANGES (#144)
This commit is contained in:
153
docs/archive/DESIGN-0.16.0.md
Normal file
153
docs/archive/DESIGN-0.16.0.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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 ...]
|
||||
Reference in New Issue
Block a user