From 8a47576de06c752172ae29e4eb53c52a01634dbb Mon Sep 17 00:00:00 2001 From: Jason-jo17 Date: Thu, 23 Jul 2026 22:00:10 +0530 Subject: [PATCH] feat(sharing): per-folder / per-project collaborators API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folders and projects had no way to add a collaborator: `doc_acl` rows were only ever written from routes/docs.ts and routes/org.ts, and routes/folders.ts had no sharing endpoints at all. This adds the missing CRUD, and nothing else. GET /api/{folders|projects}/:id/collaborators POST /api/{folders|projects}/:id/collaborators PATCH /api/{folders|projects}/:id/collaborators/:userId DELETE /api/{folders|projects}/:id/collaborators/:userId Purely additive — no schema change and no change to how permissions resolve. It writes the SAME `doc_acl` rows the org access matrix already writes, for resource types `doc_acl` has always modelled ('folder' | 'project'), so app_effective_permission() precedence and the canAccess() folder/project -> doc inheritance already do the rest: a grant here automatically covers everything inside the folder/project. Authorization: managing shares requires `admin` ON THE RESOURCE via canAccess(), which falls through to the workspace role. Unknown ids and ids in another tenant both 404 (existence is checked through RLS, so this can't probe foreign ids). Every grant/update/revoke is written to iam_audit_log. Scope: the grantee must already be a workspace member — POST returns `not_a_workspace_member` otherwise. Inviting an outside email needs a pending grant that materialises on signup plus guest-visibility work, which land separately. Validation lives in lib/collaborators/grants.ts rather than the route so it is importable without the db/config graph (same split as lib/community/handle.ts), which is what makes it unit-testable. A test asserts 'comment' does NOT validate yet, so the future comment tier can't be half-landed by accident. Signed-off-by: Jason-jo17 --- apps/api/src/lib/collaborators/grants.test.ts | 77 ++++++ apps/api/src/lib/collaborators/grants.ts | 48 ++++ apps/api/src/routes/collaborators.ts | 233 ++++++++++++++++++ apps/api/src/server.ts | 2 + 4 files changed, 360 insertions(+) create mode 100644 apps/api/src/lib/collaborators/grants.test.ts create mode 100644 apps/api/src/lib/collaborators/grants.ts create mode 100644 apps/api/src/routes/collaborators.ts diff --git a/apps/api/src/lib/collaborators/grants.test.ts b/apps/api/src/lib/collaborators/grants.test.ts new file mode 100644 index 000000000..f90455113 --- /dev/null +++ b/apps/api/src/lib/collaborators/grants.test.ts @@ -0,0 +1,77 @@ +/** + * Collaborator grant validation (resource sharing — RFC #190, phase 1). + * + * These lock the wire contract for sharing a folder/project: the permission + * vocabulary must stay identical to `doc_acl` (so nothing here can invent a + * permission the precedence function doesn't understand), and "permanent" must + * keep meaning NULL rather than an epoch date. + */ +import { describe, expect, it } from 'vitest'; +import { GRANTABLE_PERMISSIONS, grantSchema, patchSchema, toExpiryDate } from './grants.js'; + +const USER = '11111111-2222-3333-4444-555555555555'; + +describe('GRANTABLE_PERMISSIONS', () => { + it('matches the doc_acl vocabulary exactly', () => { + expect([...GRANTABLE_PERMISSIONS]).toEqual(['read', 'write', 'admin', 'none']); + }); + + it('keeps the explicit deny grantable — it is not the same as revoking', () => { + expect(grantSchema.safeParse({ user_id: USER, permission: 'none' }).success).toBe(true); + }); +}); + +describe('grantSchema', () => { + it('accepts a permanent grant (no expiry)', () => { + const r = grantSchema.safeParse({ user_id: USER, permission: 'write' }); + expect(r.success).toBe(true); + }); + + it('accepts an expiring grant', () => { + const r = grantSchema.safeParse({ + user_id: USER, permission: 'read', expires_at: '2030-01-01T00:00:00.000Z', + }); + expect(r.success).toBe(true); + }); + + it('rejects a permission outside the doc_acl vocabulary', () => { + // 'comment' is RFC phase 6 — it must not silently pass before the + // precedence function understands it. + expect(grantSchema.safeParse({ user_id: USER, permission: 'comment' }).success).toBe(false); + expect(grantSchema.safeParse({ user_id: USER, permission: 'owner' }).success).toBe(false); + }); + + it('rejects a non-uuid principal', () => { + expect(grantSchema.safeParse({ user_id: 'someone@example.com', permission: 'read' }).success).toBe(false); + }); + + it('rejects a non-ISO expiry', () => { + expect(grantSchema.safeParse({ user_id: USER, permission: 'read', expires_at: 'tomorrow' }).success).toBe(false); + }); +}); + +describe('patchSchema', () => { + it('allows changing just the permission', () => { + expect(patchSchema.safeParse({ permission: 'admin' }).success).toBe(true); + }); + + it('allows clearing the expiry (null = permanent)', () => { + expect(patchSchema.safeParse({ expires_at: null }).success).toBe(true); + }); + + it('rejects an empty patch', () => { + expect(patchSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('toExpiryDate', () => { + it('treats null/undefined as permanent', () => { + expect(toExpiryDate(null)).toBeNull(); + expect(toExpiryDate(undefined)).toBeNull(); + }); + + it('parses an ISO string to the same instant', () => { + const iso = '2030-06-01T12:30:00.000Z'; + expect(toExpiryDate(iso)?.toISOString()).toBe(iso); + }); +}); diff --git a/apps/api/src/lib/collaborators/grants.ts b/apps/api/src/lib/collaborators/grants.ts new file mode 100644 index 000000000..4e870ce08 --- /dev/null +++ b/apps/api/src/lib/collaborators/grants.ts @@ -0,0 +1,48 @@ +/** + * Collaborator grant validation (resource sharing — RFC #190, phase 1). + * + * Kept in lib/ rather than the route so it is importable (and unit-testable) + * without pulling in the db/config graph — same split as lib/community/handle.ts. + * + * These mirror `doc_acl` exactly: the permission vocabulary is the storage + * vocabulary, so nothing here re-interprets what a grant means. Precedence + * (deny-first, best-positive) stays entirely in app_effective_permission(). + */ +import { z } from 'zod'; + +/** + * Storage-level permissions, unchanged from `doc_acl`. + * + * `none` is an explicit DENY that beats every positive grant, which is why it is + * grantable here and not filtered out — revoking a row and denying are different + * intents (a deny also overrides an inherited folder/project grant). + * + * The RFC's `comment` tier is phase 6; adding it here + to the precedence + * function is the only change needed, so this list is the single source of truth. + */ +export const GRANTABLE_PERMISSIONS = ['read', 'write', 'admin', 'none'] as const; + +export type GrantablePermission = (typeof GRANTABLE_PERMISSIONS)[number]; + +/** Body for POST …/collaborators — create or re-grant for one user. */ +export const grantSchema = z.object({ + user_id: z.string().uuid(), + permission: z.enum(GRANTABLE_PERMISSIONS), + // null / omitted = permanent, matching doc_acl.expires_at semantics. + expires_at: z.string().datetime().nullable().optional(), +}); + +/** Body for PATCH …/collaborators/:userId — partial update of an existing grant. */ +export const patchSchema = z + .object({ + permission: z.enum(GRANTABLE_PERMISSIONS).optional(), + expires_at: z.string().datetime().nullable().optional(), + }) + .refine((v) => v.permission !== undefined || v.expires_at !== undefined, { + message: 'Provide at least one of permission or expires_at', + }); + +/** `expires_at` string → Date, treating null/undefined as "permanent". */ +export function toExpiryDate(value: string | null | undefined): Date | null { + return value ? new Date(value) : null; +} diff --git a/apps/api/src/routes/collaborators.ts b/apps/api/src/routes/collaborators.ts new file mode 100644 index 000000000..7f0ac73d0 --- /dev/null +++ b/apps/api/src/routes/collaborators.ts @@ -0,0 +1,233 @@ +/** + * Resource collaborators — per-folder / per-project sharing (RFC #190, phase 1). + * + * GET /api/{folders|projects}/:id/collaborators list explicit grants + * POST /api/{folders|projects}/:id/collaborators grant / re-grant + * PATCH /api/{folders|projects}/:id/collaborators/:userId change permission / expiry + * DELETE /api/{folders|projects}/:id/collaborators/:userId revoke + * + * Purely additive: it writes the SAME `doc_acl` rows the org access-matrix already + * writes (routes/org.ts), for resource types `doc_acl` has always modelled. No + * schema change, no change to how permissions resolve — `app_effective_permission` + * + `canAccess` (lib/iam.ts) already handle precedence and the folder/project → + * doc inheritance, so a grant here automatically covers everything inside. + * + * Scope of phase 1 (deliberate): the grantee must ALREADY be a member of the + * workspace. Inviting an outside email (which needs a pending grant that + * materialises on signup, plus the guest-visibility work) is phase 2/3 of the RFC. + */ +import { and, eq } from 'drizzle-orm'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { db } from '../db/index.js'; +import { docAcl, folders, iamAuditLog, projects, users, workspaceMembers } from '../db/schema.js'; +import { withTenant } from '../db/with-tenant.js'; +import { canAccess } from '../lib/iam.js'; +import { grantSchema, patchSchema, toExpiryDate } from '../lib/collaborators/grants.js'; + +type ResourceType = 'folder' | 'project'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function audit( + workspaceId: string, + actorUserId: string, + action: string, + resourceType: string, + resourceId: string, + payload: unknown, +): Promise { + await db.insert(iamAuditLog).values({ + workspaceId, actorUserId, action, resourceType, resourceId, payload: payload as object, + }); +} + +/** Resource exists in the caller's workspace? Read through RLS so a caller can't + * probe for ids in other tenants — an unknown id and a foreign id both 404. */ +async function resourceExists( + tenantId: string, + resourceType: ResourceType, + resourceId: string, +): Promise { + return await withTenant(tenantId, async (tx) => { + if (resourceType === 'folder') { + const [row] = await tx.select({ id: folders.id }).from(folders) + .where(eq(folders.id, resourceId)).limit(1); + return Boolean(row); + } + const [row] = await tx.select({ id: projects.id }).from(projects) + .where(eq(projects.id, resourceId)).limit(1); + return Boolean(row); + }); +} + +/** + * Shared guard: valid id → resource visible in this tenant → caller holds `admin` + * on it. `canAccess` falls through to the workspace role, so workspace owners and + * admins manage sharing without needing an explicit grant on every resource. + * Returns the tenant/actor on success, or null after replying with the error. + */ +async function requireResourceAdmin( + req: FastifyRequest, + reply: FastifyReply, + resourceType: ResourceType, + resourceId: string, +): Promise<{ tenantId: string; actorId: string } | null> { + if (!req.auth) { await reply.code(401).send({ error: 'unauthorized' }); return null; } + if (!UUID_RE.test(resourceId)) { await reply.code(400).send({ error: 'bad_id' }); return null; } + + const { tenant_id: tenantId, sub: actorId } = req.auth; + if (!(await resourceExists(tenantId, resourceType, resourceId))) { + await reply.code(404).send({ error: `${resourceType}_not_found` }); + return null; + } + if (!(await canAccess(db, actorId, tenantId, resourceType, resourceId, 'admin'))) { + await reply.code(403).send({ error: 'requires_admin_on_resource' }); + return null; + } + return { tenantId, actorId }; +} + +/** Explicit user grants on one resource, joined to the person they belong to. */ +async function listGrants(tenantId: string, resourceType: ResourceType, resourceId: string) { + return await db + .select({ + user_id: docAcl.principalId, + permission: docAcl.permission, + expires_at: docAcl.expiresAt, + created_at: docAcl.createdAt, + updated_at: docAcl.updatedAt, + email: users.email, + display_name: users.displayName, + }) + .from(docAcl) + .leftJoin(users, eq(users.id, docAcl.principalId)) + .where(and( + eq(docAcl.workspaceId, tenantId), + eq(docAcl.resourceType, resourceType), + eq(docAcl.resourceId, resourceId), + eq(docAcl.principalType, 'user'), + )); +} + +function registerFor(app: Parameters[0], resourceType: ResourceType): void { + const base = resourceType === 'folder' ? '/api/folders' : '/api/projects'; + + // ── List collaborators ────────────────────────────────────────────────────── + app.get<{ Params: { id: string } }>(`${base}/:id/collaborators`, async (req, reply) => { + const ctx = await requireResourceAdmin(req, reply, resourceType, req.params.id); + if (!ctx) return; + return reply.send({ collaborators: await listGrants(ctx.tenantId, resourceType, req.params.id) }); + }); + + // ── Grant / re-grant ──────────────────────────────────────────────────────── + app.post<{ Params: { id: string } }>(`${base}/:id/collaborators`, async (req, reply) => { + const ctx = await requireResourceAdmin(req, reply, resourceType, req.params.id); + if (!ctx) return; + + const parsed = grantSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'validation', issues: parsed.error.issues }); + } + const { user_id: userId, permission, expires_at: expiresAt } = parsed.data; + + // Phase 1 shares with existing members only — an outside email needs the + // pending-grant + guest flow (RFC #190 phases 2/3). + const [member] = await db + .select({ userId: workspaceMembers.userId }) + .from(workspaceMembers) + .where(and( + eq(workspaceMembers.workspaceId, ctx.tenantId), + eq(workspaceMembers.userId, userId), + )) + .limit(1); + if (!member) { + return reply.code(400).send({ + error: 'not_a_workspace_member', + message: 'Invite this person to the workspace first — sharing with an outside email is not available yet.', + }); + } + + await db.insert(docAcl).values({ + workspaceId: ctx.tenantId, + resourceType, + resourceId: req.params.id, + principalType: 'user', + principalId: userId, + permission, + createdBy: ctx.actorId, + expiresAt: toExpiryDate(expiresAt), + }).onConflictDoUpdate({ + target: [docAcl.resourceType, docAcl.resourceId, docAcl.principalType, docAcl.principalId], + set: { permission, expiresAt: toExpiryDate(expiresAt), updatedAt: new Date() }, + }); + + await audit(ctx.tenantId, ctx.actorId, 'collaborator.granted', resourceType, req.params.id, + { principalType: 'user', principalId: userId, permission, expiresAt: expiresAt ?? null }); + + return reply.send({ collaborators: await listGrants(ctx.tenantId, resourceType, req.params.id) }); + }); + + // ── Change permission / expiry ────────────────────────────────────────────── + app.patch<{ Params: { id: string; userId: string } }>( + `${base}/:id/collaborators/:userId`, + async (req, reply) => { + const ctx = await requireResourceAdmin(req, reply, resourceType, req.params.id); + if (!ctx) return; + if (!UUID_RE.test(req.params.userId)) return reply.code(400).send({ error: 'bad_user_id' }); + + const parsed = patchSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'validation', issues: parsed.error.issues }); + } + + const set: Record = { updatedAt: new Date() }; + if (parsed.data.permission !== undefined) set.permission = parsed.data.permission; + if (parsed.data.expires_at !== undefined) { + set.expiresAt = toExpiryDate(parsed.data.expires_at); + } + + const updated = await db.update(docAcl).set(set).where(and( + eq(docAcl.workspaceId, ctx.tenantId), + eq(docAcl.resourceType, resourceType), + eq(docAcl.resourceId, req.params.id), + eq(docAcl.principalType, 'user'), + eq(docAcl.principalId, req.params.userId), + )).returning({ id: docAcl.id }); + if (updated.length === 0) return reply.code(404).send({ error: 'grant_not_found' }); + + await audit(ctx.tenantId, ctx.actorId, 'collaborator.updated', resourceType, req.params.id, + { principalType: 'user', principalId: req.params.userId, ...parsed.data }); + + return reply.send({ collaborators: await listGrants(ctx.tenantId, resourceType, req.params.id) }); + }, + ); + + // ── Revoke ────────────────────────────────────────────────────────────────── + app.delete<{ Params: { id: string; userId: string } }>( + `${base}/:id/collaborators/:userId`, + async (req, reply) => { + const ctx = await requireResourceAdmin(req, reply, resourceType, req.params.id); + if (!ctx) return; + if (!UUID_RE.test(req.params.userId)) return reply.code(400).send({ error: 'bad_user_id' }); + + const removed = await db.delete(docAcl).where(and( + eq(docAcl.workspaceId, ctx.tenantId), + eq(docAcl.resourceType, resourceType), + eq(docAcl.resourceId, req.params.id), + eq(docAcl.principalType, 'user'), + eq(docAcl.principalId, req.params.userId), + )).returning({ id: docAcl.id }); + if (removed.length === 0) return reply.code(404).send({ error: 'grant_not_found' }); + + await audit(ctx.tenantId, ctx.actorId, 'collaborator.revoked', resourceType, req.params.id, + { principalType: 'user', principalId: req.params.userId }); + + return reply.send({ removed: true }); + }, + ); +} + +export const collaboratorsRoutes: FastifyPluginAsync = async (app) => { + registerFor(app, 'folder'); + registerFor(app, 'project'); +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 29fcb8147..b7a0ecdb0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -24,6 +24,7 @@ import { docsRoutes } from './routes/docs.js'; import { decisionApprovalsRoutes } from './routes/decision-approvals.js'; import { searchRoutes } from './routes/search.js'; import { foldersRoutes } from './routes/folders.js'; +import { collaboratorsRoutes } from './routes/collaborators.js'; import { mcpTokenRoutes } from './routes/mcp-tokens.js'; import { flowsRoutes } from './routes/flows.js'; import { flowPresenceRoutes } from './routes/flows-presence.js'; @@ -181,6 +182,7 @@ await app.register(openApiRoutes); await app.register(geminiRoutes); await app.register(installRoutes); await app.register(projectsRoutes); +await app.register(collaboratorsRoutes); await app.register(documentFilesRoutes); await app.register(onlyofficeRoutes); // Phase 3 — mount the enterprise (ee) modules if present (dynamic import, so