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