198 lines
8.8 KiB
Markdown
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 |
|