257 lines
7.4 KiB
Markdown
257 lines
7.4 KiB
Markdown
# Groups — Design Proposal
|
|
|
|
## Problem
|
|
|
|
Today, VFS permissions are granted **per-user**:
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```mermaid
|
|
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)
|
|
|
|
```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:
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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
|
|
|
|
```sql
|
|
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)`:
|
|
|
|
```mermaid
|
|
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.
|