mono/packages/acl/docs/groups.md

7.4 KiB

Groups — Design Proposal

Problem

Today, VFS permissions are granted per-user:

{
  "acl": [
    { "userId": "aaa-...", "path": "/docs", "permissions": ["read", "list"] },
    { "userId": "bbb-...", "path": "/docs", "permissions": ["read", "list"] },
    { "userId": "ccc-...", "path": "/docs", "permissions": ["read", "list"] }
  ]
}

If 50 users need the same access, you write 50 entries. Changing the permission set means updating all 50. This doesn't scale.

Proposal: User Groups

Introduce a group — a named set of users. Permissions are granted to groups rather than individual user IDs.

Data Model

erDiagram
    GROUP ||--o{ GROUP_MEMBER : contains
    GROUP ||--o{ VFS_PERMISSION : receives
    USER  ||--o{ GROUP_MEMBER : belongs_to
    USER  ||--o{ VFS_PERMISSION : owns

    GROUP {
        uuid id PK
        uuid owner_id FK
        string name
        string description
    }

    GROUP_MEMBER {
        uuid group_id FK
        uuid user_id FK
    }

    VFS_PERMISSION {
        uuid owner_id FK
        uuid group_id FK "nullable — group grant"
        uuid grantee_id FK "nullable — direct grant"
        string resource_path
        string[] permissions
    }

A permission row targets either a group_id or a grantee_id — never both.

VFS Settings (JSON)

{
  "owner": "3bb4cfbf-...",
  "groups": [
    {
      "name": "team",
      "members": ["aaa-...", "bbb-...", "ccc-..."]
    },
    {
      "name": "viewers",
      "members": ["ddd-...", "eee-..."]
    }
  ],
  "acl": [
    { "group": "team",    "path": "/shared", "permissions": ["read", "write", "list", "mkdir", "delete"] },
    { "group": "viewers", "path": "/docs",   "permissions": ["read", "list"] },
    { "userId": "fff-...", "path": "/private/partner", "permissions": ["read", "list"] }
  ]
}

Groups and direct user grants coexist. Direct grants take precedence for individual overrides.


ACL Mapping

How Groups Become Roles

Each group becomes a role in the ACL. All members of the group inherit that role:

sequenceDiagram
    participant Loader as VFS ACL Bridge
    participant ACL as Acl Instance

    Note over Loader: For each group
    Loader->>ACL: allow "group:team", resource, permissions
    loop Each member of team
        Loader->>ACL: addUserRoles member, "group:team"
    end

    Note over Loader: For each direct grant
    Loader->>ACL: allow "vfs-grant:owner:user", resource, permissions
    Loader->>ACL: addUserRoles user, "vfs-grant:owner:user"

Role Naming Convention

Type Role Name Example
Owner owner:<ownerId> owner:3bb4-...
Group group:<ownerId>:<groupName> group:3bb4-...:team
Direct vfs-grant:<ownerId>:<userId>:<path> vfs-grant:3bb4-...:fff-...:/private

Implementation

TypeScript Types

interface VfsGroup {
    name: string;
    members: string[];
}

interface VfsAclEntry {
    /** Direct user grant */
    userId?: string;
    /** Group grant */
    group?: string;
    /** Scoped path — defaults to "/" */
    path?: string;
    permissions: string[];
}

interface VfsSettings {
    owner: string;
    groups?: VfsGroup[];
    acl: VfsAclEntry[];
}

Loader Changes

export async function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSettings | null> {
    // ... read settings ...

    // 1. Owner — wildcard on /
    const ownerRole = `owner:${settings.owner}`;
    await acl.allow(ownerRole, vfsResource(settings.owner, '/'), '*');
    await acl.addUserRoles(settings.owner, ownerRole);

    // 2. Register groups
    const groupMembers = new Map<string, string[]>();
    for (const group of settings.groups ?? []) {
        groupMembers.set(group.name, group.members);
    }

    // 3. Process ACL entries
    for (const entry of settings.acl) {
        const resourcePath = entry.path ?? '/';
        const resource = vfsResource(settings.owner, resourcePath);

        if (entry.group) {
            // Group grant
            const role = `group:${settings.owner}:${entry.group}`;
            await acl.allow(role, resource, entry.permissions);

            const members = groupMembers.get(entry.group) ?? [];
            for (const memberId of members) {
                await acl.addUserRoles(memberId, role);
            }
        } else if (entry.userId) {
            // Direct grant
            const role = `vfs-grant:${settings.owner}:${entry.userId}:${resourcePath}`;
            await acl.allow(role, resource, entry.permissions);
            await acl.addUserRoles(entry.userId, role);
        }
    }

    return settings;
}

AclVfsClient

No changes needed. The client only calls acl.isAllowed(callerId, resource, permission) — it doesn't care whether the permission was granted via a group or a direct entry. The ACL resolves it transparently through addUserRoles.


Postgres Schema

create table public.vfs_groups (
    id          uuid primary key default gen_random_uuid(),
    owner_id    uuid not null references public.users(id) on delete cascade,
    name        text not null,
    description text,

    unique (owner_id, name)
);

create table public.vfs_group_members (
    group_id    uuid not null references public.vfs_groups(id) on delete cascade,
    user_id     uuid not null references public.users(id) on delete cascade,

    primary key (group_id, user_id)
);

-- Extend vfs_permissions to support group grants
alter table public.vfs_permissions
    add column group_id uuid references public.vfs_groups(id) on delete cascade;

-- Either group_id or grantee_id must be set, never both
alter table public.vfs_permissions
    add constraint grant_target_check
    check (
        (group_id is not null and grantee_id is null)
        or (group_id is null and grantee_id is not null)
    );

Permission Resolution Order

When checking isAllowed(userId, resource, permission):

flowchart TD
    A[isAllowed userId resource permission] --> B{Is owner?}
    B -- yes --> C[ALLOW - wildcard]
    B -- no --> D{Direct grant exists?}
    D -- yes, allowed --> C
    D -- no --> E{Any group grants?}
    E -- yes --> F{User is member of group?}
    F -- yes, allowed --> C
    F -- no --> G[Walk parent path]
    E -- no --> G
    G --> H{Reached root?}
    H -- no --> D
    H -- yes --> I[DENY]

This is already handled by the existing resourceChain + addUserRoles — no new resolution logic needed.


Open Questions

  1. Nested groups? — A group containing other groups (e.g. all-staff inherits members from team-a + team-b). This maps to addRoleParents but adds complexity. Recommend no for v1.

  2. Group-scoped deny? — Explicitly deny a permission for a group. The current ACL doesn't support deny rules (allow-only). Recommend deferring.

  3. Who can manage groups? — Only the folder owner, or delegates? RLS naturally restricts to owner_id = auth.uid(), but a group_admin role could be added later.

  4. Max group size? — For the in-memory backend, large groups (1000+ members) are fine. For the DB loader, a single SELECT ... JOIN handles it. No practical limit for v1.