feat: stage the teams rollout behind an email-domain allowlist

`teams_allowed_email_domains` limits who may enter the teams surface;
unset keeps today's behavior. Gated on the two routes that constitute
entry — creating a team, and the listing that shows the tab — with the
same 404 a teams-off deployment answers, so the GUI needs no change and
a staged rollout is indistinguishable from the feature being off.
Members of an existing team always pass, whatever their domain: an
allowed owner brought them in, and the surface follows the team.
This commit is contained in:
Juan Castro
2026-09-15 14:23:49 -04:00
parent 230241d6d2
commit ad3d15e7e2
5 changed files with 172 additions and 0 deletions
+4
View File
@@ -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
@@ -85,6 +85,7 @@ export class TeamController extends PuterController {
})
async createTeam(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+26
View File
@@ -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<boolean> {
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,
+5
View File
@@ -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.