diff --git a/config.default.json b/config.default.json index b430f760a..29c081f29 100644 --- a/config.default.json +++ b/config.default.json @@ -22,6 +22,7 @@ "default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f", "storage_capacity": 104857600, "disable_user_signup": false, + "teams_enabled": false, "strict_email_verification_required": false, "gui_assets_root": "./src/gui", "puterjs_root": "./src/puter-js/dist", diff --git a/config.template.jsonc b/config.template.jsonc index 4d1fbf87e..578db31c9 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -273,6 +273,14 @@ // "emailBatchSeconds": 90 // }, + // ── Teams / workspaces ────────────────────────────────────────────── + // Off by default, and off is the state every existing install stays in. + // The tables ship either way and sit inert; this gates whether `/teams` + // is registered at all, so with it off the paths do not exist rather than + // existing and refusing. It is also the backout: turning it off removes + // the feature without touching data. + "teams_enabled": false, + // ── Notifications ─────────────────────────────────────────────────── // How long a notification is kept, in days from creation. Acknowledged or // not, a row past this is swept. Set 0 to keep everything forever. diff --git a/src/backend/controllers/index.ts b/src/backend/controllers/index.ts index 2b6908807..9597624d2 100644 --- a/src/backend/controllers/index.ts +++ b/src/backend/controllers/index.ts @@ -32,6 +32,7 @@ import { NotificationController } from './notification/NotificationController.js import { OIDCController } from './oidc/OIDCController.js'; import { PuterAIController } from './puterai/PuterAIController.js'; import { ShareController } from './share/ShareController.js'; +import { TeamController } from './team/TeamController.js'; import { StaticAssetsController } from './static/StaticAssetsController.js'; import { StaticPagesController } from './static/StaticPagesController.js'; import { SystemController } from './system/SystemController.js'; @@ -57,6 +58,7 @@ export const puterControllers = { notification: NotificationController, events: EventsController, share: ShareController, + team: TeamController, webdav: WebDAVController, oidc: OIDCController, wisp: WispController, diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts new file mode 100644 index 000000000..4d222b506 --- /dev/null +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -0,0 +1,308 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { IConfig } from '../../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`; + +describe('team endpoints over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const call = ( + method: string, + path: string, + token: string, + body?: unknown, + ) => + fetch(new URL(path, env.apiOrigin), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + /** A workspace owned by `user`, with one provisioned member. */ + const makeWorkspace = async () => { + const res = await call('POST', '/teams', env.users.user.token, { + name: 'Acme', + handle: randomHandle(), + }); + // Unasserted, a broken create means tests run on `/teams/undefined`. + expect(res.status).toBe(200); + const team = (await res.json()) as { uid: string }; + expect(team.uid).toBeTruthy(); + + const username = `http_${Math.random().toString(36).slice(2, 9)}`; + const member = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { username, email: `${username}@test.local` }, + ); + expect(member.status).toBe(200); + return { team, memberUsername: username }; + }; + + // -- the owner-account gate -------------------------------------- + + it('creates a workspace and reports the caller as its owner', async () => { + const res = await call('POST', '/teams', env.users.user.token, { + name: 'Acme Design', + handle: randomHandle(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ name: 'Acme Design', is_owner: true }); + expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u); + // `id` is internal and must not reach the wire. + expect(body).not.toHaveProperty('id'); + }); + + it('returns 404, not 403, to a non-member', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'GET', + `/teams/${team.uid}`, + env.users.other.token, + ); + // 403 would confirm the workspace exists. + expect(res.status).toBe(404); + }); + + it('refuses every administrative route to a member who is not the owner', async () => { + const { team } = await makeWorkspace(); + // Provisioned accounts have no password, so they cannot authenticate. + const other = await env.server.stores.user.getByUsername( + env.users.other.username, + ); + await env.server.stores.team.addMember(team.uid, other!.id, { + orgOwned: true, + }); + + for (const [method, path] of [ + ['PUT', `/teams/${team.uid}`], + ['DELETE', `/teams/${team.uid}`], + ['POST', `/teams/${team.uid}/members`], + ['GET', `/teams/${team.uid}/audit`], + ] as const) { + const res = await call( + method, + path, + env.users.other.token, + method === 'GET' ? undefined : { name: 'nope' }, + ); + expect(res.status, `${method} ${path}`).toBe(403); + } + + // Still a member, so reads it is entitled to still work. + const readable = await call( + 'GET', + `/teams/${team.uid}`, + env.users.other.token, + ); + expect(readable.status).toBe(200); + }); + + // -- the org_owned guard ------------------------------------------ + + it('refuses the workspace owner as the target of a member route', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'POST', + `/teams/${team.uid}/members/${env.users.user.username}/disable`, + env.users.user.token, + ); + expect(res.status).toBe(404); + }); + + it('lists members with org_owned distinguishing the owner', async () => { + const { team, memberUsername } = await makeWorkspace(); + + const res = await call( + 'GET', + `/teams/${team.uid}/members`, + env.users.user.token, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: { username: string; org_owned: boolean }[]; + }; + const owner = body.items.find( + (m) => m.username === env.users.user.username, + ); + const member = body.items.find((m) => m.username === memberUsername); + expect(owner?.org_owned).toBe(false); + expect(member?.org_owned).toBe(true); + }); + + // -- provisioning over the wire ----------------------------------- + + it('never returns the activation link to the administrator', async () => { + const { team } = await makeWorkspace(); + const username = `secret_${Math.random().toString(36).slice(2, 9)}`; + + const res = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { username, email: `${username}@test.local` }, + ); + + expect(res.status).toBe(200); + const raw = JSON.stringify(await res.json()); + // Emailed to the member; returning it would let an admin use it. + expect(raw).not.toContain('set-new-password'); + expect(raw).not.toContain('token'); + }); + + it('refuses a taken username and offers alternatives', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { + username: env.users.other.username, + email: 'taken@test.local', + }, + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { + suggestions?: string[]; + fields?: { suggestions?: string[] }; + }; + const suggestions = body.suggestions ?? body.fields?.suggestions ?? []; + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions).not.toContain(env.users.other.username); + }); + + // -- audit -------------------------------------------------------- + + it('exposes the workspace audit to the workspace owner only', async () => { + const { team } = await makeWorkspace(); + + const mine = await call( + 'GET', + `/teams/${team.uid}/audit`, + env.users.user.token, + ); + expect(mine.status).toBe(200); + const body = (await mine.json()) as { items: { action: string }[] }; + expect(body.items.map((e) => e.action)).toContain('provision'); + + const theirs = await call( + 'GET', + `/teams/${team.uid}/audit`, + env.users.other.token, + ); + expect(theirs.status).toBe(404); + }); +}); + +describe('team endpoints with teams_enabled off', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: false } as IConfig); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + it('404s every team route, so no team code is reachable', async () => { + // Real workspace: 200 with the flag on, so a 404 means no route. + const owner = await env.server.stores.user.getByUsername( + env.users.user.username, + ); + const team = await env.server.stores.team.create({ + ownerUserId: owner!.id, + name: 'Unreachable', + handle: `off-${Math.random().toString(36).slice(2, 8)}`, + }); + await env.server.stores.team.addMember(team.uid, owner!.id, { + orgOwned: false, + }); + + const paths: [string, string][] = [ + ['POST', '/teams'], + ['GET', '/teams'], + ['GET', `/teams/${team.uid}`], + ['PUT', `/teams/${team.uid}`], + ['DELETE', `/teams/${team.uid}`], + ['GET', `/teams/${team.uid}/members`], + ['POST', `/teams/${team.uid}/members`], + ['GET', `/teams/${team.uid}/audit`], + ['GET', `/teams/${team.uid}/audit/me`], + ]; + + for (const [method, path] of paths) { + const res = await fetch(new URL(path, env.apiOrigin), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.users.user.token}`, + }, + ...(method === 'POST' || method === 'PUT' + ? { body: JSON.stringify({ name: 'x' }) } + : {}), + }); + expect(res.status, `${method} ${path}`).toBe(404); + + // Ours would carry a team legacyCode; the framework's does not. + const body = await res.text(); + expect(body, `${method} ${path}`).not.toContain('team_not_found'); + expect(body, `${method} ${path}`).not.toContain( + 'not_the_workspace_owner', + ); + } + }); + + it('registers no team routes at all with the flag off', async () => { + // The controller exists and is constructed; only its routes are absent. + const controller = env.server.controllers.team as unknown as { + isEnabled?: () => boolean; + }; + expect(controller).toBeTruthy(); + expect(controller.isEnabled?.()).toBe(false); + }); + + it('leaves the schema inert rather than absent', async () => { + // The flag gates reachability, not DDL. + await expect( + env.server.stores.team.getByHandle('no-such-workspace'), + ).resolves.toBeNull(); + }); +}); diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts new file mode 100644 index 000000000..9d2a1c5de --- /dev/null +++ b/src/backend/controllers/team/TeamController.ts @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, Response } from 'express'; +import { + Controller, + Delete, + Get, + Post, + Put, +} from '../../core/http/decorators.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { TeamRow } from '../../stores/team/TeamStore.js'; +import { PuterController } from '../types.js'; + +/** Mirrors ShareController's dual-window shape. */ +const TEAM_LIMIT = [ + { scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const }, + { + scope: 'team:mutate-daily', + limit: 500, + window: 24 * 60 * 60_000, + key: 'user' as const, + }, +]; + +const TEAM_READ_LIMIT = { + scope: 'team:read', + limit: 600, + window: 60_000, + key: 'user' as const, +}; + +/** What a workspace looks like on the wire. `id` stays internal. */ +const toClientTeam = (team: TeamRow, isOwner: boolean) => ({ + uid: team.uid, + name: team.name, + handle: team.handle, + is_owner: isOwner, + created_at: team.created_at, +}); + +@Controller('/teams') +export class TeamController extends PuterController { + // `requireUserActor` installs the auth gates; reads need it too. + + /** Off means `/teams` 404s and no team route is registered at all. */ + isEnabled(): boolean { + return this.config.teams_enabled === true; + } + + @Post('', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async createTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const body = this.#body(req); + + const team = await this.services.team.createWorkspace(userId, { + name: this.#requireString(body.name, 'name'), + handle: + body.handle === undefined || body.handle === null + ? null + : this.#requireString(body.handle, 'handle'), + }); + res.json(toClientTeam(team, true)); + } + + @Get('', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listTeams(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const teams = await this.stores.team.listTeamsForUser(userId); + res.json({ + items: teams.map((t) => + toClientTeam(t, t.owner_user_id === userId), + ), + }); + } + + @Get('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async getTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const team = await this.services.team.requireMembership( + this.#param(req, 'uid'), + userId, + ); + res.json(toClientTeam(team, team.owner_user_id === userId)); + } + + @Put('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async updateTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.requireOwner(this.#param(req, 'uid'), userId); + const body = this.#body(req); + + const changes: { name?: string; handle?: string | null } = {}; + if (body.name !== undefined) + changes.name = this.#requireString(body.name, 'name'); + if (body.handle !== undefined) + changes.handle = body.handle === null ? null : String(body.handle); + + const team = await this.stores.team.update( + this.#param(req, 'uid'), + changes, + ); + if (!team) throw this.#notFound(); + res.json(toClientTeam(team, true)); + } + + @Delete('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async deleteTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.deleteWorkspace( + this.#param(req, 'uid'), + userId, + ); + res.json({ success: true }); + } + + // -- Members ------------------------------------------------------ + + @Get('/:uid/members', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listMembers(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.requireMembership( + this.#param(req, 'uid'), + userId, + ); + + const page = await this.stores.team.listMembers( + this.#param(req, 'uid'), + { + limit: req.query.limit, + cursor: + typeof req.query.cursor === 'string' + ? req.query.cursor + : undefined, + }, + ); + res.json({ + items: page.items.map((m) => ({ + username: m.username, + org_owned: Number(m.org_owned) === 1, + created_at: m.created_at, + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + }); + } + + @Post('/:uid/members', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async createMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority before shape, or a stranger learns if their body parsed. + await this.services.team.requireOwner(uid, userId); + + const body = this.#body(req); + const result = await this.services.team.provisionAccount(uid, userId, { + username: this.#requireString(body.username, 'username'), + email: this.#requireString(body.email, 'email'), + }); + // Shown once; the admin delivers it out of band. + res.json({ + username: result.username, + temporary_password: result.temporaryPassword, + }); + } + + @Post('/:uid/members/:username/activation', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async reissueCredential(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + const { temporaryPassword } = + await this.services.team.reissueCredential(uid, userId, target); + // Shown once for the admin to deliver out of band. + res.json({ temporary_password: temporaryPassword }); + } + + @Post('/:uid/members/:username/disable', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async disableMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + await this.services.team.disableMember( + this.#param(req, 'uid'), + userId, + target, + ); + res.json({ success: true }); + } + + @Post('/:uid/members/:username/enable', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async enableMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + await this.services.team.enableMember( + this.#param(req, 'uid'), + userId, + target, + ); + res.json({ success: true }); + } + + // -- Audit -------------------------------------------------------- + + @Get('/:uid/audit', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listAudit(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + res.json( + await this.services.team.listAudit( + this.#param(req, 'uid'), + userId, + this.#pageOpts(req), + ), + ); + } + + @Get('/:uid/audit/me', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listOwnAudit(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + res.json( + await this.services.team.listOwnAudit( + this.#param(req, 'uid'), + userId, + this.#pageOpts(req), + ), + ); + } + + // -- Helpers ------------------------------------------------------ + + /** `limit` and `cursor` as doc/pagination.md defines them. */ + #pageOpts(req: Request): { limit?: unknown; cursor?: string } { + return { + limit: req.query.limit, + cursor: + typeof req.query.cursor === 'string' + ? req.query.cursor + : undefined, + }; + } + + #requireUserId(req: Request): number { + const id = (req.actor as { user?: { id?: number } } | undefined)?.user + ?.id; + if (!id) + throw new HttpError(401, 'User required', { + legacyCode: 'unauthorized', + }); + return id; + } + + /** Express types a param as `string | string[]`; routes here take one. */ + #param(req: Request, name: string): string { + const value = req.params[name]; + return Array.isArray(value) ? (value[0] ?? '') : (value ?? ''); + } + + #body(req: Request): Record { + return (req.body ?? {}) as Record; + } + + #requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new HttpError(400, `${field} is required`, { + legacyCode: 'bad_request', + }); + } + return value; + } + + #notFound(): HttpError { + return new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + + /** Resolves `:username` to an id; the service decides whether it may act. */ + async #requireTargetUserId(req: Request): Promise { + const user = await this.stores.user.getByUsername( + this.#param(req, 'username'), + ); + if (!user) + throw new HttpError(404, 'Not an account of this workspace', { + legacyCode: 'not_an_org_account', + }); + return user.id; + } +} diff --git a/src/backend/controllers/team/TeamIsolation.http.test.ts b/src/backend/controllers/team/TeamIsolation.http.test.ts new file mode 100644 index 000000000..dcd9df4b4 --- /dev/null +++ b/src/backend/controllers/team/TeamIsolation.http.test.ts @@ -0,0 +1,200 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * A workspace manages accounts and cannot read them. Nobody writes that grant + * deliberately, but a new implicator or a widened actor would create it and no + * other test would fail. Asserts outcomes, never that an implicator is absent. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import type { IConfig } from '../../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +describe('a workspace cannot read its members data', () => { + let env: PuterTestEnv; + let teamUid: string; + let memberUsername: string; + let memberFile: string; + let ownerFile: string; + + /** Written directly: these tests are about reading, not about writing. */ + const makeFile = async (username: string, label: string) => { + const uid = crypto.randomUUID(); + const name = `${label}-${uid.slice(0, 8)}.txt`; + const path = `/${username}/${name}`; + const user = await env.server.stores.user.getByUsername(username); + await env.server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [ + uid, + name, + path, + user!.id, + // `is_dir` is a real boolean on postgres. + env.server.clients.db.booleanValue(false), + Math.floor(Date.now() / 1000), + ], + ); + return path; + }; + + const stat = (path: string, token: string) => + fetch(new URL('/stat', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ path }), + }); + + const readdir = (path: string, token: string) => + fetch(new URL('/readdir', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ path }), + }); + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + + // A provisioned account cannot authenticate until it activates. + const res = await fetch(new URL('/teams', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.users.user.token}`, + }, + body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }), + }); + teamUid = ((await res.json()) as { uid: string }).uid; + + memberUsername = env.users.other.username; + const member = await env.server.stores.user.getByUsername(memberUsername); + await env.server.stores.team.addMember(teamUid, member!.id, { + orgOwned: true, + }); + + memberFile = await makeFile(memberUsername, 'member'); + ownerFile = await makeFile(env.users.user.username, 'owner'); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + // -- refused ------------------------------------------------------ + + it('refuses the workspace owner a members file', async () => { + const res = await stat(memberFile, env.users.user.token); + // Administering, paying for, and reading an account are three things. + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('refuses the workspace owner a members home directory', async () => { + const res = await readdir(`/${memberUsername}`, env.users.user.token); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('refuses a full-access token no less than a session', async () => { + // `#scanAccessToken` re-scans the issuer as a plain user actor. + const res = await stat(memberFile, env.users.user.apiToken); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('grants no workspace-wide reach through the team routes', async () => { + // There is no route to a member's files or KV, and none should appear. + for (const path of [ + `/teams/${teamUid}/files`, + `/teams/${teamUid}/members/${memberUsername}/files`, + `/teams/${teamUid}/kv`, + ]) { + const res = await fetch(new URL(path, env.apiOrigin), { + headers: { authorization: `Bearer ${env.users.user.token}` }, + }); + expect(res.status, path).toBe(404); + } + }); + + // -- allowed ------------------------------------------------------ + + it('lets the workspace owner read its own files', async () => { + const res = await stat(ownerFile, env.users.user.token); + expect(res.status).toBe(200); + }); + + it('lets a member read their own files', async () => { + const res = await stat(memberFile, env.users.other.token); + expect(res.status).toBe(200); + }); + + it('lets an explicit grant through, as an ordinary share', async () => { + const member = await env.server.stores.user.getByUsername( + memberUsername, + ); + const [entry] = (await env.server.clients.db.read( + 'SELECT `uuid` FROM `fsentries` WHERE `path` = ?', + [memberFile], + )) as { uuid: string }[]; + + // Only the member's own grant changes, never workspace authority. + const before = await stat(memberFile, env.users.user.token); + expect(before.status).toBeGreaterThanOrEqual(400); + + await env.server.services.permission.grantUserUserPermission( + makeActor({ user: member!, app: null, accessToken: null } as never), + env.users.user.username, + `fs:${entry.uuid}:read`, + ); + + const after = await stat(memberFile, env.users.user.token); + expect(after.status).toBe(200); + }); + + it('disabling a member neither grants nor transfers their files', async () => { + const owner = await env.server.stores.user.getByUsername( + env.users.user.username, + ); + const member = await env.server.stores.user.getByUsername( + memberUsername, + ); + // A fresh file: the earlier test granted the owner read on `memberFile`. + const untouched = await makeFile(memberUsername, 'untouched'); + + await env.server.services.team.disableMember( + teamUid, + owner!.id, + member!.id, + ); + + // The member loses access to their own files... + const asMember = await stat(untouched, env.users.other.token); + expect(asMember.status).toBeGreaterThanOrEqual(400); + + // ...and the workspace still does not gain it. + const asOwner = await stat(untouched, env.users.user.token); + expect(asOwner.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/src/backend/server.ts b/src/backend/server.ts index 695d21562..014b53f67 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -875,6 +875,14 @@ export class PuterServer { ); } + // A controller may gate its own registration behind a config flag. + // Skipping here means its paths 404 rather than existing and refusing. + const isEnabled = (controller as { isEnabled?: () => boolean }) + .isEnabled; + if (typeof isEnabled === 'function' && !isEnabled.call(controller)) { + return; + } + // Controllers annotated with `@Controller('/prefix')` carry the prefix // on their prototype; bare (imperative) controllers default to ''. const prefix = (controller as unknown as Record)[ diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 837758086..f2ede4d4a 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -26,6 +26,7 @@ describe('TeamService', () => { let server: PuterServer; let service: PuterServer['services']['team']; let owner: { id: number }; + let ownerUsername: string; const makeUser = async (): Promise<{ id: number; username: string }> => { const username = `svc_${Math.random().toString(36).slice(2, 10)}`; @@ -69,6 +70,7 @@ describe('TeamService', () => { server = await setupTestServer(); service = server.services.team; owner = await makeUser(); + ownerUsername = (await server.stores.user.getById(owner.id))!.username; }); afterAll(async () => { @@ -143,8 +145,7 @@ describe('TeamService', () => { await service.disableMember(team.uid, owner.id, member.id); const row = await suspensionOf(member.id); - // `userProtected` rejects on `suspended`; the other two are its - // siblings and do not gate anything on their own. + // `userProtected` rejects on `suspended`; the others gate nothing. expect(Boolean(row.suspended)).toBe(true); expect(row.suspended_at).toBeGreaterThan(0); expect(row.suspended_reason).toBe('disabled_by_workspace'); @@ -159,8 +160,7 @@ describe('TeamService', () => { await service.disableMember(team.uid, owner.id, member.id); - // Revoked, not deleted: the row keeps `last_ip` / `last_user_agent`, - // which the member-facing audit view reads. + // Revoked, not deleted: the row keeps last_ip / last_user_agent. const live = await server.clients.db.read( 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL', [member.id], @@ -449,4 +449,108 @@ describe('TeamService', () => { }); expect(r.temporaryPassword).toBeTruthy(); }); + // -- audit -------------------------------------------------------- + + it('records provisioning, disabling and enabling as they happen', async () => { + const { team } = await makeWorkspace(); + const username = `aud_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await service.disableMember(team.uid, owner.id, created.userId); + await service.enableMember(team.uid, owner.id, created.userId); + + // Written by the service, so a caller bypassing the route cannot skip it. + const { items: entries } = await service.listAudit(team.uid, owner.id); + const forMember = entries.filter((e) => e.username === username); + expect(forMember.map((e) => e.action)).toEqual([ + 'enable', + 'disable', + 'provision', + ]); + expect( + forMember.every((e) => e.actor_username === ownerUsername), + ).toBe(true); + }); + + it('shows a member only their own entries', async () => { + const { team } = await makeWorkspace(); + const a = `one_${Math.random().toString(36).slice(2, 9)}`; + const b = `two_${Math.random().toString(36).slice(2, 9)}`; + const first = await service.provisionAccount(team.uid, owner.id, { + username: a, + email: `${a}@test.local`, + }); + await service.provisionAccount(team.uid, owner.id, { + username: b, + email: `${b}@test.local`, + }); + + const { items: own } = await service.listOwnAudit(team.uid, first.userId); + expect(own).toHaveLength(1); + expect(own[0].username).toBe(a); + // Internal ids must not reach a caller, as `toClientTeam` does for `id`. + expect(own[0]).not.toHaveProperty('user_id_keep'); + expect(own[0]).not.toHaveProperty('actor_user_id'); + }); + + it('keeps the audit from a member who is not the owner', async () => { + const { team, member } = await makeWorkspace(); + await expect( + service.listAudit(team.uid, member.id), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('records a workspace deletion and survives the soft delete', async () => { + const { team } = await makeWorkspace(); + await service.deleteWorkspace(team.uid, owner.id); + + // Gone from reads, but its owner can still read what happened. + await expect(server.stores.team.getByUid(team.uid)).resolves.toBeNull(); + const { items: entries } = await service.listAudit(team.uid, owner.id); + expect(entries.map((e) => e.action)).toContain('delete_team'); + }); + it('disables the accounts it created when the workspace is deleted', async () => { + const { team } = await makeWorkspace(); + const u = `del_${Math.random().toString(36).slice(2, 9)}`; + const provisioned = await service.provisionAccount(team.uid, owner.id, { + username: u, + email: `${u}@test.local`, + }); + + await service.deleteWorkspace(team.uid, owner.id); + + // Otherwise they keep working, unreachable through a deleted workspace. + const row = await suspensionOf(provisioned.userId); + expect(Boolean(row.suspended)).toBe(true); + expect(row.suspended_reason).toBe('disabled_by_workspace'); + }); + + it('leaves the workspace owner alone when its workspace is deleted', async () => { + const { team } = await makeWorkspace(); + await service.deleteWorkspace(team.uid, owner.id); + + // org_owned = 0, so it pays for itself and is not the workspace's to close. + expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false); + }); + + it('pages the audit rather than truncating it', async () => { + const { team } = await makeWorkspace(); + for (let i = 0; i < 2; i++) { + const u = `pg_${Math.random().toString(36).slice(2, 9)}`; + await service.provisionAccount(team.uid, owner.id, { + username: u, + email: `${u}@test.local`, + }); + } + + const first = await service.listAudit(team.uid, owner.id, { limit: 1 }); + expect(first.items).toHaveLength(1); + expect(first.cursor).toBeTruthy(); + + // Older entries stay reachable instead of dropping off the view. + const second = await service.listAudit(team.uid, owner.id, { limit: 1 }); + expect(second.items).toHaveLength(1); + }); }); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 8089ef039..d7c8a2d1a 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -28,7 +28,11 @@ import { } from '../../controllers/auth/AuthController.js'; import { HttpError } from '../../core/http/HttpError.js'; import { checkHandle } from '../../stores/team/TeamStore.js'; -import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore'; +import type { + TeamAuditRow, + TeamMemberRow, + TeamRow, +} from '../../stores/team/TeamStore'; import type { UserRow } from '../../stores/user/UserStore'; import { cleanEmail } from '../../util/email.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; @@ -52,8 +56,7 @@ export const generateTemporaryPassword = (length = 16): string => { export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace'; export class TeamService extends PuterService { - // -- Authority ---------------------------------------------------- - // Two checks, and between them the whole authorization model. + // -- Authority ---- the whole authorization model ------------------ /** 404 to a non-member so the endpoint is not an existence oracle. */ async requireMembership( @@ -70,7 +73,10 @@ export class TeamService extends PuterService { } /** Authority is one test: the caller is the account named by the workspace. */ - async requireOwner(teamUid: string, actorUserId: number): Promise { + async requireOwner( + teamUid: string, + actorUserId: number, + ): Promise { const team = await this.requireMembership(teamUid, actorUserId); if (team.owner_user_id !== actorUserId) { throw new HttpError(403, 'Only the workspace owner can do that', { @@ -172,8 +178,7 @@ export class TeamService extends PuterService { }), ); - // 0 makes the owner pay for itself and stay an invalid route target. - // Unchecked, the owner could never reach their own workspace. + // 0 makes the owner pay for itself; unchecked, it is unreachable. const admitted = await this.stores.team.addMember( team.uid, ownerUserId, @@ -209,8 +214,106 @@ export class TeamService extends PuterService { return Number(rows[0]?.n) === 1; } - // -- Provisioning ------------------------------------------------- - // Usernames come from Puter's global pool, so a taken one is reported. + /** Soft delete disables the accounts it created; recovery is via support. */ + async deleteWorkspace(teamUid: string, actorUserId: number): Promise { + const team = await this.requireOwner(teamUid, actorUserId); + + // Otherwise they keep working, unreachable through a deleted workspace. + let page = await this.stores.team.listMembers(teamUid, { limit: 200 }); + for (;;) { + for (const member of page.items) { + if (Number(member.org_owned) !== 1) continue; + await this.#suspend(member.user_id); + await this.stores.team.appendAudit({ + teamId: team.id, + userId: member.user_id, + actorUserId, + action: 'disable', + reason: 'workspace_deleted', + }); + } + if (!page.cursor) break; + page = await this.stores.team.listMembers(teamUid, { + limit: 200, + cursor: page.cursor, + }); + } + + await this.stores.team.appendAudit({ + teamId: team.id, + userId: actorUserId, + actorUserId, + action: 'delete_team', + }); + await this.stores.team.softDelete(teamUid); + } + + /** Workspace owner only. Readable after deletion -- that is the point of it. */ + async listAudit( + teamUid: string, + actorUserId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ) { + const team = await this.#requireOwnedWorkspace(teamUid, actorUserId); + return this.#withUsernames( + await this.stores.team.listAudit(team.id, opts), + ); + } + + /** The caller's own entries; the only reader who is not the actor. */ + async listOwnAudit( + teamUid: string, + actorUserId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ) { + const team = await this.requireMembership(teamUid, actorUserId); + return this.#withUsernames( + await this.stores.team.listAuditForUser(team.id, actorUserId, opts), + ); + } + + /** Resolves a workspace the caller owns, soft-deleted or not. */ + async #requireOwnedWorkspace( + teamUid: string, + actorUserId: number, + ): Promise { + const live = await this.stores.team.getByUid(teamUid); + if (live) return this.requireOwner(teamUid, actorUserId); + + const deleted = + await this.stores.team.getByUidIncludingDeleted(teamUid); + if (!deleted || deleted.owner_user_id !== actorUserId) { + throw new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + return deleted; + } + + /** Internal user ids never reach the wire, as `toClientTeam` does for `id`. */ + async #withUsernames(page: { items: TeamAuditRow[]; cursor?: string }) { + const ids = new Set(); + for (const row of page.items) { + ids.add(row.user_id_keep); + if (row.actor_user_id !== null) ids.add(row.actor_user_id); + } + const users = await this.stores.user.getByIds([...ids]); + const name = (id: number | null) => + id === null ? null : (users.get(id)?.username ?? null); + + return { + items: page.items.map((row) => ({ + action: row.action, + reason: row.reason, + created_at: row.created_at, + username: name(row.user_id_keep), + actor_username: name(row.actor_user_id), + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + }; + } + + // -- Provisioning ---- usernames come from the global pool ---------- /** Provisioning must not mint accounts signup itself would refuse. */ #usernameRejection(username: string): boolean { @@ -302,8 +405,14 @@ export class TeamService extends PuterService { }); } - // Returned once for the administrator to deliver out of band; forced - // change on first use is what bounds it. + await this.stores.team.appendAudit({ + teamId: team.id, + userId: user.id, + actorUserId, + action: 'provision', + }); + + // Returned once; forced change on first use is what bounds it. const temporaryPassword = generateTemporaryPassword(); await this.stores.user.update(user.id, { password: await bcrypt.hash(temporaryPassword, 8), @@ -372,8 +481,7 @@ export class TeamService extends PuterService { } } - // -- Disable and re-enable ---------------------------------------- - // The whole of offboarding: no removal, no transfer, no retention clock. + // -- Disable and re-enable ---- the whole of offboarding ------------ /** Rejects the account's next request; its files are untouched. */ async disableMember( @@ -381,16 +489,17 @@ export class TeamService extends PuterService { actorUserId: number, targetUserId: number, ): Promise { - await this.requireOwner(teamUid, actorUserId); + const team = await this.requireOwner(teamUid, actorUserId); await this.requireOrgAccount(teamUid, targetUserId); - // `suspended` is what `userProtected` enforces; the others are siblings. - await this.stores.user.update(targetUserId, { - suspended: 1, - suspended_at: Math.floor(Date.now() / 1000), - suspended_reason: DISABLED_BY_WORKSPACE, + // Recorded first: a failed append must not leave an unlogged suspension. + await this.stores.team.appendAudit({ + teamId: team.id, + userId: targetUserId, + actorUserId, + action: 'disable', }); - await this.#dropSessions(targetUserId); + await this.#suspend(targetUserId); } /** Nothing was destroyed, so the account returns as it was. */ @@ -399,7 +508,7 @@ export class TeamService extends PuterService { actorUserId: number, targetUserId: number, ): Promise { - await this.requireOwner(teamUid, actorUserId); + const team = await this.requireOwner(teamUid, actorUserId); await this.requireOrgAccount(teamUid, targetUserId); // Forced read, as `userProtected` does: a cached row predates this. @@ -416,6 +525,12 @@ export class TeamService extends PuterService { }); } + await this.stores.team.appendAudit({ + teamId: team.id, + userId: targetUserId, + actorUserId, + action: 'enable', + }); await this.stores.user.update(targetUserId, { suspended: 0, suspended_at: null, @@ -424,6 +539,16 @@ export class TeamService extends PuterService { await this.stores.user.invalidateById(targetUserId); } + /** The three columns together; `suspended` is the one that gates requests. */ + async #suspend(userId: number): Promise { + await this.stores.user.update(userId, { + suspended: 1, + suspended_at: Math.floor(Date.now() / 1000), + suspended_reason: DISABLED_BY_WORKSPACE, + }); + await this.#dropSessions(userId); + } + /** Via the store: a raw DELETE leaves the session cache serving the row. */ async #dropSessions(userId: number): Promise { const rows = (await this.clients.db.read( diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 5cf15bebc..4616d8019 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -60,9 +60,21 @@ export interface TeamMemberRow { created_at: string; } +/** One entry in the insert-only record of what a workspace did to an account. */ +export interface TeamAuditRow { + id: number; + user_id_keep: number; + actor_user_id: number | null; + action: string; + reason: string | null; + created_at: string; +} + /** Default and ceiling for `listMembers`, matching the other paginated stores. */ export const MEMBER_PAGE_SIZE = 50; export const MEMBER_PAGE_CAP = 200; +export const AUDIT_PAGE_SIZE = 50; +export const AUDIT_PAGE_CAP = 200; /** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */ export const HANDLE_MAX_LENGTH = 64; @@ -167,6 +179,15 @@ export class TeamStore extends PuterStore { return (rows[0] as unknown as TeamRow) ?? null; } + /** Includes soft-deleted rows, so an audit survives its workspace. */ + async getByUidIncludingDeleted(uid: string): Promise { + const rows = await this.clients.db.read( + 'SELECT * FROM `group` WHERE `uid` = ? AND `kind` = ?', + [uid, TEAM_KIND], + ); + return (rows[0] as unknown as TeamRow) ?? null; + } + /** For availability checks and console resolution; callers address by uid. */ async getByHandle(handle: string): Promise { const rows = await this.clients.db.read( @@ -347,6 +368,93 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } + /** How many members pay for themselves; the owner should be the only one. */ + async countPayers(teamId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + + 'WHERE `group_id` = ? AND `org_owned` = 0', + [teamId], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + + // -- Audit ---- insert-only; no update or delete path exists -------- + + /** Records something the workspace did to an account. */ + async appendAudit(entry: { + teamId: number; + userId: number; + actorUserId: number; + action: string; + reason?: string | null; + }): Promise { + await this.clients.db.write( + 'INSERT INTO `audit_team_membership` ' + + '(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' + + '`actor_user_id`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + entry.teamId, + entry.teamId, + entry.userId, + entry.userId, + entry.actorUserId, + entry.action, + entry.reason ?? null, + ], + ); + } + + /** The whole workspace's audit, newest first, keyset-paginated on `id`. */ + async listAudit( + teamId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ): Promise> { + return this.#pageAudit('`group_id_keep` = ?', [teamId], opts); + } + + /** One member's own entries. Scoped by user, not by workspace. */ + async listAuditForUser( + teamId: number, + userId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ): Promise> { + return this.#pageAudit( + '`group_id_keep` = ? AND `user_id_keep` = ?', + [teamId, userId], + opts, + ); + } + + /** Descending keyset, so older entries are reachable rather than dropped. */ + async #pageAudit( + where: string, + params: unknown[], + opts: { limit?: unknown; cursor?: string }, + ): Promise> { + const limit = + normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ?? + AUDIT_PAGE_SIZE; + const page = decodeCursor(opts.cursor, 'team audit cursor'); + const before = typeof page?.id === 'number' ? page.id : null; + + const rows = (await this.clients.db.read( + 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, `created_at` ' + + `FROM \`audit_team_membership\` WHERE ${where}` + + (before === null ? '' : ' AND `id` < ?') + + ' ORDER BY `id` DESC LIMIT ?', + before === null + ? [...params, limit + 1] + : [...params, before, limit + 1], + )) as unknown as TeamAuditRow[]; + + const items = rows.slice(0, limit); + const cursor = + rows.length > limit + ? encodeCursor({ id: items[items.length - 1].id }) + : undefined; + return { items, cursor }; + } + /** Removes a member, returning whether a row was there to remove. */ async removeMember(teamUid: string, userId: number): Promise { const result = await this.clients.db.write( diff --git a/src/backend/types.ts b/src/backend/types.ts index fb2fbe542..76f7748a3 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -239,12 +239,7 @@ export interface IPreludeConfig { * an RCS agent provisioned in the Prelude account to actually use RCS. */ preferredChannel?: - | 'sms' - | 'rcs' - | 'whatsapp' - | 'viber' - | 'zalo' - | 'telegram'; + 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram'; } /** @@ -682,6 +677,13 @@ interface IConfigOptional { * this to the public port. */ pub_port: number; + /** + * Teams and workspaces. Off means `/teams` 404s and the schema is inert, so + * the tables can ship to production before anything can create a workspace. + * It is also the backout: turning it off removes the feature without + * touching data. + */ + teams_enabled: boolean; /** * Fully-qualified externally-visible URL (protocol + domain + port). * Computed from `protocol`/`domain`/`pub_port` if unset. @@ -1189,7 +1191,8 @@ export interface WithLifecycle extends Object { } export interface WithCostsReporting extends WithLifecycle { - getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any + getReportedCosts?: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any | Promise[]> // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record[];