8.8 KiB
Groups & ACL — Proposal
Problem
The ACL system currently hacks anonymous and authenticated as sentinel strings mapped to group_name in resource_acl. This works but:
- No concept of custom groups (
vendors,editors, etc.) - Virtual user ID mapping in 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 already has a proper RBAC system:
| Concept | Implementation |
|---|---|
| Core | Acl.ts — Zend-inspired RBAC: allow(roles, resources, perms), isAllowed(user, resource, perms) |
| Backend interface | IBackend — bucket-based key-value storage |
| Backends | MemoryBackend, FileBackend |
| VFS layer | vfs-acl.ts, AclVfsClient |
The Acl class already supports:
addUserRoles(userId, roles)/removeUserRoles(userId, roles)userRoles(userId)→ returns all roles for a userroleUsers(role)→ returns all users in a roleaddRoleParents(role, parents)→ role hierarchyisAllowed(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:
SupabaseBackend— implementsIBackendusing Supabase tables instead of in-memory mapsGroupManager— thin wrapper for creating/listing groups and managing membership (writes togroups+group_memberstables)
Database Tables
-- 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_rolesseparate. 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 | 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 | Remove sentinel hack, use GroupManager for membership checks |
| [MODIFY] db-categories.ts | isCategoryVisible() uses group membership lookup |
| [MODIFY] db-acl.ts | Group-aware permission resolution |
| [MODIFY] 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 | Dynamic group picker from groups table |
[NEW] src/components/admin/GroupManager.tsx |
Admin UI: create groups, manage members |
| [MODIFY] 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
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 |