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

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

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