feat: add TeamController, the teams_enabled flag, and the isolation suite

Covers PUT-1708, PUT-1709 and PUT-1743.

Twelve routes, every one setting requireUserActor -- that option is what
installs requireAuthGate, requireVerifiedAccount and requireNonAccessTokenGate,
because server.ts derives `needsAuth` from the route options. Reads need it as
much as writes: without an auth option a route gets no suspension check and
admits access tokens, so a just-disabled member could still read the roster and
a scoped third-party token could read the audit log.

Authority is checked before anything observable. Validating the body first made
POST /members answer 400 before 403, and resolving :username first turned the
member routes into a global username-existence oracle.

Provisioning applies the same username and email rules as signup rather than
its own -- USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and
validator.isEmail, now exported from AuthController. Without them a workspace
could mint accounts signup would refuse, claim unregistered reserved names, and
mail arbitrary unvalidated addresses.

Handle problems are 400 or 409 rather than a bare Error, which the server turns
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable drops sessions through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key; without that a
disabled member kept authenticating from cache for the session TTL, which is
exactly the "takes effect on the next request, not after a cache TTL" property
disable is supposed to have. Revoking also preserves last_ip/last_user_agent,
which the member-facing audit view reads.

Audit writes live in TeamService at the point of each action rather than in the
route, so a caller reaching the service directly cannot skip them, and the SQL
lives in TeamStore. Audit reads map internal user ids to usernames, and remain
readable by the owner after the workspace is soft-deleted -- otherwise the
delete_team entry was written and immediately unreachable.

teams_enabled gates route registration through an optional isEnabled() the
server honours, so with it off the paths do not exist rather than existing and
refusing. It does not gate DDL.

TeamIsolation.http.test.ts asserts the negative the feature rests on: the
workspace manages accounts and cannot read them, including through a
full-access token and after the member is disabled. It asserts outcomes rather
than the absence of an implicator.
This commit is contained in:
Juan Castro
2026-09-03 15:57:23 -04:00
parent 673f7fe75f
commit 9bc2cd741c
11 changed files with 1272 additions and 31 deletions
+1
View File
@@ -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",
+8
View File
@@ -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.
+2
View File
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, unknown>;
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();
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> {
return (req.body ?? {}) as Record<string, unknown>;
}
#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<number> {
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;
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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);
});
});
+8
View File
@@ -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<string, unknown>)[
+108 -4
View File
@@ -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);
});
});
+145 -20
View File
@@ -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<TeamRow> {
async requireOwner(
teamUid: string,
actorUserId: number,
): Promise<TeamRow> {
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<void> {
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<TeamRow> {
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<number>();
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<void> {
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<void> {
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<void> {
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<void> {
const rows = (await this.clients.db.read(
+108
View File
@@ -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<TeamRow | null> {
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<TeamRow | null> {
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<number> {
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<void> {
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<PageResult<TeamAuditRow>> {
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<PageResult<TeamAuditRow>> {
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<PageResult<TeamAuditRow>> {
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<boolean> {
const result = await this.clients.db.write(
+10 -7
View File
@@ -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<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];