mono/packages/ui/docs/groups-acl.md
2026-03-21 20:18:25 +01:00

198 lines
8.8 KiB
Markdown

# Groups & ACL — Proposal
## Problem
The ACL system currently hacks `anonymous` and `authenticated` as **sentinel strings** mapped to `group_name` in [resource_acl](../supabase/migrations/20260219215000_create_resource_acl.sql). This works but:
- No concept of custom groups (`vendors`, `editors`, etc.)
- Virtual user ID mapping in [db-acl-db.ts](../server/src/products/serving/db/db-acl-db.ts) is fragile
- No UI for managing groups or discovering group membership
- Can't answer "which groups does user X belong to?" or "who is in group Y?"
## @polymech/acl Package
The [acl package](../../polymech-mono/packages/acl/README.md) already has a proper RBAC system:
| Concept | Implementation |
|---------|---------------|
| Core | [Acl.ts](../../polymech-mono/packages/acl/src/Acl.ts) — Zend-inspired RBAC: `allow(roles, resources, perms)`, `isAllowed(user, resource, perms)` |
| Backend interface | [IBackend](../../polymech-mono/packages/acl/src/interfaces.ts) — bucket-based key-value storage |
| Backends | [MemoryBackend](../../polymech-mono/packages/acl/src/data/MemoryBackend.ts), [FileBackend](../../polymech-mono/packages/acl/src/data/FileBackend.ts) |
| VFS layer | [vfs-acl.ts](../../polymech-mono/packages/acl/src/vfs/vfs-acl.ts), [AclVfsClient](../../polymech-mono/packages/acl/src/vfs/AclVfsClient.ts) |
The `Acl` class already supports:
- `addUserRoles(userId, roles)` / `removeUserRoles(userId, roles)`
- `userRoles(userId)` → returns all roles for a user
- `roleUsers(role)` → returns all users in a role
- `addRoleParents(role, parents)` → role hierarchy
- `isAllowed(userId, resource, permissions)` → permission check
**Groups = Roles in the ACL model.** The `Acl` class uses "roles" — groups are just roles with membership. The missing piece is a **Supabase-backed backend** that persists role/group data in the DB instead of files/memory.
## Current Architecture
```
pm-pics (app) @polymech/acl (package)
───────────────── ──────────────────────
resource_acl table Acl class
├── user_id UUID FK ├── addUserRoles()
├── group_name TEXT (hack) ├── isAllowed()
│ └── IBackend
db-acl-db.ts (DbAclBackend) ├── MemoryBackend
├── sentinel mapping hack └── FileBackend
├── rowToEntry / entryToRow
└── direct Supabase queries VFS extensions
├── AclVfsClient
db-acl.ts (orchestrator) ├── vfs-acl.ts
├── IAclBackend (app-level) └── uses FileBackend
├── registerAclBackend()
└── fetchAclSettings / grant / revoke
```
The problem: `pm-pics` has its own `IAclBackend` + `DbAclBackend` that **duplicate** what `@polymech/acl` does, but with Supabase instead of files. The sentinel hack exists because `DbAclBackend` bypasses `Acl` entirely.
## Proposed Architecture
### New backend in @polymech/acl
```
@polymech/acl
├── src/
│ ├── data/
│ │ ├── MemoryBackend.ts (existing)
│ │ ├── FileBackend.ts (existing)
│ │ └── SupabaseBackend.ts [NEW] — IBackend using Supabase tables
│ ├── groups/
│ │ ├── GroupManager.ts [NEW] — CRUD for groups + membership
│ │ └── interfaces.ts [NEW] — IGroupStore, Group, GroupMember
│ ├── Acl.ts (existing, no changes)
│ └── interfaces.ts (existing, no changes)
```
The key insight: **the `Acl` class doesn't need to change**. It already handles roles, users, and permissions generically. We just need:
1. **`SupabaseBackend`** — implements `IBackend` using Supabase tables instead of in-memory maps
2. **`GroupManager`** — thin wrapper for creating/listing groups and managing membership (writes to `groups` + `group_members` tables)
### Database Tables
```sql
-- Groups definition
create table public.groups (
id uuid not null default gen_random_uuid() primary key,
slug text not null unique, -- 'editors', 'vendors'
name text not null, -- 'Editors', 'Vendors'
description text,
builtin boolean not null default false, -- true for anonymous/authenticated
created_by uuid references auth.users(id),
created_at timestamptz default now()
);
-- Seed built-in groups
insert into public.groups (slug, name, builtin) values
('anonymous', 'Anonymous', true),
('authenticated', 'Authenticated Users', true);
-- Group membership (not needed for built-in groups)
create table public.group_members (
id uuid not null default gen_random_uuid() primary key,
group_id uuid not null references public.groups(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text default 'member',
added_at timestamptz default now(),
unique(group_id, user_id)
);
```
### How It Connects
```
resource_acl.group_name ──references──> groups.slug
groups.builtin = true → membership is implicit (auth state)
groups.builtin = false → membership via group_members
```
`resource_acl.group_name` stays as-is — becomes a soft FK to `groups.slug`.
### Resolution Flow
```
1. Load ACL entries for resource R
2. For each entry:
├── entry.user_id === caller → direct user grant
├── entry.group_name = 'anonymous' → builtin, match everyone
├── entry.group_name = 'authenticated' && caller.authenticated → match
└── entry.group_name = 'vendors' → check group_members → match?
3. Any match with required permission → allow
```
### Relationship to user_roles
> **Keep `user_roles` separate.** It's for system-level admin. Groups are for content/resource access control.
## Files to Change
### Phase 1 — @polymech/acl package
| File | Change |
|------|--------|
| [NEW] `packages/acl/src/groups/interfaces.ts` | `IGroupStore`, `Group`, `GroupMember` types |
| [NEW] `packages/acl/src/groups/GroupManager.ts` | CRUD: `fetchGroups`, `createGroup`, `addMember`, `removeMember`, `getGroupsForUser` |
| [MODIFY] [index.ts](../../polymech-mono/packages/acl/src/index.ts) | Export `GroupManager` + types |
### Phase 2 — pm-pics integration
| File | Change |
|------|--------|
| [NEW] `supabase/migrations/xxx_create_groups.sql` | `groups` + `group_members` tables |
| [MODIFY] [db-acl-db.ts](../server/src/products/serving/db/db-acl-db.ts) | Remove sentinel hack, use `GroupManager` for membership checks |
| [MODIFY] [db-categories.ts](../server/src/products/serving/db/db-categories.ts) | `isCategoryVisible()` uses group membership lookup |
| [MODIFY] [db-acl.ts](../server/src/products/serving/db/db-acl.ts) | Group-aware permission resolution |
| [MODIFY] [index.ts](../server/src/products/serving/index.ts) | Register group API routes |
| [NEW] `server/src/products/serving/db/db-groups.ts` | Handlers for groups API |
| [NEW] `src/modules/groups/client-groups.ts` | Client API |
### Phase 3 — UI
| File | Change |
|------|--------|
| [MODIFY] [AclEditor.tsx](../src/components/admin/AclEditor.tsx) | Dynamic group picker from `groups` table |
| [NEW] `src/components/admin/GroupManager.tsx` | Admin UI: create groups, manage members |
| [MODIFY] [CategoryManager.tsx](../src/components/widgets/CategoryManager.tsx) | Group picker in permissions |
## API Design
```
GET /api/groups → list all groups
POST /api/groups → create group (admin)
PATCH /api/groups/:id → update group (admin)
DELETE /api/groups/:id → delete group (admin, not builtin)
GET /api/groups/:id/members → list members
POST /api/groups/:id/members → add member
DELETE /api/groups/:id/members/:userId → remove member
GET /api/users/:id/groups → groups for a user (self or admin)
```
## AclEntry Type Evolution
```diff
interface AclEntry {
- userId?: string; // real UUID or 'anonymous'/'authenticated' (hack)
- group?: string; // unused for virtual IDs
+ userId?: string; // real UUID only
+ groupSlug?: string; // 'anonymous', 'authenticated', 'vendors', etc.
path?: string;
permissions: string[];
}
```
## Priority
| Phase | Effort | Enables |
|-------|--------|---------|
| 1. @polymech/acl groups | ~2h | Reusable group system for any app |
| 2. pm-pics integration | ~2h | Category/resource visibility by group |
| 3. UI | ~3h | Admin can manage groups visually |