mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-20 12:15:57 +00:00
feat: add the two-team fixture and group permission grants
Two tickets, together because the fixture has no behaviour of its own — the
grant tests are its first real use, and "a grant to team A must not
resolve for B's members" is exactly a two-team assertion.
PUT-1719. Every query resolving team-scoped data has to take the
team as a parameter rather than infer it. One that forgets returns the
right rows against a database holding a single team, so a one-team
fixture does not give weaker coverage — it gives false confidence.
The fixture provides two teams, each with its own master and two
activated seats, plus a user in neither. Each team having its own master
is also what the shipped `max_teams_per_user: 1` requires, so it runs
against the real cap rather than lifting it the way the existing team suites
have to. Seats are activated before use: provisioning leaves
`requires_email_confirmation` set and `requireVerified` rejects them outright,
so an unactivated seat cannot call anything and every authorization assertion
built on one would be vacuous.
PUT-1725. The write half of group grants; the read half already worked, since
`#scanUserGroup` joins `jct_user_group` itself and resolves for whoever is a
member at scan time.
PermissionStore resolveGroupId, listGroupMemberUuids,
upsertUserGroupPerm, deleteUserGroupPerm,
auditUserGroupPerm
PermissionService grantUserGroupPermission, revokeUserGroupPermission
Both rewrite the permission first, so `fs:/path:mode` collapses to
`fs:<uuid>:mode` and a revoke names the same string the grant wrote; both gate
on `canManagePermission`; both audit into the existing
`audit_user_to_group_permissions`.
The delete is scoped to the issuer, so one issuer's revoke cannot drop
another's identical grant — the PK is (user_id, group_id, permission) and
user_id is the issuer.
Cache invalidation goes through `bumpCacheGenerations` with every member uuid
at once. That already announces to peer regions in a single event, so the
fan-out costs one broadcast rather than one per member.
There is no flat-KV equivalent for group grants, so unlike the user-to-user
path there is no second write to keep consistent and no second invalidation
surface.
Closes PUT-1719 and PUT-1725.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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 { Actor } from '../../core/actor';
|
||||
import {
|
||||
setupTwoTeams,
|
||||
type TwoTeams,
|
||||
} from '../../testFixtures/twoTeams.js';
|
||||
|
||||
describe('group grants', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
/** A plain user actor, which is what a grant is issued as. */
|
||||
const actorFor = async (userId: number): Promise<Actor> => {
|
||||
const user = await fx.env.server.stores.user.getById(userId);
|
||||
return { user } as unknown as Actor;
|
||||
};
|
||||
|
||||
/** Written directly: these tests are about granting, not about writing. */
|
||||
const makeFile = async (ownerId: number) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `f_${uid.slice(0, 8)}.txt`;
|
||||
const owner = await fx.env.server.stores.user.getById(ownerId);
|
||||
const path = `/${owner!.username}/${name}`;
|
||||
await fx.env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
ownerId,
|
||||
// `is_dir` is a real boolean on postgres.
|
||||
fx.env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return { path, uid };
|
||||
};
|
||||
|
||||
const permissions = () => fx.env.server.services.permission;
|
||||
const store = () => fx.env.server.stores.permission;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
// -- the fixture itself -------------------------------------------
|
||||
|
||||
it('builds two teams, each with its own owner and two seats', async () => {
|
||||
expect(fx.a.uid).not.toBe(fx.b.uid);
|
||||
expect(fx.a.owner.userId).not.toBe(fx.b.owner.userId);
|
||||
expect(fx.a.seats).toHaveLength(2);
|
||||
expect(fx.b.seats).toHaveLength(2);
|
||||
|
||||
// Distinct throughout, or it cannot tell this team from any.
|
||||
const ids = [
|
||||
fx.a.owner.userId,
|
||||
fx.b.owner.userId,
|
||||
...fx.a.seats.map((s) => s.userId),
|
||||
...fx.b.seats.map((s) => s.userId),
|
||||
fx.outsider.userId,
|
||||
];
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('runs against the shipped one-team-per-user cap', async () => {
|
||||
// Lifting the cap here would stop it resembling production.
|
||||
const cfg = fx.env.server.services.team.config as {
|
||||
max_teams_per_user?: number;
|
||||
};
|
||||
expect(cfg.max_teams_per_user ?? 1).toBe(1);
|
||||
});
|
||||
|
||||
it('seats can actually call the API', async () => {
|
||||
// An inert token would make every assertion below vacuous.
|
||||
const res = await fx.call('GET', `/teams/${fx.a.uid}`, fx.a.seats[0].token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// -- grants resolve for members, and only members -----------------
|
||||
|
||||
it('resolves a grant for every member of the team it was given to', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
const rows = await store().readUserGroupPerms(seat.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows, `seat ${seat.username}`).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resolve for the other team', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
// Why the fixture has two: with one, an unscoped query still passes.
|
||||
for (const seat of fx.b.seats) {
|
||||
const rows = await store().readUserGroupPerms(seat.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows, `B seat ${seat.username}`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resolve for a user in no team', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
const rows = await store().readUserGroupPerms(fx.outsider.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
// -- revoke --------------------------------------------------------
|
||||
|
||||
it('revoking removes it for every member', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
const removed = await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
expect(removed).toBe(true);
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat.userId, [permission]),
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('announces the revoke for every member, so their watches get settled', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
const announced: number[] = [];
|
||||
const bus = fx.env.server.clients.event;
|
||||
const listen = ((_k: string, d: { holderUserId: number }) => {
|
||||
announced.push(d.holderUserId);
|
||||
}) as never;
|
||||
bus.on('permission.revoked', listen);
|
||||
|
||||
try {
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
announced.length = 0;
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
} finally {
|
||||
bus.off?.('permission.revoked', listen);
|
||||
}
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(announced).toContain(seat.userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports false when there was nothing to revoke', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const removed = await permissions().revokeUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
);
|
||||
// Matching nothing is not an error, but a caller must be able to tell.
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it('one issuer revoking does not drop another issuer identical grant', async () => {
|
||||
// Revoker owns the file; the rival grant is written directly.
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const permission = `fs:${file.uid}:write`;
|
||||
const groupId = (await store().resolveGroupId(fx.a.uid))!;
|
||||
|
||||
// The only shape where the DELETE's issuer scoping decides anything.
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
await store().upsertUserGroupPerm(
|
||||
groupId,
|
||||
fx.outsider.userId,
|
||||
permission,
|
||||
{},
|
||||
);
|
||||
expect(
|
||||
await store().readUserGroupPerms(fx.a.seats[0].userId, [permission]),
|
||||
).toHaveLength(2);
|
||||
|
||||
await permissions().revokeUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
// The owner's row is gone; the other issuer's survives.
|
||||
const left = await store().readUserGroupPerms(fx.a.seats[0].userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(left).toHaveLength(1);
|
||||
expect(left[0].user_id).toBe(fx.outsider.userId);
|
||||
});
|
||||
|
||||
// -- authorization and shape --------------------------------------
|
||||
|
||||
it('refuses a grant the issuer has no authority over', async () => {
|
||||
const file = await makeFile(fx.b.owner.userId);
|
||||
|
||||
await expect(
|
||||
permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
`fs:${file.uid}:write`,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('404s on a group that does not exist', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
await expect(
|
||||
permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
`fs:${file.uid}:read`,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('round-trips a path-form permission through grant and revoke', async () => {
|
||||
const owner = await fx.env.server.stores.user.getById(
|
||||
fx.outsider.userId,
|
||||
);
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
// Both must collapse to the same `fs:<uuid>:` string, or nothing matches.
|
||||
const pathPerm = `fs:${file.path}:read`;
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
pathPerm,
|
||||
);
|
||||
expect(owner).toBeTruthy();
|
||||
|
||||
const stored = await store().readUserGroupPerms(fx.a.seats[0].userId, [
|
||||
`fs:${file.uid}:read`,
|
||||
]);
|
||||
expect(stored).toHaveLength(1);
|
||||
|
||||
expect(
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
pathPerm,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('audits both the grant and the revoke', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
const rows = (await fx.env.server.clients.db.read(
|
||||
'SELECT `action` FROM `audit_user_to_group_permissions` ' +
|
||||
'WHERE `permission` = ? ORDER BY `id`',
|
||||
[permission],
|
||||
)) as { action: string }[];
|
||||
expect(rows.map((r) => r.action)).toEqual(['grant', 'revoke']);
|
||||
});
|
||||
|
||||
it('stops resolving once the team is soft-deleted', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.b.uid,
|
||||
permission,
|
||||
);
|
||||
const seat = fx.b.seats[0].userId;
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat, [permission]),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Deletion suspends the seats but leaves memberships and grants, and
|
||||
// the uid stops resolving -- so this access would be unwithdrawable.
|
||||
await fx.env.server.stores.team.softDelete(fx.b.uid);
|
||||
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat, [permission]),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -885,6 +885,145 @@ export class PermissionService extends PuterService {
|
||||
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
|
||||
}
|
||||
|
||||
// -- Group grants ---- the write half; `#scanUserGroup` reads them ----
|
||||
|
||||
/** Resolved once here so neither grant nor revoke holds SQL. */
|
||||
async #requireGroupId(groupUid: string): Promise<number> {
|
||||
const groupId = await this.stores.permission.resolveGroupId(groupUid);
|
||||
if (groupId === null) {
|
||||
throw new HttpError(404, `group_does_not_exist: ${groupUid}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
|
||||
/** Batched: one event for the whole group, not one per member. */
|
||||
async #bumpGroupCacheGeneration(groupId: number): Promise<void> {
|
||||
const uuids =
|
||||
await this.stores.permission.listGroupMemberUuids(groupId);
|
||||
if (uuids.length === 0) return;
|
||||
await this.stores.permission.bumpCacheGenerations(
|
||||
uuids.map((uuid) => `user:${uuid}`),
|
||||
);
|
||||
}
|
||||
|
||||
async grantUserGroupPermission(
|
||||
actor: Actor,
|
||||
groupUid: string,
|
||||
permission: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
meta: GrantMeta = {},
|
||||
): Promise<void> {
|
||||
// First: the rewrite decides the row's width and what a revoke matches.
|
||||
permission = await this.rewritePermission(permission);
|
||||
if (permission.length > PERMISSION_MAX_LEN) {
|
||||
throw new HttpError(400, 'permission is too long', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const groupId = await this.#requireGroupId(groupUid);
|
||||
|
||||
if (!(await this.canManagePermission(actor, permission))) {
|
||||
throw new HttpError(403, `permission_denied: ${permission}`, {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
}
|
||||
if (!actor.user?.id) {
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
const issuerId = actor.user.id;
|
||||
|
||||
await this.stores.permission.upsertUserGroupPerm(
|
||||
groupId,
|
||||
issuerId,
|
||||
permission,
|
||||
extra,
|
||||
);
|
||||
|
||||
// Off the critical path, but a silent drop makes the log untrustworthy.
|
||||
this.stores.permission
|
||||
.auditUserGroupPerm({
|
||||
group_id: groupId,
|
||||
issuer_user_id: issuerId,
|
||||
permission,
|
||||
action: 'grant',
|
||||
reason: meta.reason ?? 'granted via PermissionService',
|
||||
extra: this.#auditActorContext(actor),
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
'[PermissionService] failed to audit user-group grant:',
|
||||
err,
|
||||
);
|
||||
});
|
||||
|
||||
await this.#bumpGroupCacheGeneration(groupId);
|
||||
}
|
||||
|
||||
/** Scoped to this issuer's grant; returns whether one was removed. */
|
||||
async revokeUserGroupPermission(
|
||||
actor: Actor,
|
||||
groupUid: string,
|
||||
permission: string,
|
||||
meta: GrantMeta = {},
|
||||
): Promise<boolean> {
|
||||
// Same rewrite as the grant, or this matches nothing and says it did.
|
||||
permission = await this.rewritePermission(permission);
|
||||
const groupId = await this.#requireGroupId(groupUid);
|
||||
|
||||
if (!actor.user?.id) {
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
const issuerId = actor.user.id;
|
||||
|
||||
if (!(await this.canManagePermission(actor, permission))) {
|
||||
throw new HttpError(403, `permission_denied: ${permission}`, {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
}
|
||||
|
||||
const revoked = await this.stores.permission.deleteUserGroupPerm(
|
||||
groupId,
|
||||
issuerId,
|
||||
permission,
|
||||
);
|
||||
|
||||
this.stores.permission
|
||||
.auditUserGroupPerm({
|
||||
group_id: groupId,
|
||||
issuer_user_id: issuerId,
|
||||
permission,
|
||||
action: 'revoke',
|
||||
reason: meta.reason ?? 'revoked via PermissionService',
|
||||
extra: this.#auditActorContext(actor),
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
'[PermissionService] failed to audit user-group revoke:',
|
||||
err,
|
||||
);
|
||||
});
|
||||
|
||||
// Bumped even when nothing matched: a cached allow must not survive.
|
||||
await this.#bumpGroupCacheGeneration(groupId);
|
||||
|
||||
// Nothing but the membership names the holders, so without this their
|
||||
// watches outlive the revoke.
|
||||
if (revoked) {
|
||||
for (const memberId of await this.stores.permission.listGroupMemberIds(
|
||||
groupId,
|
||||
)) {
|
||||
this.#announceRevoked(memberId, null, permission);
|
||||
}
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the grant `actor` issued, or the one named by `opts.issuerUserId`
|
||||
* when the caller has established authority over another issuer's grant (a
|
||||
|
||||
@@ -959,10 +959,13 @@ export class PermissionStore extends PuterStore {
|
||||
if (permissions.length === 0) return [];
|
||||
let permClause = permissions.map(() => 'p.permission = ?').join(' OR ');
|
||||
if (permissions.length > 1) permClause = `(${permClause})`;
|
||||
// Deletion leaves memberships and grants, so a deleted team would
|
||||
// otherwise keep resolving access nothing can withdraw.
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' +
|
||||
'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' +
|
||||
`WHERE ug.user_id = ? AND ${permClause}`,
|
||||
'JOIN `group` g ON g.`id` = ug.group_id ' +
|
||||
`WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`,
|
||||
[userId, ...permissions],
|
||||
);
|
||||
return rows.map((row) =>
|
||||
@@ -970,6 +973,99 @@ export class PermissionStore extends PuterStore {
|
||||
);
|
||||
}
|
||||
|
||||
/** Any group, seeded or team: a grant does not care which kind it is. */
|
||||
async resolveGroupId(groupUid: string): Promise<number | null> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `id` FROM `group` WHERE `uid` = ? LIMIT 1',
|
||||
[groupUid],
|
||||
)) as { id: number }[];
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Whose cached readings a grant to this group invalidates. */
|
||||
async listGroupMemberUuids(groupId: number): Promise<string[]> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT u.`uuid` FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'WHERE ug.`group_id` = ?',
|
||||
[groupId],
|
||||
)) as { uuid: string | null }[];
|
||||
return rows
|
||||
.map((r) => r.uuid)
|
||||
.filter((uuid): uuid is string => Boolean(uuid));
|
||||
}
|
||||
|
||||
/** Whose standing access a grant to this group settles. */
|
||||
async listGroupMemberIds(groupId: number): Promise<number[]> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `user_id` FROM `jct_user_group` WHERE `group_id` = ?',
|
||||
[groupId],
|
||||
)) as { user_id: number }[];
|
||||
return rows.map((r) => Number(r.user_id));
|
||||
}
|
||||
|
||||
/** `user_id` is the issuer; `group_id` is who receives it. */
|
||||
async upsertUserGroupPerm(
|
||||
groupId: number,
|
||||
issuerUserId: number,
|
||||
permission: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const upsertClause = this.clients.db.upsertClause(
|
||||
['user_id', 'group_id', 'permission'],
|
||||
['extra'],
|
||||
);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `user_to_group_permissions` (`user_id`, `group_id`, `permission`, `extra`) ' +
|
||||
`VALUES (?, ?, ?, ?) ${upsertClause}`,
|
||||
[
|
||||
issuerUserId,
|
||||
groupId,
|
||||
permission,
|
||||
JSON.stringify(extra),
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Scoped to the issuer: one issuer's revoke must not drop another's. */
|
||||
async deleteUserGroupPerm(
|
||||
groupId: number,
|
||||
issuerUserId: number,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.clients.db.write(
|
||||
'DELETE FROM `user_to_group_permissions` ' +
|
||||
'WHERE `group_id` = ? AND `user_id` = ? AND `permission` = ?',
|
||||
[groupId, issuerUserId, permission],
|
||||
);
|
||||
return result.anyRowsAffected;
|
||||
}
|
||||
|
||||
async auditUserGroupPerm(
|
||||
entry: AuditEntry & {
|
||||
group_id: number;
|
||||
issuer_user_id: number;
|
||||
permission: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `audit_user_to_group_permissions` (' +
|
||||
'`user_id`, `user_id_keep`, `group_id`, `group_id_keep`, ' +
|
||||
'`permission`, `extra`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
entry.issuer_user_id,
|
||||
entry.issuer_user_id,
|
||||
entry.group_id,
|
||||
entry.group_id,
|
||||
entry.permission,
|
||||
entry.extra ? JSON.stringify(entry.extra) : null,
|
||||
entry.action,
|
||||
entry.reason,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -- SQL: access token permissions -------------------------------
|
||||
|
||||
async hasAccessTokenPerm(
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Two teams side by side, plus a user in neither: an unscoped query still
|
||||
* returns the right rows when only one exists, so one gives false confidence.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IConfig } from '../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../testUtil.js';
|
||||
|
||||
/** An account that can call the API: verified, with a session token. */
|
||||
export type FixtureUser = {
|
||||
userId: number;
|
||||
username: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type FixtureTeam = {
|
||||
uid: string;
|
||||
handle: string;
|
||||
name: string;
|
||||
/** Owns the team and pays for it; `org_owned = 0`. */
|
||||
owner: FixtureUser;
|
||||
/** Provisioned seats, activated so they can make requests. */
|
||||
seats: FixtureUser[];
|
||||
};
|
||||
|
||||
export type TwoTeams = {
|
||||
env: PuterTestEnv;
|
||||
a: FixtureTeam;
|
||||
b: FixtureTeam;
|
||||
/** Signed in, in no team at all. */
|
||||
outsider: FixtureUser;
|
||||
call: (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) => Promise<Response>;
|
||||
shutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const rand = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** Seats per team. Two is enough to tell "this one" from "all of them". */
|
||||
const SEATS_PER_TEAM = 2;
|
||||
|
||||
/** Own owner per team, so this runs against the real cap. */
|
||||
export const setupTwoTeams = async (
|
||||
configOverrides: Partial<IConfig> = {},
|
||||
): Promise<TwoTeams> => {
|
||||
const env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
...configOverrides,
|
||||
} as IConfig);
|
||||
|
||||
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 tokenFor = async (userId: number): Promise<string> => {
|
||||
const row = await env.server.stores.user.getById(userId);
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
row!,
|
||||
);
|
||||
return token;
|
||||
};
|
||||
|
||||
const makeUser = async (): Promise<FixtureUser> => {
|
||||
const username = `fx_${rand()}`;
|
||||
const created = (await env.server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
// `requireVerified` rejects an unconfirmed account outright.
|
||||
email_confirmed: true,
|
||||
})) as unknown as { id: number };
|
||||
return {
|
||||
userId: created.id,
|
||||
username,
|
||||
token: await tokenFor(created.id),
|
||||
};
|
||||
};
|
||||
|
||||
/** Provisioning leaves the seat unconfirmed and mid-password-change. */
|
||||
const activate = async (username: string): Promise<FixtureUser> => {
|
||||
const seat = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(seat!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
return {
|
||||
userId: seat!.id,
|
||||
username,
|
||||
token: await tokenFor(seat!.id),
|
||||
};
|
||||
};
|
||||
|
||||
const expectOk = async (res: Response, what: string) => {
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`fixture: ${what} failed with ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const makeTeam = async (name: string): Promise<FixtureTeam> => {
|
||||
const owner = await makeUser();
|
||||
const handle = `ws-${rand()}`;
|
||||
|
||||
const res = await expectOk(
|
||||
await call('POST', '/teams', owner.token, { name, handle }),
|
||||
`creating ${name}`,
|
||||
);
|
||||
const team = (await res.json()) as { uid: string };
|
||||
|
||||
const seats: FixtureUser[] = [];
|
||||
for (let i = 0; i < SEATS_PER_TEAM; i++) {
|
||||
const username = `st_${rand()}`;
|
||||
await expectOk(
|
||||
await call('POST', `/teams/${team.uid}/members`, owner.token, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
}),
|
||||
`provisioning into ${name}`,
|
||||
);
|
||||
seats.push(await activate(username));
|
||||
}
|
||||
|
||||
return { uid: team.uid, handle, name, owner, seats };
|
||||
};
|
||||
|
||||
const a = await makeTeam('Team A');
|
||||
const b = await makeTeam('Team B');
|
||||
const outsider = await makeUser();
|
||||
|
||||
return { env, a, b, outsider, call, shutdown: () => env.shutdown() };
|
||||
};
|
||||
Reference in New Issue
Block a user