mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-14 18:01:21 +00:00
Durable subscribe, list, fetch and unsubscribe now apply the same scoped token check as the handler, worker and kv-handle routes: `isAccessTokenActor(actor) && !isAccountContext(actor)`. An access token an app issued (effectiveApp = the app) is treated the same as one the account issued: subscribe answers 403 `events_durable_requires_account`, list and fetch return an empty page, unsubscribe answers 404. `rowInActorScope` returns false for these tokens, which covers durable unsubscribe and ack. The session verbs that also use it already refuse access tokens at the socket handshake. Full-access tokens, app tokens and sessions are unchanged.
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
/*
|
|
* 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/>.
|
|
*/
|
|
|
|
/**
|
|
* What each credential shape may see and remove, and whether it can hold a
|
|
* connection at all.
|
|
*
|
|
* Every actor here is minted and then authenticated for real, because the whole
|
|
* question is what `effectiveApp` resolves to at the end of a token chain: an
|
|
* app sees only what it created, a credential acting for the account sees
|
|
* across apps, and an id belonging to another app is answered as absent rather
|
|
* than refused.
|
|
*/
|
|
|
|
import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client';
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
|
|
import type { Actor } from '../../core/actor.js';
|
|
import { isHttpError } from '../../core/http/HttpError.js';
|
|
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
|
import type { IConfig } from '../../types.js';
|
|
import type { AuthResult } from '../auth/AuthService.js';
|
|
import { decideSocketAuth } from '../socket/SocketService.js';
|
|
import type { DeliveryEnvelope } from './EventsService.js';
|
|
|
|
const BOOT_TIMEOUT_MS = 120_000;
|
|
const SOCKET_ID = 'matrix-socket';
|
|
|
|
let env: PuterTestEnv;
|
|
let username: string;
|
|
let userId: number;
|
|
let anchor: string;
|
|
|
|
/** One actor per row of the scoping matrix, each from a real credential. */
|
|
let session: Actor;
|
|
let appOne: Actor;
|
|
let appTwo: Actor;
|
|
let appAccessToken: Actor;
|
|
let personalAccessToken: Actor;
|
|
let worker: Actor;
|
|
|
|
/** Raw token strings, for the shapes a live handshake is attempted with. */
|
|
let appAccessTokenStr: string;
|
|
|
|
let appOneUid: string;
|
|
let appTwoUid: string;
|
|
|
|
const events = () => env.server.services.events;
|
|
const auth = () => env.server.services.auth;
|
|
|
|
const actorFor = async (token: string): Promise<Actor> => {
|
|
const result = await auth().authenticate(token);
|
|
expect(result.actor, 'credential did not authenticate').toBeDefined();
|
|
return result.actor!;
|
|
};
|
|
|
|
/** An app the user has granted `list` on the shared anchor. */
|
|
const makeAppActor = async (): Promise<{ uid: string; actor: Actor }> => {
|
|
const uid = `app-${uuidv4()}`;
|
|
await env.server.clients.db.write(
|
|
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
|
|
[uid, uid, uid, `https://${uid}.example/`, userId],
|
|
);
|
|
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
|
await env.server.services.permission.grantUserAppPermission(
|
|
session,
|
|
uid,
|
|
`fs:${entry!.uid}:list`,
|
|
);
|
|
const token = await auth().getUserAppToken(session, uid);
|
|
return { uid, actor: await actorFor(token) };
|
|
};
|
|
|
|
const subscribeAs = async (actor: Actor) =>
|
|
(await events().subscribe(actor, SOCKET_ID, { subject: `fs:${anchor}` }))
|
|
.sub;
|
|
|
|
/** A write by the user, through the route a client actually calls. */
|
|
const writeAsUser = async (path: string): Promise<void> => {
|
|
const response = await fetch(new URL('/mkdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${env.users.user.token}`,
|
|
},
|
|
body: JSON.stringify({ path, create_missing_parents: true }),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
};
|
|
|
|
const heldBy = async (actor: Actor): Promise<string[]> =>
|
|
(await events().listSubscriptions(actor, SOCKET_ID)).map(
|
|
(sub) => sub.subId,
|
|
);
|
|
|
|
const absent = (err: unknown) =>
|
|
isHttpError(err) &&
|
|
err.statusCode === 404 &&
|
|
err.legacyCode === 'subscription_does_not_exist';
|
|
|
|
/**
|
|
* Attempt a live handshake; resolves with the rejection message, or throws if
|
|
* it connects.
|
|
*/
|
|
const attemptConnect = (token: string): Promise<string> =>
|
|
new Promise((resolve, reject) => {
|
|
const socket: ClientSocket = ioClient(env.origin, {
|
|
auth: { auth_token: token },
|
|
transports: ['websocket'],
|
|
reconnection: false,
|
|
});
|
|
socket.on('connect', () => {
|
|
socket.disconnect();
|
|
reject(
|
|
new Error('socket connected; expected a handshake rejection'),
|
|
);
|
|
});
|
|
socket.on('connect_error', (err: Error) => {
|
|
socket.disconnect();
|
|
resolve(err.message);
|
|
});
|
|
});
|
|
|
|
/** Resolves true once a live handshake is admitted. */
|
|
const connects = (token: string): Promise<boolean> =>
|
|
new Promise((resolve, reject) => {
|
|
const socket: ClientSocket = ioClient(env.origin, {
|
|
auth: { auth_token: token },
|
|
transports: ['websocket'],
|
|
reconnection: false,
|
|
});
|
|
socket.on('connect', () => {
|
|
socket.disconnect();
|
|
resolve(true);
|
|
});
|
|
socket.on('connect_error', (err: Error) => {
|
|
socket.disconnect();
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv({ events: { enabled: true } } as IConfig);
|
|
username = env.users.user.username;
|
|
const row = await env.server.stores.user.getByUsername(username);
|
|
userId = row!.id;
|
|
|
|
anchor = `/${username}/matrix`;
|
|
await env.server.services.fs.mkdir(userId, {
|
|
path: anchor,
|
|
createMissingParents: true,
|
|
});
|
|
|
|
session = await actorFor(env.users.user.token);
|
|
({ uid: appOneUid, actor: appOne } = await makeAppActor());
|
|
({ uid: appTwoUid, actor: appTwo } = await makeAppActor());
|
|
|
|
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
|
appAccessTokenStr = await auth().createAccessToken(
|
|
appOne,
|
|
[[`fs:${entry!.uid}:list`]],
|
|
{ label: 'matrix' },
|
|
);
|
|
appAccessToken = await actorFor(appAccessTokenStr);
|
|
personalAccessToken = await actorFor(env.users.user.apiToken);
|
|
worker = await actorFor(env.users.user.workerToken);
|
|
}, BOOT_TIMEOUT_MS);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
describe('which credentials reach the session verbs at all', () => {
|
|
const accepted = (actor: Actor) => {
|
|
const decision = decideSocketAuth({ actor } as AuthResult, {
|
|
allowAppActors: true,
|
|
});
|
|
return 'accept' in decision;
|
|
};
|
|
|
|
it('admits a session, an app, a worker and the account`s own token', () => {
|
|
expect(accepted(session)).toBe(true);
|
|
expect(accepted(appOne)).toBe(true);
|
|
// A worker session is user-shaped — no app, no access token — so it
|
|
// connects like one and is scoped by what it was minted with.
|
|
expect(worker.session?.kind).toBe('worker');
|
|
expect(accepted(worker)).toBe(true);
|
|
// No app, so it is scoped exactly as the session is.
|
|
expect(personalAccessToken.effectiveApp).toBeNull();
|
|
expect(accepted(personalAccessToken)).toBe(true);
|
|
|
|
// A scoped token is neither, so it never holds a socket at all.
|
|
expect(accepted(appAccessToken)).toBe(false);
|
|
});
|
|
|
|
it('sorts the two token shapes at a live handshake, even with events on', async () => {
|
|
// `accepted` above is the pure decision function; this drives an
|
|
// actual connection against a server booted with events enabled, so
|
|
// the wiring — not just the predicate — is what is on the hook.
|
|
await expect(attemptConnect(appAccessTokenStr)).resolves.toMatch(
|
|
/only user tokens/,
|
|
);
|
|
await expect(connects(env.users.user.apiToken)).resolves.toBe(true);
|
|
});
|
|
|
|
it('refuses an app while events are off, whatever the token says', () => {
|
|
expect(
|
|
'reject' in decideSocketAuth({ actor: appOne } as AuthResult),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('what an actor sees and removes', () => {
|
|
it('stamps the creating app on the row, and nothing on a session`s', async () => {
|
|
const own = await subscribeAs(session);
|
|
const theirs = await subscribeAs(appOne);
|
|
|
|
const rows = await env.server.stores.eventSubscription.listForSocket(
|
|
userId,
|
|
SOCKET_ID,
|
|
);
|
|
const byId = new Map(rows.map((row) => [row.subId, row]));
|
|
expect(byId.get(own.subId)?.appUid).toBeNull();
|
|
expect(byId.get(theirs.subId)?.appUid).toBe(appOneUid);
|
|
|
|
// An access token an app issued resolves to that app, one hop through
|
|
// its issuer — and is still not the app when scoping rows.
|
|
expect(appAccessToken.effectiveApp?.uid).toBe(appOneUid);
|
|
expect(personalAccessToken.effectiveApp).toBeNull();
|
|
expect(worker.effectiveApp).toBeNull();
|
|
|
|
await events().unsubscribe(session, SOCKET_ID, { subId: own.subId });
|
|
await events().unsubscribe(session, SOCKET_ID, { subId: theirs.subId });
|
|
});
|
|
|
|
it('shows an app its own rows and no others', async () => {
|
|
const mine = await subscribeAs(appOne);
|
|
const theirs = await subscribeAs(appTwo);
|
|
const account = await subscribeAs(session);
|
|
|
|
await expect(heldBy(appOne)).resolves.toEqual([mine.subId]);
|
|
await expect(heldBy(appTwo)).resolves.toEqual([theirs.subId]);
|
|
// A scoped token the app issued sees none of the app's rows.
|
|
await expect(heldBy(appAccessToken)).resolves.toEqual([]);
|
|
|
|
for (const wide of [session, personalAccessToken, worker])
|
|
expect((await heldBy(wide)).sort()).toEqual(
|
|
[mine.subId, theirs.subId, account.subId].sort(),
|
|
);
|
|
|
|
await events().unsubscribe(session, SOCKET_ID, { subId: mine.subId });
|
|
await events().unsubscribe(session, SOCKET_ID, { subId: theirs.subId });
|
|
await events().unsubscribe(session, SOCKET_ID, {
|
|
subId: account.subId,
|
|
});
|
|
});
|
|
|
|
it('answers another app`s subscription id as absent', async () => {
|
|
const mine = await subscribeAs(appOne);
|
|
|
|
await expect(
|
|
events().unsubscribe(appTwo, SOCKET_ID, { subId: mine.subId }),
|
|
).rejects.toSatisfy(absent);
|
|
// Still there: the refusal removed nothing.
|
|
await expect(heldBy(appOne)).resolves.toEqual([mine.subId]);
|
|
|
|
await events().unsubscribe(appOne, SOCKET_ID, { subId: mine.subId });
|
|
await expect(heldBy(appOne)).resolves.toEqual([]);
|
|
});
|
|
|
|
it('answers a session`s subscription id as absent to an app', async () => {
|
|
const account = await subscribeAs(session);
|
|
|
|
await expect(
|
|
events().unsubscribe(appOne, SOCKET_ID, { subId: account.subId }),
|
|
).rejects.toSatisfy(absent);
|
|
|
|
await events().unsubscribe(session, SOCKET_ID, {
|
|
subId: account.subId,
|
|
});
|
|
});
|
|
|
|
it('lets the account remove what an app left behind', async () => {
|
|
const theirs = await subscribeAs(appTwo);
|
|
expect(appTwoUid).not.toBe(appOneUid);
|
|
|
|
await events().unsubscribe(session, SOCKET_ID, { subId: theirs.subId });
|
|
|
|
await expect(heldBy(session)).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('an app`s row is delivered as the app', () => {
|
|
it('follows the grant the app holds, and stops when it is taken back', async () => {
|
|
const delivered: DeliveryEnvelope[] = [];
|
|
events().onDelivered = (envelope) => delivered.push(envelope);
|
|
const sub = await subscribeAs(appOne);
|
|
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
|
|
|
await writeAsUser(`${anchor}/granted-${uuidv4().slice(0, 8)}`);
|
|
await vi.waitFor(() => expect(delivered).toHaveLength(1), {
|
|
timeout: EVENTS_COALESCE_WINDOW_MS * 12,
|
|
interval: 25,
|
|
});
|
|
expect(delivered[0].subId).toBe(sub.subId);
|
|
|
|
// The user takes the app's access away; the row outlives the grant,
|
|
// and the re-check is what makes it stop.
|
|
await env.server.services.permission.revokeUserAppPermission(
|
|
session,
|
|
appOneUid,
|
|
`fs:${entry!.uid}:list`,
|
|
);
|
|
delivered.length = 0;
|
|
|
|
await writeAsUser(`${anchor}/revoked-${uuidv4().slice(0, 8)}`);
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, EVENTS_COALESCE_WINDOW_MS * 3),
|
|
);
|
|
expect(delivered).toEqual([]);
|
|
});
|
|
});
|