diff --git a/src/backend/core/http/HttpError.ts b/src/backend/core/http/HttpError.ts index 2a7046295..a13580e7c 100644 --- a/src/backend/core/http/HttpError.ts +++ b/src/backend/core/http/HttpError.ts @@ -54,7 +54,10 @@ export type LegacyErrorCodes = | 'password_mismatch' | 'field_not_allowed_for_create' | 'account_is_not_verified' - | 'app_or_api_token_required'; + | 'app_or_api_token_required' + | 'team_not_found' + | 'not_the_workspace_owner' + | 'not_an_org_account'; /** * Copyright (C) 2024-present Puter Technologies Inc. diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index dfb5c41c6..e769bce03 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -40,6 +40,7 @@ import { PermissionService } from './permission/PermissionService'; import { DefaultUserService } from './selfhosted/DefaultUserService'; import { ShareNotificationService } from './share/ShareNotificationService'; import { ShareService } from './share/ShareService'; +import { TeamService } from './team/TeamService'; import { SocketService } from './socket/SocketService'; import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService'; import type { IPuterServiceRegistry } from './types'; @@ -79,6 +80,7 @@ declare module './types' { homepage: PuterHomepageService; health: ServerHealthService; userAccount: UserAccountService; + team: TeamService; } } @@ -110,6 +112,8 @@ export const puterServices = { // Declared after `fs` — account teardown tears the user's filesystem down // first. userAccount: UserAccountService, + // Leaf: team + user stores only. + team: TeamService, // AppPermissionService + SubdomainPermissionService register permission // rewriters/implicators only; no runtime state. Placed after fsEntry so // the FS rewriter runs first for `fs:/path` → `fs:` before any diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts new file mode 100644 index 000000000..4a21a2852 --- /dev/null +++ b/src/backend/services/team/TeamService.test.ts @@ -0,0 +1,260 @@ +/** + * 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 { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('TeamService', () => { + let server: PuterServer; + let service: PuterServer['services']['team']; + let owner: { id: number }; + + const makeUser = async (): Promise<{ id: number; username: string }> => { + const username = `svc-${Math.random().toString(36).slice(2, 10)}`; + const created = (await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + })) as unknown as { id: number }; + return { id: created.id, username }; + }; + + const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`; + + /** A workspace with the owner admitted and one provisioned member. */ + const makeWorkspace = async () => { + const team = await service.createWorkspace(owner.id, { + name: 'Acme', + handle: freeHandle(), + }); + const member = await makeUser(); + await server.stores.team.addMember(team.uid, member.id, { + orgOwned: true, + }); + return { team, member }; + }; + + const suspensionOf = async (userId: number) => { + const [row] = (await server.clients.db.read( + 'SELECT `suspended`, `suspended_at`, `suspended_reason` FROM `user` WHERE `id` = ?', + [userId], + )) as { + suspended: number | null; + suspended_at: number | null; + suspended_reason: string | null; + }[]; + return row; + }; + + beforeAll(async () => { + server = await setupTestServer(); + service = server.services.team; + owner = await makeUser(); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + // -- creating a workspace ----------------------------------------- + + it('creates a workspace and admits its creator as the workspace owner', async () => { + const team = await service.createWorkspace(owner.id, { + name: 'Acme Design', + handle: freeHandle(), + }); + + expect(team.owner_user_id).toBe(owner.id); + const membership = await server.stores.team.getMembership( + team.uid, + owner.id, + ); + // 0 is what makes the owner pay for itself. + expect(Number(membership?.org_owned)).toBe(0); + }); + + it('holds the owner invariant that no dialect can express', async () => { + const { team } = await makeWorkspace(); + await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(true); + }); + + it('breaks the invariant if a second account is admitted as the payer', async () => { + const { team } = await makeWorkspace(); + const other = await makeUser(); + await server.stores.team.addMember(team.uid, other.id, { + orgOwned: false, + }); + + // The check exists precisely because the schema cannot refuse this. + await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false); + }); + + // -- authority ---------------------------------------------------- + + it('refuses a member who is not the workspace owner', async () => { + const { team, member } = await makeWorkspace(); + await expect( + service.requireOwner(team.uid, member.id), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('gives a stranger 404 rather than 403, so it is not an oracle', async () => { + const { team } = await makeWorkspace(); + const stranger = await makeUser(); + await expect( + service.requireMembership(team.uid, stranger.id), + ).rejects.toMatchObject({ statusCode: 404 }); + await expect( + service.requireOwner(team.uid, stranger.id), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('refuses the workspace owner as the target of a member route', async () => { + const { team } = await makeWorkspace(); + await expect( + service.requireOrgAccount(team.uid, owner.id), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + // -- disable and re-enable ---------------------------------------- + + it('sets the column the request gate actually reads', async () => { + const { team, member } = await makeWorkspace(); + + 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. + expect(Boolean(row.suspended)).toBe(true); + expect(row.suspended_at).toBeGreaterThan(0); + expect(row.suspended_reason).toBe('disabled_by_workspace'); + }); + + it('drops the disabled account\'s sessions', async () => { + const { team, member } = await makeWorkspace(); + await server.clients.db.write( + 'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)', + [uuidv4(), member.id], + ); + + 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. + const live = await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL', + [member.id], + ); + expect(Number(live[0].n)).toBe(0); + + const kept = await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ?', + [member.id], + ); + expect(Number(kept[0].n)).toBe(1); + }); + + it('restores the account exactly as it was on re-enable', async () => { + const { team, member } = await makeWorkspace(); + await service.disableMember(team.uid, owner.id, member.id); + + await service.enableMember(team.uid, owner.id, member.id); + + const row = await suspensionOf(member.id); + expect(Boolean(row.suspended)).toBe(false); + expect(row.suspended_at).toBeNull(); + expect(row.suspended_reason).toBeNull(); + }); + + it('leaves the disabled account\'s files alone', async () => { + const { team, member } = await makeWorkspace(); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)', + [uuidv4(), 'kept.txt', member.id, 0], + ); + + await service.disableMember(team.uid, owner.id, member.id); + + const rows = await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `fsentries` WHERE `user_id` = ?', + [member.id], + ); + expect(Number(rows[0].n)).toBe(1); + }); + + it('refuses to disable the workspace owner', async () => { + const { team } = await makeWorkspace(); + await expect( + service.disableMember(team.uid, owner.id, owner.id), + ).rejects.toMatchObject({ statusCode: 404 }); + + expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false); + }); + + it('refuses a disable ordered by someone who is not the owner', async () => { + const { team, member } = await makeWorkspace(); + const other = await makeUser(); + await server.stores.team.addMember(team.uid, other.id, { + orgOwned: true, + }); + + await expect( + service.disableMember(team.uid, other.id, member.id), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false); + }); + + it('refuses to disable a member of another workspace', async () => { + const a = await makeWorkspace(); + const b = await makeWorkspace(); + + await expect( + service.disableMember(a.team.uid, owner.id, b.member.id), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + it('refuses to lift a suspension the workspace did not impose', async () => { + const { team, member } = await makeWorkspace(); + // What a platform abuse suspension looks like. + await server.stores.user.update(member.id, { + suspended: 1, + suspended_at: Math.floor(Date.now() / 1000), + suspended_reason: 'abuse_detected', + }); + + await expect( + service.enableMember(team.uid, owner.id, member.id), + ).rejects.toMatchObject({ statusCode: 409 }); + + const row = await suspensionOf(member.id); + expect(Boolean(row.suspended)).toBe(true); + expect(row.suspended_reason).toBe('abuse_detected'); + }); + + it('lifts its own suspension normally', async () => { + const { team, member } = await makeWorkspace(); + await service.disableMember(team.uid, owner.id, member.id); + + await service.enableMember(team.uid, owner.id, member.id); + expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false); + }); +}); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts new file mode 100644 index 000000000..5d70db01d --- /dev/null +++ b/src/backend/services/team/TeamService.ts @@ -0,0 +1,249 @@ +/* + * 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 { HttpError } from '../../core/http/HttpError.js'; +import { checkHandle } from '../../stores/team/TeamStore.js'; +import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore'; +import { PuterService } from '../types'; + +/** Why an account was disabled. Free text in `0063`; this is the team one. */ +export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace'; + +export class TeamService extends PuterService { + // -- Authority ---------------------------------------------------- + // Two checks, and between them the whole authorization model. + + /** 404 to a non-member so the endpoint is not an existence oracle. */ + async requireMembership( + teamUid: string, + actorUserId: number, + ): Promise { + const team = await this.stores.team.getByUid(teamUid); + if (!team || !(await this.stores.team.isMember(teamUid, actorUserId))) { + throw new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + return team; + } + + /** Authority is one test: the caller is the account named by the workspace. */ + 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', { + legacyCode: 'not_the_workspace_owner', + }); + } + return team; + } + + /** The workspace owner is never a valid target of a member route. */ + async requireOrgAccount( + teamUid: string, + targetUserId: number, + ): Promise { + const membership = await this.stores.team.getMembership( + teamUid, + targetUserId, + ); + // Tested explicitly, never inferred from NULL. + if (!membership || Number(membership.org_owned) !== 1) { + throw new HttpError(404, 'Not an account of this workspace', { + legacyCode: 'not_an_org_account', + }); + } + return membership; + } + + // -- Workspace lifecycle ------------------------------------------ + + /** A rejected handle is 400, a taken one 409, never an unhandled 500. */ + async assertHandleUsable(handle: string): Promise { + const rejection = checkHandle(handle); + if (rejection) { + throw new HttpError(400, `Unusable handle: ${rejection}`, { + legacyCode: 'bad_request', + }); + } + if (!(await this.stores.team.isHandleAvailable(handle))) { + throw new HttpError(409, 'That handle is taken', { + legacyCode: 'conflict', + }); + } + } + + /** The unique index is the arbiter, so a race still lands as a 409. */ + async #asHttpErrors(run: () => Promise): Promise { + try { + return await run(); + } catch (e) { + if (e instanceof HttpError) throw e; + const message = String((e as Error)?.message ?? ''); + if (/unusable team handle/iu.test(message)) { + throw new HttpError(400, message, { + legacyCode: 'bad_request', + }); + } + if (/unique|duplicate/iu.test(message)) { + throw new HttpError(409, 'That handle is taken', { + legacyCode: 'conflict', + }); + } + throw e; + } + } + + /** Renames or re-handles a workspace, refusing an unusable handle. */ + async updateWorkspace( + teamUid: string, + actorUserId: number, + changes: { name?: string; handle?: string | null }, + ): Promise { + await this.requireOwner(teamUid, actorUserId); + if (changes.handle) await this.assertHandleUsable(changes.handle); + + const team = await this.#asHttpErrors(() => + this.stores.team.update(teamUid, changes), + ); + if (!team) { + throw new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + return team; + } + + /** Creates a workspace and admits its creator as the workspace owner. */ + async createWorkspace( + ownerUserId: number, + input: { name: string; handle?: string | null }, + ): Promise { + const handle = input.handle ?? null; + if (handle !== null) await this.assertHandleUsable(handle); + + const team = await this.#asHttpErrors(() => + this.stores.team.create({ + ownerUserId, + name: input.name, + handle, + }), + ); + + // 0 makes the owner pay for itself and stay an invalid route target. + // Unchecked, the owner could never reach their own workspace. + const admitted = await this.stores.team.addMember( + team.uid, + ownerUserId, + { + orgOwned: false, + }, + ); + if (!admitted) { + await this.stores.team.softDelete(team.uid); + throw new HttpError(500, 'Could not create the workspace', { + legacyCode: 'internal_error', + }); + } + return team; + } + + /** The owner is a member with `org_owned = 0`, and the only such member. */ + async checkOwnerInvariant(teamUid: string): Promise { + const team = await this.stores.team.getByUid(teamUid); + if (!team) return false; + + const owner = await this.stores.team.getMembership( + teamUid, + team.owner_user_id, + ); + if (!owner || Number(owner.org_owned) !== 0) return false; + + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + + 'WHERE `group_id` = ? AND `org_owned` = 0', + [team.id], + )) as { n: number }[]; + return Number(rows[0]?.n) === 1; + } + + // -- Disable and re-enable ---------------------------------------- + // The whole of offboarding: no removal, no transfer, no retention clock. + + /** Rejects the account's next request; its files are untouched. */ + async disableMember( + teamUid: string, + actorUserId: number, + targetUserId: number, + ): Promise { + 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, + }); + await this.#dropSessions(targetUserId); + } + + /** Nothing was destroyed, so the account returns as it was. */ + async enableMember( + teamUid: string, + actorUserId: number, + targetUserId: number, + ): Promise { + await this.requireOwner(teamUid, actorUserId); + await this.requireOrgAccount(teamUid, targetUserId); + + // Forced read, as `userProtected` does: a cached row predates this. + const user = await this.stores.user.getByProperty('id', targetUserId, { + force: true, + }); + // Only the workspace's own suspension; a platform one must not lift. + if ( + user?.suspended && + user.suspended_reason !== DISABLED_BY_WORKSPACE + ) { + throw new HttpError(409, 'That account was suspended by Puter', { + legacyCode: 'conflict', + }); + } + + await this.stores.user.update(targetUserId, { + suspended: 0, + suspended_at: null, + suspended_reason: null, + }); + await this.stores.user.invalidateById(targetUserId); + } + + /** 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( + 'SELECT `uuid` FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL', + [userId], + )) as { uuid: string }[]; + for (const row of rows) { + await this.stores.session.removeByUuid(row.uuid); + } + await this.stores.user.invalidateById(userId); + } +}