diff --git a/config.template.jsonc b/config.template.jsonc index 911ce3ec8..07f757206 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -300,6 +300,10 @@ // ignore plans entirely. Unset by default — leave it that way to keep the // free cap meaningful. // "max_seats_per_team": 50, + // + // Staged rollout: only these email domains may create a team or see the + // tab. Members of an existing team always pass. Unset means everyone. + // "teams_allowed_email_domains": ["puter.com"], // ── Notifications ─────────────────────────────────────────────────── // How long a notification is kept, in days from creation. Acknowledged or diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index 2ae90e7c1..2488aa41d 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -85,6 +85,7 @@ export class TeamController extends PuterController { }) async createTeam(req: Request, res: Response): Promise { const userId = this.#requireUserId(req); + await this.#requireTeamsAvailable(req, userId); const body = this.#body(req); const team = await this.services.team.createTeam(userId, { @@ -105,6 +106,7 @@ export class TeamController extends PuterController { }) async listTeams(req: Request, res: Response): Promise { const userId = this.#requireUserId(req); + await this.#requireTeamsAvailable(req, userId); const teams = await this.stores.team.listTeamsForUser(userId); res.json({ items: teams.map((t) => @@ -410,6 +412,18 @@ export class TeamController extends PuterController { }; } + /** + * The domain-allowlist gate, on the two routes that enter the feature. Same + * 404 as a teams-off deployment; other routes bound by membership. + */ + async #requireTeamsAvailable(req: Request, userId: number): Promise { + const email = ( + req.actor as { user?: { email?: string | null } } | undefined + )?.user?.email; + if (await this.services.team.teamsAvailableTo(userId, email)) return; + throw new HttpError(404, 'Not found', { legacyCode: 'not_found' }); + } + #requireUserId(req: Request): number { const id = (req.actor as { user?: { id?: number } } | undefined)?.user ?.id; diff --git a/src/backend/services/team/TeamDomainGate.test.ts b/src/backend/services/team/TeamDomainGate.test.ts new file mode 100644 index 000000000..895554eaf --- /dev/null +++ b/src/backend/services/team/TeamDomainGate.test.ts @@ -0,0 +1,123 @@ +/** + * 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 type { IConfig } from '../../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.ts'; + +describe('teams email-domain allowlist', () => { + let env: PuterTestEnv; + + const makeUser = async (email: string) => { + const username = `dg_${Math.random().toString(36).slice(2, 10)}`; + const created = (await env.server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email, + email_confirmed: true, + })) as unknown as { id: number }; + const row = await env.server.stores.user.getById(created.id); + const { token } = await env.server.services.auth.createSessionToken( + row!, + ); + return { id: created.id, username, token }; + }; + + 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) }), + }); + + const create = (token: string) => + call('POST', '/teams', token, { + name: 'Gate', + handle: `gt-${Math.random().toString(36).slice(2, 10)}`, + }); + + beforeAll(async () => { + env = await setupPuterTestEnv({ + teams_enabled: true, + teams_allowed_email_domains: ['puter.com'], + max_teams_per_user: 10, + } as IConfig); + }, 180_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + it('lets an allowed domain create and list', async () => { + const staff = await makeUser(`${uuidv4().slice(0, 8)}@puter.com`); + expect((await create(staff.token)).status).toBe(200); + expect((await call('GET', '/teams', staff.token)).status).toBe(200); + }); + + it('matches the domain case-insensitively', async () => { + const staff = await makeUser(`${uuidv4().slice(0, 8)}@PUTER.COM`); + expect((await create(staff.token)).status).toBe(200); + }); + + it('answers other domains with the same 404 as teams-off', async () => { + const outsider = await makeUser(`${uuidv4().slice(0, 8)}@gmail.com`); + expect((await create(outsider.token)).status).toBe(404); + const list = await call('GET', '/teams', outsider.token); + expect(list.status).toBe(404); + // `not_found` is what the GUI reads as "feature not here". + expect(((await list.json()) as { code?: string }).code).toBe( + 'not_found', + ); + }); + + it('lets a provisioned seat through on membership alone', async () => { + const staff = await makeUser(`${uuidv4().slice(0, 8)}@puter.com`); + const created = await create(staff.token); + const team = (await created.json()) as { uid: string }; + + const seatName = `sg_${Math.random().toString(36).slice(2, 10)}`; + const provisioned = await call( + 'POST', + `/teams/${team.uid}/members`, + staff.token, + { username: seatName, email: `${seatName}@gmail.com` }, + ); + expect(provisioned.status).toBe(200); + + // Off-domain, but someone allowed brought them in. + const seatRow = await env.server.stores.user.getByUsername(seatName); + await env.server.stores.user.update(seatRow!.id, { + email_confirmed: 1, + requires_email_confirmation: 0, + requires_password_change: 0, + }); + const { token } = await env.server.services.auth.createSessionToken( + (await env.server.stores.user.getById(seatRow!.id))!, + ); + const list = await call('GET', '/teams', token); + expect(list.status).toBe(200); + const body = (await list.json()) as { items: Array<{ uid: string }> }; + expect(body.items.map((t) => t.uid)).toContain(team.uid); + }); +}); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 961447c02..3c23b1149 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -199,6 +199,32 @@ export class TeamService extends PuterService { } // -- Authority ---- the whole authorization model ------------------ + /** + * Whether this account may enter the teams surface. Membership in any team + * always passes: an allowed owner brought them in. + */ + async teamsAvailableTo( + userId: number, + email: string | null | undefined, + ): Promise { + const domains = this.config.teams_allowed_email_domains; + if (!Array.isArray(domains) || domains.length === 0) return true; + const at = String(email ?? '').lastIndexOf('@'); + const domain = + at === -1 + ? '' + : String(email) + .slice(at + 1) + .toLowerCase(); + if ( + domain !== '' && + domains.some((d) => String(d).toLowerCase() === domain) + ) { + return true; + } + return (await this.stores.team.listGroupIdsForUser(userId)).length > 0; + } + /** 404 to a non-member so the endpoint is not an existence oracle. */ async requireMembership( teamUid: string, diff --git a/src/backend/types.ts b/src/backend/types.ts index 99d9c6da6..80f7ad0d9 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -692,6 +692,11 @@ interface IConfigOptional { max_seats_per_team_paid?: number; /** One flat cap whatever the owner pays; overrides both of the above. */ max_seats_per_team?: number; + /** + * Only these email domains may enter the teams surface; members of an + * existing team always pass. Unset means everyone. + */ + teams_allowed_email_domains?: string[]; /** * Fully-qualified externally-visible URL (protocol + domain + port). * Computed from `protocol`/`domain`/`pub_port` if unset.