mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-13 00:36:02 +00:00
feat: subscribe ACL and app scoping for event subscriptions (PUT-1672) (#3677)
This commit is contained in:
@@ -140,6 +140,38 @@ const actorFor = (id = userId): Actor =>
|
||||
effectiveApp: null,
|
||||
}) as unknown as Actor;
|
||||
|
||||
/**
|
||||
* Paths the ACL says no to, and how loudly. The real check is exercised against
|
||||
* real grants in the integration suite; here access is data, so a test can say
|
||||
* "this went away" without staging a share.
|
||||
*/
|
||||
let denied: Map<string, 'hidden' | 'forbidden'>;
|
||||
|
||||
const aclService = () => ({
|
||||
check: async (_actor: Actor, resource: { path: string }) =>
|
||||
!denied.has(resource.path),
|
||||
getSafeAclError: async (_actor: Actor, resource: { path: string }) =>
|
||||
denied.get(resource.path) === 'forbidden'
|
||||
? { status: 403, message: 'Forbidden', fields: { code: 'forbidden' } }
|
||||
: {
|
||||
status: 404,
|
||||
message: 'Subject does not exist',
|
||||
fields: { code: 'subject_does_not_exist' },
|
||||
},
|
||||
});
|
||||
|
||||
const userStore = {
|
||||
getById: async (id: number) => ({
|
||||
id,
|
||||
uuid: `user-${id}`,
|
||||
username: `u${id}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const appStore = {
|
||||
getByUid: async (uid: string) => ({ uid, id: 1 }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Each service gets its own outbox. A delivery still in flight when a test
|
||||
* ends must land in that test's record, not in the next one's.
|
||||
@@ -161,7 +193,12 @@ const buildService = (
|
||||
redis,
|
||||
event: bus,
|
||||
} as never,
|
||||
{ eventSubscription: store, fsEntry: fsEntryStore } as never,
|
||||
{
|
||||
eventSubscription: store,
|
||||
fsEntry: fsEntryStore,
|
||||
user: userStore,
|
||||
app: appStore,
|
||||
} as never,
|
||||
{
|
||||
socket: {
|
||||
send: vi.fn(async (spec: { socket?: string }, _key, data) => {
|
||||
@@ -176,6 +213,7 @@ const buildService = (
|
||||
ancestorChain(path),
|
||||
),
|
||||
},
|
||||
acl: aclService(),
|
||||
} as never,
|
||||
);
|
||||
built.onDelivered = (envelope) => counted.push(envelope);
|
||||
@@ -233,16 +271,23 @@ const subscribe = async (subject: string, socket = socketId) =>
|
||||
*/
|
||||
const seedSubscriptions = async (
|
||||
count: number,
|
||||
row: { token: string; anchorUid: string; anchorPath: string; match: string | null },
|
||||
row: {
|
||||
token: string;
|
||||
anchorUid: string;
|
||||
anchorPath: string;
|
||||
match: string | null;
|
||||
},
|
||||
): Promise<void> => {
|
||||
for (let i = 0; i < count; i++)
|
||||
await store.add({
|
||||
subId: `seed-${seq}-${i}`,
|
||||
socketId: `socket-${seq}-${i}`,
|
||||
userId,
|
||||
holderUserId: userId,
|
||||
ownerUserId: userId,
|
||||
subject: 'fs:seeded',
|
||||
op: null,
|
||||
appUid: null,
|
||||
permission: 'list',
|
||||
...row,
|
||||
});
|
||||
};
|
||||
@@ -251,7 +296,7 @@ const seedSubscriptions = async (
|
||||
const dispatch = async (node: FSEntry, key = 'fs.write.file' as const) =>
|
||||
service.dispatchFs(key, node, {
|
||||
actingUserId: userId,
|
||||
ancestorUids: async () => ancestorChain(node.path).map((a) => a.uid),
|
||||
ancestors: async () => ancestorChain(node.path),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -260,6 +305,7 @@ beforeEach(() => {
|
||||
socketId = `socket-${seq}`;
|
||||
commands = [];
|
||||
entries = new Map();
|
||||
denied = new Map();
|
||||
redis = countingRedis(new MockRedis.Cluster(['redis://localhost:7001']));
|
||||
store = new EventSubscriptionStore(
|
||||
{} as IConfig,
|
||||
@@ -302,7 +348,7 @@ describe('the feature switch', () => {
|
||||
const { file } = seedTree();
|
||||
|
||||
await off.dispatchFs('fs.write.file', file, {
|
||||
ancestorUids: async () => {
|
||||
ancestors: async () => {
|
||||
throw new Error('the tree must not be walked');
|
||||
},
|
||||
});
|
||||
@@ -335,13 +381,11 @@ describe('subscribing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('answers a node someone else owns as absent, not as refused', async () => {
|
||||
it('answers a node the caller cannot see as absent, not as refused', async () => {
|
||||
const { documents } = seedTree();
|
||||
register({ ...documents, userId: userId + 500 } as FSEntry);
|
||||
denied.set(documents.path, 'hidden');
|
||||
|
||||
await expect(
|
||||
subscribe(`fs:${documents.uid}`),
|
||||
).rejects.toSatisfy(
|
||||
await expect(subscribe(`fs:${documents.uid}`)).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
isHttpError(err) &&
|
||||
err.statusCode === 404 &&
|
||||
@@ -349,6 +393,29 @@ describe('subscribing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a node the caller can see but not list', async () => {
|
||||
const { documents } = seedTree();
|
||||
denied.set(documents.path, 'forbidden');
|
||||
|
||||
await expect(subscribe(`fs:${documents.uid}`)).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
isHttpError(err) &&
|
||||
err.statusCode === 403 &&
|
||||
err.legacyCode === 'forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores the anchor`s owner, not the subscriber, as the keyspace', async () => {
|
||||
const { documents } = seedTree();
|
||||
const owner = userId + 500;
|
||||
register({ ...documents, userId: owner } as FSEntry);
|
||||
|
||||
await subscribe(`fs:${documents.uid}`);
|
||||
|
||||
await expect(store.userHasAny(owner)).resolves.toBe(true);
|
||||
await expect(store.userHasAny(userId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('files the missing remainder as the filter', async () => {
|
||||
const { documents } = seedTree();
|
||||
|
||||
@@ -431,13 +498,13 @@ describe('what a dispatch costs', () => {
|
||||
|
||||
it('walks the tree only for a user who has subscriptions', async () => {
|
||||
const { file } = seedTree();
|
||||
const walk = vi.fn(async () => ['docs']);
|
||||
const walk = vi.fn(async () => [{ uid: 'docs', path: '/docs' }]);
|
||||
|
||||
await service.dispatchFs('fs.write.file', file, {
|
||||
ancestorUids: walk,
|
||||
ancestors: walk,
|
||||
});
|
||||
await service.dispatchFs('fs.write.file', file, {
|
||||
ancestorUids: walk,
|
||||
ancestors: walk,
|
||||
});
|
||||
|
||||
expect(walk).not.toHaveBeenCalled();
|
||||
@@ -468,7 +535,8 @@ describe('cross-process invalidation', () => {
|
||||
await store.add({
|
||||
subId: 'remote-sub',
|
||||
socketId: 'remote-socket',
|
||||
userId,
|
||||
holderUserId: userId,
|
||||
ownerUserId: userId,
|
||||
subject: `fs:${documents.uid}`,
|
||||
token: `f#${documents.uid}`,
|
||||
anchorUid: documents.uid,
|
||||
@@ -476,6 +544,7 @@ describe('cross-process invalidation', () => {
|
||||
match: null,
|
||||
op: null,
|
||||
appUid: null,
|
||||
permission: 'list',
|
||||
});
|
||||
const generation = await store.getGeneration(userId);
|
||||
|
||||
@@ -587,42 +656,63 @@ describe('matching', () => {
|
||||
|
||||
await service.dispatchFs('fs.write.file', file, {
|
||||
actingUserId: userId + 900,
|
||||
ancestorUids: async () => [documents.uid],
|
||||
ancestors: async () => [
|
||||
{ uid: documents.uid, path: documents.path },
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(sent[0].envelope.event).toMatchObject({ self: false });
|
||||
});
|
||||
|
||||
it('will not deliver a row whose holder does not own the node', async () => {
|
||||
it('stops delivering the moment the holder`s access goes', async () => {
|
||||
const { documents, file } = seedTree();
|
||||
const sub = await subscribe(`fs:${documents.uid}`);
|
||||
// Holder and owner cannot disagree through the API yet, so the stored
|
||||
// row is where they are made to — which is what the delivery-side
|
||||
// check is for once a shared anchor can produce that pair.
|
||||
const rowKey = `ev:t:{${userId}}:f#${documents.uid}`;
|
||||
const stored = JSON.parse(
|
||||
(await redis.hget(rowKey, sub.subId)) as string,
|
||||
) as Record<string, unknown>;
|
||||
await redis.hset(
|
||||
rowKey,
|
||||
sub.subId,
|
||||
JSON.stringify({ ...stored, userId: userId + 700 }),
|
||||
);
|
||||
await subscribe(`fs:${documents.uid}`);
|
||||
|
||||
// The row is still there and still matches; only the answer to "may
|
||||
// this holder list the node" changed.
|
||||
denied.set(file.path, 'hidden');
|
||||
await dispatch(file);
|
||||
await flush();
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-checks the node the event is about, not the anchor', async () => {
|
||||
const { documents } = seedTree();
|
||||
await subscribe(`fs:/u${userId}/Documents/**`);
|
||||
const reachable = register(
|
||||
entry({
|
||||
uid: `open-${seq}`,
|
||||
path: `${documents.path}/open/notes.txt`,
|
||||
}),
|
||||
);
|
||||
const closed = register(
|
||||
entry({
|
||||
uid: `closed-${seq}`,
|
||||
path: `${documents.path}/closed/secret.txt`,
|
||||
}),
|
||||
);
|
||||
denied.set(closed.path, 'hidden');
|
||||
|
||||
await dispatch(reachable);
|
||||
await dispatch(closed);
|
||||
await flush();
|
||||
|
||||
expect(sent.map((s) => (s.envelope.event as { uid: string }).uid)).toEqual([
|
||||
reachable.uid,
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes nothing for an event with no registry entry', async () => {
|
||||
const { documents, file } = seedTree();
|
||||
await subscribe(`fs:${documents.uid}`);
|
||||
commands = [];
|
||||
|
||||
await service.dispatchFs('fs.copy.node', file, {
|
||||
ancestorUids: async () => [documents.uid],
|
||||
ancestors: async () => [
|
||||
{ uid: documents.uid, path: documents.path },
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
|
||||
|
||||
@@ -30,15 +30,20 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
type GenerationBump,
|
||||
type SessionSubscription,
|
||||
} from '../../stores/events/EventSubscriptionStore.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { ResourceDescriptor } from '../acl/ACLService.js';
|
||||
import { resolveNode } from '../fs/resolveNode.js';
|
||||
import { PuterService } from '../types.js';
|
||||
import { resolveFsAnchor, type FsAnchorDeps } from './anchors.js';
|
||||
import {
|
||||
assertSubscribeAuthorized,
|
||||
checkSubscribeAuthorization,
|
||||
checkDeliveryAuthorized,
|
||||
nodeDescriptor,
|
||||
rowInActorScope,
|
||||
type EventAclDeps,
|
||||
} from './authorization.js';
|
||||
import { DeliveryCoalescer } from './coalescer.js';
|
||||
import {
|
||||
@@ -70,7 +75,11 @@ import { parseSubject, type FsOp } from './subjects.js';
|
||||
* rather than a timer, so it costs nothing once warm.
|
||||
* 3. One Redis command against the user's watched tokens.
|
||||
*
|
||||
* Only past all three does anything read a subscription or walk the tree.
|
||||
* Only past all three does anything read a subscription or walk the tree, and
|
||||
* only a row that survived op and filter matching pays for the ACL re-check
|
||||
* that decides whether its holder may still be told. Everything is keyed by the
|
||||
* owner of the resource, because that is all a write knows about itself.
|
||||
*
|
||||
* Nothing here reaches the caller: an event that could not be dispatched is an
|
||||
* event nobody hears, and the write that produced it still succeeded.
|
||||
*/
|
||||
@@ -134,8 +143,10 @@ export interface FsDispatchOptions {
|
||||
/**
|
||||
* Resolved lazily and only past the second gate — walking the tree for a
|
||||
* user nobody subscribed on behalf of is the cost this exists to avoid.
|
||||
* Paths as well as uids: the same walk answers the token lookup and the
|
||||
* per-delivery ACL re-check.
|
||||
*/
|
||||
ancestorUids?: () => Promise<readonly string[]>;
|
||||
ancestors?: () => Promise<ReadonlyArray<{ uid: string; path: string }>>;
|
||||
/** Who performed the write, for the `self` flag. */
|
||||
actingUserId?: number;
|
||||
}
|
||||
@@ -295,10 +306,10 @@ export class EventsService extends PuterService {
|
||||
request: SubscribeRequest,
|
||||
): Promise<{ sub: SubscriptionView }> {
|
||||
if (!this.enabled) throw disabled();
|
||||
const userId = actor.user?.id;
|
||||
if (userId === undefined) throw disabled();
|
||||
const holderUserId = actor.user?.id;
|
||||
if (holderUserId === undefined) throw disabled();
|
||||
|
||||
await this.#spendCallBudget(userId);
|
||||
await this.#spendCallBudget(holderUserId);
|
||||
|
||||
const rawSubject = String(request?.subject ?? '');
|
||||
const parsed = parseSubject(rawSubject);
|
||||
@@ -314,7 +325,9 @@ export class EventsService extends PuterService {
|
||||
});
|
||||
|
||||
// The resolver answers where a subscription keys, not whose it is, so
|
||||
// the owner comes from the anchor node itself.
|
||||
// the owner comes from the anchor node itself — and that is the
|
||||
// keyspace the row is indexed in, because dispatch only ever knows
|
||||
// whose resource changed.
|
||||
const entry = await resolveNode(this.stores.fsEntry, {
|
||||
uid: anchor.uid,
|
||||
});
|
||||
@@ -322,10 +335,11 @@ export class EventsService extends PuterService {
|
||||
throw new HttpError(404, `No such entry: ${anchor.path}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
assertSubscribeAuthorized(
|
||||
{ userId },
|
||||
{ ownerUserId: entry.userId },
|
||||
const permission = await assertSubscribeAuthorized(
|
||||
actor,
|
||||
{ uid: anchor.uid, path: anchor.path },
|
||||
rawSubject,
|
||||
this.#aclDeps(),
|
||||
);
|
||||
|
||||
// Compile now so an unusable pattern fails this call rather than every
|
||||
@@ -335,7 +349,8 @@ export class EventsService extends PuterService {
|
||||
const sub: SessionSubscription = {
|
||||
subId: randomUUID(),
|
||||
socketId,
|
||||
userId,
|
||||
holderUserId,
|
||||
ownerUserId: entry.userId,
|
||||
subject: rawSubject,
|
||||
token: anchor.token,
|
||||
anchorUid: anchor.uid,
|
||||
@@ -343,11 +358,12 @@ export class EventsService extends PuterService {
|
||||
match: anchor.match,
|
||||
op: anchor.op,
|
||||
appUid: actor.effectiveApp?.uid ?? null,
|
||||
permission,
|
||||
};
|
||||
|
||||
const generation = await this.stores.eventSubscription.add(sub);
|
||||
this.#publishGeneration(userId, generation);
|
||||
this.#startRefresh(userId, socketId);
|
||||
const bump = await this.stores.eventSubscription.add(sub);
|
||||
this.#publishGeneration(bump);
|
||||
this.#startRefresh(holderUserId, socketId);
|
||||
|
||||
return { sub: toView(sub) };
|
||||
}
|
||||
@@ -358,46 +374,62 @@ export class EventsService extends PuterService {
|
||||
request: UnsubscribeRequest,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) throw disabled();
|
||||
const userId = actor.user?.id;
|
||||
if (userId === undefined) throw disabled();
|
||||
const holderUserId = actor.user?.id;
|
||||
if (holderUserId === undefined) throw disabled();
|
||||
|
||||
await this.#spendCallBudget(userId);
|
||||
await this.#spendCallBudget(holderUserId);
|
||||
|
||||
const subId = String(request?.subId ?? '');
|
||||
if (!subId) throw unknownSubscription();
|
||||
|
||||
const generation = await this.stores.eventSubscription.remove(
|
||||
userId,
|
||||
const sub = await this.stores.eventSubscription.getForSocket(
|
||||
holderUserId,
|
||||
socketId,
|
||||
subId,
|
||||
);
|
||||
// An id this socket never held reads as absent rather than refused —
|
||||
// otherwise unsubscribe reports whether someone else's id exists.
|
||||
if (generation === null) throw unknownSubscription();
|
||||
// An id this socket never held — or one another app created — reads as
|
||||
// absent rather than refused: a 403 here is an oracle for subIds.
|
||||
if (!sub || !rowInActorScope(actor, sub)) throw unknownSubscription();
|
||||
|
||||
const bump = await this.stores.eventSubscription.remove(sub);
|
||||
this.#forget(subId);
|
||||
this.#publishGeneration(userId, generation);
|
||||
this.#publishGeneration(bump);
|
||||
}
|
||||
|
||||
/** What this actor holds on one connection, scoped to what it may see. */
|
||||
async listSubscriptions(
|
||||
actor: Actor,
|
||||
socketId: string,
|
||||
): Promise<SubscriptionView[]> {
|
||||
if (!this.enabled) throw disabled();
|
||||
const holderUserId = actor.user?.id;
|
||||
if (holderUserId === undefined) throw disabled();
|
||||
|
||||
const held = await this.stores.eventSubscription.listForSocket(
|
||||
holderUserId,
|
||||
socketId,
|
||||
);
|
||||
return held.filter((sub) => rowInActorScope(actor, sub)).map(toView);
|
||||
}
|
||||
|
||||
/** Disconnect handler; also covers a socket the server dropped. */
|
||||
async reapSocket(userId: number, socketId: string): Promise<void> {
|
||||
async reapSocket(holderUserId: number, socketId: string): Promise<void> {
|
||||
// Nothing could have been registered, so nothing has to be looked up.
|
||||
// A switch flipped off under live subscriptions leaves them to the TTL.
|
||||
if (!this.enabled) return;
|
||||
this.#stopRefresh(userId, socketId);
|
||||
this.#stopRefresh(holderUserId, socketId);
|
||||
try {
|
||||
const held = await this.stores.eventSubscription.listForSocket(
|
||||
userId,
|
||||
holderUserId,
|
||||
socketId,
|
||||
);
|
||||
for (const sub of held) this.#forget(sub.subId);
|
||||
|
||||
const generation = await this.stores.eventSubscription.reapSocket(
|
||||
userId,
|
||||
const bumps = await this.stores.eventSubscription.reapSocket(
|
||||
holderUserId,
|
||||
socketId,
|
||||
);
|
||||
if (generation !== null)
|
||||
this.#publishGeneration(userId, generation);
|
||||
for (const bump of bumps) this.#publishGeneration(bump);
|
||||
} catch (err) {
|
||||
// The TTL backstop exists for exactly this.
|
||||
console.warn('[events] failed to reap socket subscriptions', err);
|
||||
@@ -420,43 +452,41 @@ export class EventsService extends PuterService {
|
||||
const subject = lookupPublicSubject(key);
|
||||
if (!subject) return;
|
||||
|
||||
const userId = entry?.userId;
|
||||
if (typeof userId !== 'number') return;
|
||||
const ownerUserId = entry?.userId;
|
||||
if (typeof ownerUserId !== 'number') return;
|
||||
|
||||
if (!(await this.#userHasAny(userId))) return;
|
||||
if (!(await this.#userHasAny(ownerUserId))) return;
|
||||
|
||||
const ancestorUids = options.ancestorUids
|
||||
? await options.ancestorUids()
|
||||
: [];
|
||||
const ancestors = options.ancestors ? await options.ancestors() : [];
|
||||
const context: EventContext = {
|
||||
key,
|
||||
entry,
|
||||
ancestorUids,
|
||||
ancestors,
|
||||
id: randomUUID(),
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
const watched = await this.stores.eventSubscription.watchedTokens(
|
||||
userId,
|
||||
ownerUserId,
|
||||
subject.tokens(context),
|
||||
);
|
||||
if (watched.length === 0) return;
|
||||
|
||||
const rows = await this.stores.eventSubscription.getForTokens(
|
||||
userId,
|
||||
ownerUserId,
|
||||
watched,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
this.#route(subject, context, rows, options.actingUserId);
|
||||
await this.#route(subject, context, rows, options.actingUserId);
|
||||
}
|
||||
|
||||
#route(
|
||||
async #route(
|
||||
subject: PublicSubject,
|
||||
context: EventContext,
|
||||
rows: SessionSubscription[],
|
||||
actingUserId: number | undefined,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// One throwaway projection reads the op off the registry entry rather
|
||||
// than re-deriving it from the subject string.
|
||||
const { op } = subject.project({ ...context, self: false, seq: 0 });
|
||||
@@ -464,20 +494,22 @@ export class EventsService extends PuterService {
|
||||
|
||||
const evaluated = evaluateWithCap(
|
||||
rows,
|
||||
(row) => this.#passes(row, op, matchOn, context),
|
||||
(row) => this.#passes(row, op, matchOn),
|
||||
FILTER_EVALUATIONS_PER_EVENT,
|
||||
);
|
||||
|
||||
const matched = evaluated.matched.slice(
|
||||
0,
|
||||
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
const matched = await this.#stillAuthorized(
|
||||
evaluated.matched.slice(0, EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT),
|
||||
context,
|
||||
);
|
||||
|
||||
let seq = 0;
|
||||
for (const row of matched) {
|
||||
const event = subject.project({
|
||||
...context,
|
||||
self: actingUserId === undefined || actingUserId === row.userId,
|
||||
self:
|
||||
actingUserId === undefined ||
|
||||
actingUserId === row.holderUserId,
|
||||
seq: seq++,
|
||||
});
|
||||
this.#coalesce().push(coalesceKey(row.subId, event.subject), {
|
||||
@@ -508,20 +540,8 @@ export class EventsService extends PuterService {
|
||||
}
|
||||
|
||||
/** Op filter first — a comparison, where the glob is not. */
|
||||
#passes(
|
||||
row: SessionSubscription,
|
||||
op: FsOp,
|
||||
matchOn: string,
|
||||
context: EventContext,
|
||||
): boolean {
|
||||
#passes(row: SessionSubscription, op: FsOp, matchOn: string): boolean {
|
||||
if (row.op !== null && row.op !== op) return false;
|
||||
if (
|
||||
!checkSubscribeAuthorization(
|
||||
{ userId: row.userId },
|
||||
{ ownerUserId: context.entry.userId },
|
||||
)
|
||||
)
|
||||
return false;
|
||||
if (!row.match) return true;
|
||||
|
||||
const relative = relativeTo(row.anchorPath, matchOn);
|
||||
@@ -529,6 +549,50 @@ export class EventsService extends PuterService {
|
||||
return this.#matcherFor(row).test(relative);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run each surviving row's access against the node the event is about,
|
||||
* not against its anchor: a filter that reaches into something the holder
|
||||
* cannot list must not deliver from it, and a share revoked after the
|
||||
* subscription was made must stop delivering at once rather than when the
|
||||
* row is next touched.
|
||||
*
|
||||
* Last of the filters, because it is the only one that can cost a lookup —
|
||||
* and rows that share an identity and a grant share one decision.
|
||||
*/
|
||||
async #stillAuthorized(
|
||||
rows: SessionSubscription[],
|
||||
context: EventContext,
|
||||
): Promise<SessionSubscription[]> {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
const node = this.#eventDescriptor(context);
|
||||
const decisions = new Map<string, Promise<boolean>>();
|
||||
const allowed = await Promise.all(
|
||||
rows.map((row) => {
|
||||
const key = `${row.holderUserId}|${row.appUid ?? ''}|${row.permission}`;
|
||||
let decision = decisions.get(key);
|
||||
if (!decision) {
|
||||
decision = checkDeliveryAuthorized(
|
||||
row,
|
||||
node,
|
||||
this.#aclDeps(),
|
||||
);
|
||||
decisions.set(key, decision);
|
||||
}
|
||||
return decision;
|
||||
}),
|
||||
);
|
||||
return rows.filter((_row, i) => allowed[i]);
|
||||
}
|
||||
|
||||
/** The event's node as ACL wants it, reusing the walk dispatch already did. */
|
||||
#eventDescriptor(context: EventContext): ResourceDescriptor {
|
||||
return nodeDescriptor(
|
||||
{ uid: context.entry.uid, path: context.entry.path },
|
||||
{ getAncestorChain: async () => context.ancestors },
|
||||
);
|
||||
}
|
||||
|
||||
#matcherFor(row: SessionSubscription): CompiledMatch {
|
||||
const cached = this.#compiled.get(row.subId);
|
||||
if (cached && cached.pattern === row.match) return cached;
|
||||
@@ -654,7 +718,7 @@ export class EventsService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
#publishGeneration(userId: number, generation: number): void {
|
||||
#publishGeneration({ userId, generation }: GenerationBump): void {
|
||||
this.#cache.bump(userId, generation);
|
||||
try {
|
||||
this.clients.event.emit(
|
||||
@@ -681,6 +745,15 @@ export class EventsService extends PuterService {
|
||||
};
|
||||
}
|
||||
|
||||
#aclDeps(): EventAclDeps {
|
||||
return {
|
||||
acl: this.services.acl,
|
||||
getAncestorChain: (path) => this.services.fs.getAncestorChain(path),
|
||||
getUser: (userId) => this.stores.user.getById(userId),
|
||||
getApp: (uid) => this.stores.app.getByUid(uid),
|
||||
};
|
||||
}
|
||||
|
||||
async #spendCallBudget(userId: number): Promise<void> {
|
||||
const ok = await checkRateLimit(
|
||||
`${EVENTS_SUBSCRIBE_LIMIT.scope}:${userId}`,
|
||||
@@ -695,13 +768,13 @@ export class EventsService extends PuterService {
|
||||
* and renewing is what separates one of those from a node that died without
|
||||
* reaping.
|
||||
*/
|
||||
#startRefresh(userId: number, socketId: string): void {
|
||||
const key = `${userId}|${socketId}`;
|
||||
#startRefresh(holderUserId: number, socketId: string): void {
|
||||
const key = `${holderUserId}|${socketId}`;
|
||||
if (this.#refreshTimers.has(key)) return;
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
void this.stores.eventSubscription
|
||||
.refresh(userId, socketId)
|
||||
.refresh(holderUserId, socketId)
|
||||
.catch(() => {});
|
||||
},
|
||||
Math.floor((SESSION_SUBSCRIPTION_TTL_SECONDS * 1000) / 3),
|
||||
@@ -710,8 +783,8 @@ export class EventsService extends PuterService {
|
||||
this.#refreshTimers.set(key, timer);
|
||||
}
|
||||
|
||||
#stopRefresh(userId: number, socketId: string): void {
|
||||
const key = `${userId}|${socketId}`;
|
||||
#stopRefresh(holderUserId: number, socketId: string): void {
|
||||
const key = `${holderUserId}|${socketId}`;
|
||||
const timer = this.#refreshTimers.get(key);
|
||||
if (!timer) return;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Subscribing, and staying subscribed, against real grants.
|
||||
*
|
||||
* The unit tests treat access as data; these stage actual shares, so they are
|
||||
* what shows that a subscription on someone else's folder can be made at all,
|
||||
* that the row is found from the owner's side when that owner writes, and that
|
||||
* taking the share away stops the delivery rather than the write.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
|
||||
import { makeActor, 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 { AclMode } from '../acl/ACLService.js';
|
||||
import type { DeliveryEnvelope } from './EventsService.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
let env: PuterTestEnv;
|
||||
let owner: { actor: Actor; username: string; token: string; id: number };
|
||||
let guest: { actor: Actor; username: string; token: string; id: number };
|
||||
let delivered: DeliveryEnvelope[];
|
||||
|
||||
const events = () => env.server.services.events;
|
||||
const fs = () => env.server.services.fs;
|
||||
|
||||
const descriptor = (path: string) => ({
|
||||
path,
|
||||
resolveAncestors: () => fs().getAncestorChain(path),
|
||||
});
|
||||
|
||||
const share = async (path: string, mode: AclMode): Promise<void> => {
|
||||
await env.server.services.acl.setUserUser(
|
||||
owner.actor,
|
||||
guest.actor,
|
||||
descriptor(path),
|
||||
mode,
|
||||
);
|
||||
};
|
||||
|
||||
const unshare = async (path: string, mode: AclMode): Promise<void> => {
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
|
||||
await env.server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
guest.username,
|
||||
`fs:${entry!.uid}:${mode}`,
|
||||
);
|
||||
};
|
||||
|
||||
const folder = async (path: string): Promise<string> => {
|
||||
await fs().mkdir(owner.id, { path, createMissingParents: true });
|
||||
return path;
|
||||
};
|
||||
|
||||
/** A write by the owner, through the route a client actually calls. */
|
||||
const mkdirAsOwner = async (path: string): Promise<void> => {
|
||||
const response = await fetch(new URL('/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${owner.token}`,
|
||||
},
|
||||
body: JSON.stringify({ path, create_missing_parents: true }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
};
|
||||
|
||||
const subscribeAs = async (
|
||||
who: { actor: Actor },
|
||||
socketId: string,
|
||||
subject: string,
|
||||
) => (await events().subscribe(who.actor, socketId, { subject })).sub;
|
||||
|
||||
const settle = (count = 1) =>
|
||||
vi.waitFor(() => expect(delivered.length).toBeGreaterThanOrEqual(count), {
|
||||
timeout: EVENTS_COALESCE_WINDOW_MS * 12,
|
||||
interval: 25,
|
||||
});
|
||||
|
||||
const quiet = () =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, EVENTS_COALESCE_WINDOW_MS * 3),
|
||||
);
|
||||
|
||||
const uniquePath = (base: string) =>
|
||||
`${base}/n-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ events: { enabled: true } } as IConfig);
|
||||
|
||||
const ownerRow = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const guestRow = await env.server.stores.user.getByUsername(
|
||||
env.users.other.username,
|
||||
);
|
||||
owner = {
|
||||
actor: makeActor({ user: ownerRow as never }),
|
||||
username: env.users.user.username,
|
||||
token: env.users.user.token,
|
||||
id: ownerRow!.id,
|
||||
};
|
||||
guest = {
|
||||
actor: makeActor({ user: guestRow as never }),
|
||||
username: env.users.other.username,
|
||||
token: env.users.other.token,
|
||||
id: guestRow!.id,
|
||||
};
|
||||
|
||||
delivered = [];
|
||||
events().onDelivered = (envelope) => delivered.push(envelope);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
describe('who may subscribe', () => {
|
||||
it('lets a guest subscribe to a folder shared with them', async () => {
|
||||
const path = await folder(`/${owner.username}/shared-listable`);
|
||||
await share(path, 'list');
|
||||
|
||||
const sub = await subscribeAs(guest, 'guest-a', `fs:${path}`);
|
||||
|
||||
expect(sub.anchor.path).toBe(path);
|
||||
});
|
||||
|
||||
it('answers a folder that was never shared as absent', async () => {
|
||||
const path = await folder(`/${owner.username}/never-shared`);
|
||||
|
||||
await expect(
|
||||
subscribeAs(guest, 'guest-b', `fs:${path}`),
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
isHttpError(err) &&
|
||||
err.statusCode === 404 &&
|
||||
err.legacyCode === 'subject_does_not_exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a folder the guest can see but not list', async () => {
|
||||
const path = await folder(`/${owner.username}/shared-visible`);
|
||||
await share(path, 'see');
|
||||
|
||||
await expect(
|
||||
subscribeAs(guest, 'guest-c', `fs:${path}`),
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
isHttpError(err) &&
|
||||
err.statusCode === 403 &&
|
||||
err.legacyCode === 'forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('will not let a filter buy reach the anchor check refuses', async () => {
|
||||
const shared = await folder(`/${owner.username}/reach-shared`);
|
||||
await folder(`/${owner.username}/reach-private`);
|
||||
await share(shared, 'list');
|
||||
|
||||
await expect(
|
||||
subscribeAs(
|
||||
guest,
|
||||
'guest-d',
|
||||
`fs:/${owner.username}/reach-private/**`,
|
||||
),
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) => isHttpError(err) && err.statusCode === 404,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivering across an account boundary', () => {
|
||||
it('finds the guest`s row from the owner`s write', async () => {
|
||||
const path = await folder(`/${owner.username}/shared-writes`);
|
||||
await share(path, 'list');
|
||||
const theirs = await subscribeAs(guest, 'guest-e', `fs:${path}`);
|
||||
const mine = await subscribeAs(owner, 'owner-e', `fs:${path}`);
|
||||
delivered.length = 0;
|
||||
|
||||
await mkdirAsOwner(uniquePath(path));
|
||||
await settle(2);
|
||||
|
||||
const byId = new Map(delivered.map((d) => [d.subId, d.event]));
|
||||
// The guest hears an event about someone else's write; the owner
|
||||
// hears their own.
|
||||
expect(byId.get(theirs.subId)).toMatchObject({ self: false });
|
||||
expect(byId.get(mine.subId)).toMatchObject({ self: true });
|
||||
});
|
||||
|
||||
it('stops delivering the moment the share is revoked', async () => {
|
||||
const path = await folder(`/${owner.username}/shared-revoked`);
|
||||
await share(path, 'list');
|
||||
await subscribeAs(guest, 'guest-f', `fs:${path}`);
|
||||
|
||||
await unshare(path, 'list');
|
||||
delivered.length = 0;
|
||||
await mkdirAsOwner(uniquePath(path));
|
||||
await quiet();
|
||||
|
||||
// The row is still registered; it just no longer authorizes anything.
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
|
||||
it('delivers only from the part of the anchor the guest can still list', async () => {
|
||||
const shared = await folder(`/${owner.username}/narrowed`);
|
||||
const allowed = await folder(`${shared}/allowed`);
|
||||
const closed = await folder(`${shared}/closed`);
|
||||
await share(shared, 'list');
|
||||
const sub = await subscribeAs(guest, 'guest-g', `fs:${shared}/**`);
|
||||
|
||||
// The share narrows to one subfolder: the subscription's anchor is no
|
||||
// longer listable, and the filter still spans both.
|
||||
await share(allowed, 'list');
|
||||
await unshare(shared, 'list');
|
||||
delivered.length = 0;
|
||||
|
||||
await mkdirAsOwner(uniquePath(closed));
|
||||
await quiet();
|
||||
expect(delivered).toEqual([]);
|
||||
|
||||
await mkdirAsOwner(uniquePath(allowed));
|
||||
await settle();
|
||||
expect(delivered.map((d) => d.subId)).toEqual([sub.subId]);
|
||||
});
|
||||
});
|
||||
@@ -17,45 +17,176 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type {
|
||||
AclError,
|
||||
AclMode,
|
||||
ResourceDescriptor,
|
||||
} from '../acl/ACLService.js';
|
||||
|
||||
/**
|
||||
* Who may subscribe to an anchor, and who may still be delivered from it.
|
||||
* Who may subscribe to an anchor, who may still be delivered from it, and which
|
||||
* of their rows an actor is allowed to see.
|
||||
*
|
||||
* Owner-only today. The real rule is an ACL check for `list` on the anchor,
|
||||
* with the grant it succeeded under stored on the subscription and re-checked
|
||||
* at delivery; both call sites below are shaped for that and neither has to
|
||||
* move when it lands. What must not change is the failure: a node the caller
|
||||
* cannot reach is answered as absent, because a distinguishable "forbidden"
|
||||
* turns subscribe into a way to ask whether a path exists.
|
||||
* Subscribing is the same check as reading the resource, at mode `list` — `see`
|
||||
* is not enough, because a subscription reports the names of things appearing
|
||||
* under the anchor. The mode the check passed under is stored on the row and
|
||||
* re-run per event, against the node the event is about rather than the anchor:
|
||||
* that is what keeps a match filter from reaching anything the holder could not
|
||||
* have subscribed to directly, and what makes a revoked share stop delivering
|
||||
* without anyone having to find and delete the row.
|
||||
*
|
||||
* The failure is a `getSafeAclError` failure, not a bare 403: a node the caller
|
||||
* cannot even `see` is answered as absent, because a distinguishable
|
||||
* "forbidden" turns subscribe into a way to ask whether a path exists.
|
||||
*/
|
||||
|
||||
export interface AnchorOwnership {
|
||||
/** The user the anchor node belongs to. */
|
||||
ownerUserId: number;
|
||||
/** Minimum ACL mode a subscription needs on its anchor. */
|
||||
export const SUBSCRIBE_MODE: AclMode = 'list';
|
||||
|
||||
/** A node an authorization decision is about. */
|
||||
export interface AuthorizedNode {
|
||||
uid: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SubscribingActor {
|
||||
userId: number;
|
||||
/** What a stored subscription carries about the grant it was made under. */
|
||||
export interface SubscriptionGrant {
|
||||
holderUserId: number;
|
||||
appUid: string | null;
|
||||
permission: AclMode;
|
||||
}
|
||||
|
||||
const subjectDoesNotExist = (subject: string): HttpError =>
|
||||
new HttpError(404, `No such entry: ${subject}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
export interface EventAclDeps {
|
||||
acl: {
|
||||
check: (
|
||||
actor: Actor,
|
||||
resource: ResourceDescriptor,
|
||||
mode: AclMode,
|
||||
) => Promise<boolean>;
|
||||
getSafeAclError: (
|
||||
actor: Actor,
|
||||
resource: ResourceDescriptor,
|
||||
mode: AclMode,
|
||||
) => Promise<AclError>;
|
||||
};
|
||||
/** Existing ancestors of a path, deepest first. */
|
||||
getAncestorChain: (
|
||||
path: string,
|
||||
) => Promise<ReadonlyArray<{ uid: string; path: string }>>;
|
||||
getUser: (userId: number) => Promise<UserRow | null>;
|
||||
getApp: (uid: string) => Promise<{ id?: number } | null>;
|
||||
}
|
||||
|
||||
/** Whether this actor may hold a subscription on this anchor. */
|
||||
export const checkSubscribeAuthorization = (
|
||||
actor: SubscribingActor,
|
||||
anchor: AnchorOwnership,
|
||||
): boolean => actor.userId === anchor.ownerUserId;
|
||||
|
||||
/** The same decision, as the subscribe path needs it: pass, or 404. */
|
||||
export const assertSubscribeAuthorized = (
|
||||
actor: SubscribingActor,
|
||||
anchor: AnchorOwnership,
|
||||
/**
|
||||
* Descriptor for one node, walking the tree at most once however many
|
||||
* subscriptions ask about it. The chain must start with the node itself, which
|
||||
* the store no longer returns once the node is gone — a removal is exactly the
|
||||
* event whose own grant may live on the node being removed.
|
||||
*/
|
||||
export const nodeDescriptor = (
|
||||
node: AuthorizedNode,
|
||||
deps: Pick<EventAclDeps, 'getAncestorChain'>,
|
||||
): ResourceDescriptor => {
|
||||
let ancestors: Promise<
|
||||
ReadonlyArray<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
return {
|
||||
path: node.path,
|
||||
resolveAncestors: () => {
|
||||
ancestors ??= deps
|
||||
.getAncestorChain(node.path)
|
||||
.then((chain) =>
|
||||
chain[0]?.uid === node.uid
|
||||
? chain
|
||||
: [{ uid: node.uid, path: node.path }, ...chain],
|
||||
);
|
||||
return ancestors;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The identity a stored subscription acts as when its access is re-checked. An
|
||||
* app's row is re-checked as that app, not as the user who launched it — and
|
||||
* the app is resolved rather than taken from the row, because a grant to an app
|
||||
* is stored against its numeric id and is invisible to an actor without one.
|
||||
*/
|
||||
const grantActor = async (
|
||||
grant: SubscriptionGrant,
|
||||
user: UserRow,
|
||||
deps: EventAclDeps,
|
||||
): Promise<Actor | null> => {
|
||||
if (!grant.appUid) return makeActor({ user, app: null });
|
||||
const app = await deps.getApp(grant.appUid);
|
||||
if (!app) return null;
|
||||
return makeActor({ user, app: { uid: grant.appUid, id: app.id } });
|
||||
};
|
||||
|
||||
/**
|
||||
* Authorize a subscribe. Returns the mode the check succeeded under, which the
|
||||
* row stores; throws the safe error otherwise.
|
||||
*/
|
||||
export const assertSubscribeAuthorized = async (
|
||||
actor: Actor,
|
||||
anchor: AuthorizedNode,
|
||||
subject: string,
|
||||
): void => {
|
||||
if (!checkSubscribeAuthorization(actor, anchor))
|
||||
throw subjectDoesNotExist(subject);
|
||||
deps: EventAclDeps,
|
||||
): Promise<AclMode> => {
|
||||
const resource = nodeDescriptor(anchor, deps);
|
||||
if (await deps.acl.check(actor, resource, SUBSCRIBE_MODE))
|
||||
return SUBSCRIBE_MODE;
|
||||
|
||||
const safe = await deps.acl.getSafeAclError(
|
||||
actor,
|
||||
resource,
|
||||
SUBSCRIBE_MODE,
|
||||
);
|
||||
if (safe.status === 404)
|
||||
throw new HttpError(404, `No such entry: ${subject}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
throw new HttpError(403, safe.message, {
|
||||
legacyCode: safe.fields.code,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a stored subscription may still be delivered an event about `node`.
|
||||
* Anything that cannot be decided is a no: a delivery is not worth failing a
|
||||
* write over, and silence is the safe direction.
|
||||
*/
|
||||
export const checkDeliveryAuthorized = async (
|
||||
grant: SubscriptionGrant,
|
||||
node: ResourceDescriptor,
|
||||
deps: EventAclDeps,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const user = await deps.getUser(grant.holderUserId);
|
||||
if (!user) return false;
|
||||
const actor = await grantActor(grant, user, deps);
|
||||
if (!actor) return false;
|
||||
return await deps.acl.check(actor, node, grant.permission);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a row is inside what this actor may see and remove. An app-context
|
||||
* actor — an app token, or an access token an app issued — is confined to the
|
||||
* rows its own app created; a user-context one sees everything of theirs across
|
||||
* apps, which is what makes the account the revoke surface.
|
||||
*/
|
||||
export const rowInActorScope = (
|
||||
actor: Actor,
|
||||
row: { appUid: string | null },
|
||||
): boolean => {
|
||||
const app = actor.effectiveApp;
|
||||
// Unresolved is not "no app": reading it that way is what would hand an
|
||||
// app the account-wide view.
|
||||
if (app === undefined) return false;
|
||||
return app === null || row.appUid === app.uid;
|
||||
};
|
||||
|
||||
@@ -80,7 +80,10 @@ const entry = {
|
||||
const delivery: DeliveryContext = {
|
||||
key: 'fs.write.file',
|
||||
entry,
|
||||
ancestorUids: ['uid-parent', 'uid-home'],
|
||||
ancestors: [
|
||||
{ uid: 'uid-parent', path: '/u/Documents' },
|
||||
{ uid: 'uid-home', path: '/u' },
|
||||
],
|
||||
id: 'ev-1',
|
||||
ts: 1_700_000_001,
|
||||
self: true,
|
||||
|
||||
@@ -50,8 +50,8 @@ export interface ProjectedEvent {
|
||||
export interface EventContext {
|
||||
key: EventKey;
|
||||
entry: FSEntry;
|
||||
/** Ancestor uids of `entry`, deepest first. */
|
||||
ancestorUids: readonly string[];
|
||||
/** Existing ancestors of `entry`, deepest first. */
|
||||
ancestors: ReadonlyArray<{ uid: string; path: string }>;
|
||||
id: string;
|
||||
ts: number;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export interface UnpublishedInternalEvent {
|
||||
// uid alone and a deep write still matches.
|
||||
const fsTokens = (event: EventContext): string[] => [
|
||||
fsAnchorToken(event.entry.uid),
|
||||
...event.ancestorUids.map(fsAnchorToken),
|
||||
...event.ancestors.map((ancestor) => fsAnchorToken(ancestor.uid)),
|
||||
];
|
||||
|
||||
const fsMatchOn = (event: EventContext): string => event.entry.path;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
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 and a worker, and refuses access tokens', () => {
|
||||
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);
|
||||
|
||||
// Both access-token shapes are refused at the handshake, so neither
|
||||
// ever holds a socket to call a session verb on.
|
||||
expect(accepted(appAccessToken)).toBe(false);
|
||||
expect(accepted(personalAccessToken)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses both access-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(env.users.user.apiToken),
|
||||
).resolves.toMatch(/only user tokens/);
|
||||
await expect(attemptConnect(appAccessTokenStr)).resolves.toMatch(
|
||||
/only user tokens/,
|
||||
);
|
||||
});
|
||||
|
||||
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 acts as that app, one hop through its
|
||||
// issuer — which is what the whole scope keys on.
|
||||
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]);
|
||||
// The token the app issued inherits exactly the app's view.
|
||||
await expect(heldBy(appAccessToken)).resolves.toEqual([mine.subId]);
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,11 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* The "does this user have any subscriptions at all" answer, per process.
|
||||
* The "does anyone watch anything of this user's" answer, per process — asked
|
||||
* of whoever owns the resource being written, which is all a write knows.
|
||||
*
|
||||
* Nearly every user has none, and this is what lets a write on their behalf
|
||||
* cost nothing: after the first miss the answer is in memory and dispatch never
|
||||
* Nearly every user has nothing watched, and this is what lets a write cost
|
||||
* nothing: after the first miss the answer is in memory and dispatch never
|
||||
* touches Redis again. That only holds if invalidation is pushed rather than
|
||||
* polled, so entries are keyed by a per-user generation the subscribe and
|
||||
* unsubscribe paths bump and broadcast — never by a timer, which would put the
|
||||
|
||||
@@ -3819,10 +3819,7 @@ export class FSService extends PuterService {
|
||||
.dispatchFs(key, entry, {
|
||||
actingUserId: (Context.get('actor') as Actor | undefined)
|
||||
?.user?.id,
|
||||
ancestorUids: async () =>
|
||||
(await this.getAncestorChain(entry.path)).map(
|
||||
(ancestor) => ancestor.uid,
|
||||
),
|
||||
ancestors: () => this.getAncestorChain(entry.path),
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[fs] event dispatch failed', err);
|
||||
|
||||
@@ -41,7 +41,8 @@ const makeSub = (
|
||||
): SessionSubscription => ({
|
||||
subId: over.subId ?? `sub-${Math.random().toString(36).slice(2)}`,
|
||||
socketId: 'socket-a',
|
||||
userId: USER,
|
||||
holderUserId: USER,
|
||||
ownerUserId: USER,
|
||||
subject: 'fs:/testuser/Documents',
|
||||
token: 'f#anchor',
|
||||
anchorUid: 'anchor',
|
||||
@@ -49,9 +50,17 @@ const makeSub = (
|
||||
match: null,
|
||||
op: null,
|
||||
appUid: null,
|
||||
permission: 'list',
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A row on someone else's node: the shared-folder case, in one place. */
|
||||
const sharedWith = (
|
||||
holderUserId: number,
|
||||
over: Partial<SessionSubscription> = {},
|
||||
): SessionSubscription =>
|
||||
makeSub({ holderUserId, ownerUserId: USER, ...over });
|
||||
|
||||
beforeEach(() => {
|
||||
USER = ++userSeq;
|
||||
redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
@@ -76,6 +85,26 @@ describe('registration', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('indexes a shared anchor under its owner, not its subscriber', async () => {
|
||||
const holder = USER + 5000;
|
||||
const sub = sharedWith(holder);
|
||||
|
||||
const bump = await store.add(sub);
|
||||
|
||||
// Dispatch only knows whose resource changed, so that is the side the
|
||||
// row has to be findable from.
|
||||
expect(bump.userId).toBe(USER);
|
||||
await expect(store.userHasAny(USER)).resolves.toBe(true);
|
||||
await expect(store.userHasAny(holder)).resolves.toBe(false);
|
||||
await expect(store.getForTokens(USER, ['f#anchor'])).resolves.toEqual([
|
||||
sub,
|
||||
]);
|
||||
// The socket set is the one side that is the holder's.
|
||||
expect(await redis.smembers(`ev:s:{${holder}}:socket-a`)).toEqual([
|
||||
`${USER}|f#anchor|${sub.subId}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keys every one of a user`s keys into one cluster slot', async () => {
|
||||
await store.add(makeSub());
|
||||
const keys = await redis.keys(`ev:*{${USER}}*`);
|
||||
@@ -100,6 +129,20 @@ describe('registration', () => {
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS - 10,
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the owner`s keys, not only the holder`s', async () => {
|
||||
const holder = USER + 5000;
|
||||
await store.add(sharedWith(holder));
|
||||
await redis.expire(`ev:w:{${USER}}`, 5);
|
||||
await redis.expire(`ev:t:{${USER}}:f#anchor`, 5);
|
||||
|
||||
await store.refresh(holder, 'socket-a');
|
||||
|
||||
for (const key of [`ev:w:{${USER}}`, `ev:t:{${USER}}:f#anchor`])
|
||||
expect(await redis.ttl(key)).toBeGreaterThan(
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS - 10,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the per-socket cap', () => {
|
||||
@@ -123,7 +166,7 @@ describe('the per-socket cap', () => {
|
||||
|
||||
await expect(
|
||||
store.add(makeSub({ subId: 'b-0', socketId: 'socket-b' })),
|
||||
).resolves.toBeGreaterThan(0);
|
||||
).resolves.toMatchObject({ userId: USER });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +175,7 @@ describe('removal', () => {
|
||||
const sub = makeSub();
|
||||
await store.add(sub);
|
||||
|
||||
await store.remove(USER, sub.socketId, sub.subId);
|
||||
await store.remove(sub);
|
||||
|
||||
await expect(store.watchedTokens(USER, ['f#anchor'])).resolves.toEqual(
|
||||
[],
|
||||
@@ -146,7 +189,7 @@ describe('removal', () => {
|
||||
await store.add(mine);
|
||||
await store.add(theirs);
|
||||
|
||||
await store.remove(USER, 'socket-a', 'mine');
|
||||
await store.remove(mine);
|
||||
|
||||
await expect(store.watchedTokens(USER, ['f#anchor'])).resolves.toEqual([
|
||||
'f#anchor',
|
||||
@@ -156,12 +199,32 @@ describe('removal', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports an id this socket never held as absent', async () => {
|
||||
await store.add(makeSub({ subId: 'mine', socketId: 'socket-a' }));
|
||||
it('reads back only what the asking socket holds', async () => {
|
||||
const mine = makeSub({ subId: 'mine', socketId: 'socket-a' });
|
||||
await store.add(mine);
|
||||
|
||||
await expect(
|
||||
store.remove(USER, 'socket-b', 'mine'),
|
||||
store.getForSocket(USER, 'socket-a', 'mine'),
|
||||
).resolves.toEqual(mine);
|
||||
await expect(
|
||||
store.getForSocket(USER, 'socket-b', 'mine'),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
store.getForSocket(USER, 'socket-a', 'not-a-sub'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('finds a shared-anchor row from the holder`s socket', async () => {
|
||||
const holder = USER + 5000;
|
||||
const sub = sharedWith(holder, { subId: 'shared' });
|
||||
await store.add(sub);
|
||||
|
||||
await expect(
|
||||
store.getForSocket(holder, 'socket-a', 'shared'),
|
||||
).resolves.toEqual(sub);
|
||||
|
||||
await store.remove(sub);
|
||||
await expect(store.userHasAny(USER)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +259,29 @@ describe('disconnect', () => {
|
||||
});
|
||||
|
||||
it('says nothing changed when the socket held nothing', async () => {
|
||||
await expect(store.reapSocket(USER, 'socket-z')).resolves.toBeNull();
|
||||
await expect(store.reapSocket(USER, 'socket-z')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('moves the generation of every owner the socket watched', async () => {
|
||||
const holder = USER + 5000;
|
||||
const otherOwner = USER + 6000;
|
||||
await store.add(sharedWith(holder));
|
||||
await store.add(
|
||||
makeSub({
|
||||
subId: 'elsewhere',
|
||||
holderUserId: holder,
|
||||
ownerUserId: otherOwner,
|
||||
token: 'f#other',
|
||||
}),
|
||||
);
|
||||
|
||||
const bumps = await store.reapSocket(holder, 'socket-a');
|
||||
|
||||
expect(bumps.map((bump) => bump.userId).sort()).toEqual(
|
||||
[USER, otherOwner].sort(),
|
||||
);
|
||||
await expect(store.userHasAny(USER)).resolves.toBe(false);
|
||||
await expect(store.userHasAny(otherOwner)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,11 +289,11 @@ describe('the generation counter', () => {
|
||||
it('advances on every registration and removal', async () => {
|
||||
const first = await store.add(makeSub({ subId: 'a' }));
|
||||
const second = await store.add(makeSub({ subId: 'b', token: 'f#two' }));
|
||||
expect(second).toBeGreaterThan(first);
|
||||
expect(second.generation).toBeGreaterThan(first.generation);
|
||||
|
||||
const third = await store.remove(USER, 'socket-a', 'a');
|
||||
expect(third).toBeGreaterThan(second);
|
||||
await expect(store.getGeneration(USER)).resolves.toBe(third);
|
||||
const third = await store.remove(makeSub({ subId: 'a' }));
|
||||
expect(third.generation).toBeGreaterThan(second.generation);
|
||||
await expect(store.getGeneration(USER)).resolves.toBe(third.generation);
|
||||
});
|
||||
|
||||
it('outlives the subscriptions it orders', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET } from '../../controllers/events/limits.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { FsOp } from '../../services/events/subjects.js';
|
||||
import type { AclMode } from '../../services/acl/ACLService.js';
|
||||
import { PuterStore } from '../types.js';
|
||||
|
||||
/**
|
||||
@@ -27,14 +28,25 @@ import { PuterStore } from '../types.js';
|
||||
* when it disconnects. Nothing here outlives a connection, so none of it
|
||||
* belongs in a table.
|
||||
*
|
||||
* Every key a user owns carries the same `{<userId>}` hash tag, so one user's
|
||||
* whole set lives in one cluster slot and a pipeline over it never crosses
|
||||
* slots. Four keys, each answering one question:
|
||||
* Rows are indexed by the **owner of the anchor node**, not by the subscriber:
|
||||
* dispatch knows only whose resource changed, and a subscription on a folder
|
||||
* shared with someone else has to be found from that side. The subscriber is
|
||||
* still on the row — it is who the delivery is for and whose access is
|
||||
* re-checked — but it is not what anything is keyed by.
|
||||
*
|
||||
* ev:w:{<userId>} SET which tokens anyone is watching
|
||||
* ev:t:{<userId>}:<token> HASH subId -> row, for one watched token
|
||||
* ev:s:{<userId>}:<socketId> SET what this socket holds, for reaping
|
||||
* ev:g:{<userId>} STR subscription-set generation
|
||||
* Keys carry a `{<userId>}` hash tag so one user's set lives in one cluster
|
||||
* slot and a pipeline over it never crosses slots. Four keys, each answering
|
||||
* one question:
|
||||
*
|
||||
* ev:w:{<ownerId>} SET which tokens anyone is watching
|
||||
* ev:t:{<ownerId>}:<token> HASH subId -> row, for one watched token
|
||||
* ev:s:{<holderId>}:<socketId> SET what this socket holds, for reaping
|
||||
* ev:g:{<ownerId>} STR subscription-set generation
|
||||
*
|
||||
* The socket set is the one keyed by the holder — it is read on disconnect,
|
||||
* when all that is known is whose connection went — so its members name the
|
||||
* owner whose keyspace each row lives in, and a write touching both sides
|
||||
* splits into one pipeline per slot.
|
||||
*
|
||||
* `ev:w` is what dispatch asks first and is the only one on the hot path.
|
||||
* Membership in it is exact rather than approximate: a token's row hash going
|
||||
@@ -51,7 +63,10 @@ import { PuterStore } from '../types.js';
|
||||
export interface SessionSubscription {
|
||||
subId: string;
|
||||
socketId: string;
|
||||
userId: number;
|
||||
/** Who subscribed: the delivery target, and whose access is re-checked. */
|
||||
holderUserId: number;
|
||||
/** Owner of the anchor node: the keyspace this row is indexed in. */
|
||||
ownerUserId: number;
|
||||
/** The subject as the client asked for it. */
|
||||
subject: string;
|
||||
/** Anchor token the row is indexed under. */
|
||||
@@ -61,7 +76,16 @@ export interface SessionSubscription {
|
||||
/** Glob relative to the anchor, or `null` for a node-form subscription. */
|
||||
match: string | null;
|
||||
op: FsOp | null;
|
||||
/** The app that created the row, and the scope of the three verbs. */
|
||||
appUid: string | null;
|
||||
/** ACL mode the subscribe check passed under; re-checked per delivery. */
|
||||
permission: AclMode;
|
||||
}
|
||||
|
||||
/** One owner's generation after a change to the set of rows keyed under them. */
|
||||
export interface GenerationBump {
|
||||
userId: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// -- Keys -------------------------------------------------------------
|
||||
@@ -73,11 +97,35 @@ const socketKey = (userId: number | string, socketId: string): string =>
|
||||
`ev:s:{${userId}}:${socketId}`;
|
||||
const generationKey = (userId: number | string): string => `ev:g:{${userId}}`;
|
||||
|
||||
/** `ev:s` members name the row they point at. */
|
||||
const socketRef = (token: string, subId: string): string => `${token}|${subId}`;
|
||||
const parseSocketRef = (ref: string): { token: string; subId: string } => {
|
||||
const at = ref.indexOf('|');
|
||||
return { token: ref.slice(0, at), subId: ref.slice(at + 1) };
|
||||
/** `ev:s` members name the row they point at, and the keyspace it is in. */
|
||||
interface SocketRef {
|
||||
ownerUserId: number;
|
||||
token: string;
|
||||
subId: string;
|
||||
}
|
||||
|
||||
const socketRef = (ref: SocketRef): string =>
|
||||
`${ref.ownerUserId}|${ref.token}|${ref.subId}`;
|
||||
|
||||
const parseSocketRef = (ref: string): SocketRef => {
|
||||
const owner = ref.indexOf('|');
|
||||
const token = ref.indexOf('|', owner + 1);
|
||||
return {
|
||||
ownerUserId: Number(ref.slice(0, owner)),
|
||||
token: ref.slice(owner + 1, token),
|
||||
subId: ref.slice(token + 1),
|
||||
};
|
||||
};
|
||||
|
||||
/** Group refs by the keyspace they live in, so no pipeline crosses slots. */
|
||||
const byOwner = (refs: readonly SocketRef[]): Map<number, SocketRef[]> => {
|
||||
const grouped = new Map<number, SocketRef[]>();
|
||||
for (const ref of refs) {
|
||||
const held = grouped.get(ref.ownerUserId) ?? [];
|
||||
held.push(ref);
|
||||
grouped.set(ref.ownerUserId, held);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
// -- Lifetimes --------------------------------------------------------
|
||||
@@ -107,70 +155,89 @@ export class EventSubscriptionStore extends PuterStore {
|
||||
// -- Writes ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register one subscription. Returns the generation the write produced, so
|
||||
* the caller can broadcast it.
|
||||
* Register one subscription. Returns the owner's new generation, so the
|
||||
* caller can broadcast it — that is the keyspace dispatch reads.
|
||||
*
|
||||
* Ordering is deliberate: the row lands before the token joins the watched
|
||||
* set, so dispatch never sees a token whose rows it cannot read yet.
|
||||
*/
|
||||
async add(sub: SessionSubscription): Promise<number> {
|
||||
const { userId, socketId, token, subId } = sub;
|
||||
async add(sub: SessionSubscription): Promise<GenerationBump> {
|
||||
const { holderUserId, ownerUserId, socketId, token, subId } = sub;
|
||||
|
||||
const held = await this.clients.redis.scard(
|
||||
socketKey(userId, socketId),
|
||||
socketKey(holderUserId, socketId),
|
||||
);
|
||||
if (held >= EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET)
|
||||
throw subscriptionLimitReached();
|
||||
|
||||
const pipeline = this.clients.redis.pipeline();
|
||||
pipeline.hset(tokenKey(userId, token), subId, JSON.stringify(sub));
|
||||
pipeline.expire(
|
||||
tokenKey(userId, token),
|
||||
const rows = this.clients.redis.pipeline();
|
||||
rows.hset(tokenKey(ownerUserId, token), subId, JSON.stringify(sub));
|
||||
rows.expire(
|
||||
tokenKey(ownerUserId, token),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
pipeline.sadd(socketKey(userId, socketId), socketRef(token, subId));
|
||||
pipeline.expire(
|
||||
socketKey(userId, socketId),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
pipeline.sadd(watchedKey(userId), token);
|
||||
pipeline.expire(watchedKey(userId), SESSION_SUBSCRIPTION_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
rows.sadd(watchedKey(ownerUserId), token);
|
||||
rows.expire(watchedKey(ownerUserId), SESSION_SUBSCRIPTION_TTL_SECONDS);
|
||||
await rows.exec();
|
||||
|
||||
return this.bumpGeneration(userId);
|
||||
const holder = this.clients.redis.pipeline();
|
||||
holder.sadd(
|
||||
socketKey(holderUserId, socketId),
|
||||
socketRef({ ownerUserId, token, subId }),
|
||||
);
|
||||
holder.expire(
|
||||
socketKey(holderUserId, socketId),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
await holder.exec();
|
||||
|
||||
return {
|
||||
userId: ownerUserId,
|
||||
generation: await this.bumpGeneration(ownerUserId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one subscription. Returns the new generation, or `null` when the
|
||||
* subscription was not this socket's to remove — the caller answers that as
|
||||
* a 404 rather than telling a client which ids exist.
|
||||
* Drop one subscription the caller has already read back. Taking the row
|
||||
* rather than an id keeps the scope decision — whose row this is, and which
|
||||
* app's — with the actor, where it belongs.
|
||||
*/
|
||||
async remove(
|
||||
userId: number,
|
||||
socketId: string,
|
||||
subId: string,
|
||||
): Promise<number | null> {
|
||||
const refs = await this.clients.redis.smembers(
|
||||
socketKey(userId, socketId),
|
||||
);
|
||||
const ref = refs.find((r) => parseSocketRef(r).subId === subId);
|
||||
if (!ref) return null;
|
||||
|
||||
await this.#dropRefs(userId, socketId, [ref]);
|
||||
return this.bumpGeneration(userId);
|
||||
async remove(sub: SessionSubscription): Promise<GenerationBump> {
|
||||
await this.#dropRefs(sub.holderUserId, sub.socketId, [
|
||||
{
|
||||
ownerUserId: sub.ownerUserId,
|
||||
token: sub.token,
|
||||
subId: sub.subId,
|
||||
},
|
||||
]);
|
||||
return {
|
||||
userId: sub.ownerUserId,
|
||||
generation: await this.bumpGeneration(sub.ownerUserId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop everything a socket held. Runs on disconnect; the TTL is what covers
|
||||
* the disconnect that never runs.
|
||||
* the disconnect that never runs. One socket can hold rows in several
|
||||
* owners' keyspaces, so several generations may move.
|
||||
*/
|
||||
async reapSocket(userId: number, socketId: string): Promise<number | null> {
|
||||
const refs = await this.clients.redis.smembers(
|
||||
socketKey(userId, socketId),
|
||||
);
|
||||
if (refs.length === 0) return null;
|
||||
await this.#dropRefs(userId, socketId, refs);
|
||||
return this.bumpGeneration(userId);
|
||||
async reapSocket(
|
||||
holderUserId: number,
|
||||
socketId: string,
|
||||
): Promise<GenerationBump[]> {
|
||||
const refs = (
|
||||
await this.clients.redis.smembers(socketKey(holderUserId, socketId))
|
||||
).map(parseSocketRef);
|
||||
if (refs.length === 0) return [];
|
||||
|
||||
await this.#dropRefs(holderUserId, socketId, refs);
|
||||
const bumps: GenerationBump[] = [];
|
||||
for (const ownerUserId of byOwner(refs).keys())
|
||||
bumps.push({
|
||||
userId: ownerUserId,
|
||||
generation: await this.bumpGeneration(ownerUserId),
|
||||
});
|
||||
return bumps;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,49 +247,75 @@ export class EventSubscriptionStore extends PuterStore {
|
||||
* anchor.
|
||||
*/
|
||||
async #dropRefs(
|
||||
userId: number,
|
||||
holderUserId: number,
|
||||
socketId: string,
|
||||
refs: string[],
|
||||
refs: readonly SocketRef[],
|
||||
): Promise<void> {
|
||||
const parsed = refs.map(parseSocketRef);
|
||||
|
||||
const drop = this.clients.redis.pipeline();
|
||||
for (const { token, subId } of parsed)
|
||||
drop.hdel(tokenKey(userId, token), subId);
|
||||
drop.srem(socketKey(userId, socketId), ...refs);
|
||||
await drop.exec();
|
||||
|
||||
const tokens = [...new Set(parsed.map((p) => p.token))];
|
||||
const counts = this.clients.redis.pipeline();
|
||||
for (const token of tokens) counts.hlen(tokenKey(userId, token));
|
||||
const results = (await counts.exec()) ?? [];
|
||||
|
||||
const orphaned = tokens.filter(
|
||||
(_token, i) => Number(results[i]?.[1] ?? 0) === 0,
|
||||
await this.clients.redis.srem(
|
||||
socketKey(holderUserId, socketId),
|
||||
...refs.map(socketRef),
|
||||
);
|
||||
if (orphaned.length > 0)
|
||||
await this.clients.redis.srem(watchedKey(userId), ...orphaned);
|
||||
|
||||
for (const [ownerUserId, owned] of byOwner(refs)) {
|
||||
const drop = this.clients.redis.pipeline();
|
||||
for (const { token, subId } of owned)
|
||||
drop.hdel(tokenKey(ownerUserId, token), subId);
|
||||
await drop.exec();
|
||||
|
||||
const tokens = [...new Set(owned.map((ref) => ref.token))];
|
||||
const counts = this.clients.redis.pipeline();
|
||||
for (const token of tokens)
|
||||
counts.hlen(tokenKey(ownerUserId, token));
|
||||
const results = (await counts.exec()) ?? [];
|
||||
|
||||
const orphaned = tokens.filter(
|
||||
(_token, i) => Number(results[i]?.[1] ?? 0) === 0,
|
||||
);
|
||||
if (orphaned.length > 0)
|
||||
await this.clients.redis.srem(
|
||||
watchedKey(ownerUserId),
|
||||
...orphaned,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a live socket's keys ahead of the TTL backstop. */
|
||||
async refresh(userId: number, socketId: string): Promise<void> {
|
||||
const pipeline = this.clients.redis.pipeline();
|
||||
pipeline.expire(
|
||||
socketKey(userId, socketId),
|
||||
/**
|
||||
* Keep a live socket's keys ahead of the TTL backstop — its own, and the
|
||||
* watched sets its rows live in, which may belong to other users.
|
||||
*/
|
||||
async refresh(holderUserId: number, socketId: string): Promise<void> {
|
||||
const refs = (
|
||||
await this.clients.redis.smembers(socketKey(holderUserId, socketId))
|
||||
).map(parseSocketRef);
|
||||
|
||||
await this.clients.redis.expire(
|
||||
socketKey(holderUserId, socketId),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
pipeline.expire(watchedKey(userId), SESSION_SUBSCRIPTION_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
|
||||
for (const [ownerUserId, owned] of byOwner(refs)) {
|
||||
const pipeline = this.clients.redis.pipeline();
|
||||
pipeline.expire(
|
||||
watchedKey(ownerUserId),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
for (const token of new Set(owned.map((ref) => ref.token)))
|
||||
pipeline.expire(
|
||||
tokenKey(ownerUserId, token),
|
||||
SESSION_SUBSCRIPTION_TTL_SECONDS,
|
||||
);
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Reads -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether this user has any subscriptions at all. One command, and the only
|
||||
* thing a cold process needs before it can answer from memory.
|
||||
* Whether anyone watches anything of this owner's at all. One command, and
|
||||
* the only thing a cold process needs before it can answer from memory.
|
||||
*/
|
||||
async userHasAny(userId: number): Promise<boolean> {
|
||||
return (await this.clients.redis.exists(watchedKey(userId))) === 1;
|
||||
async userHasAny(ownerUserId: number): Promise<boolean> {
|
||||
return (await this.clients.redis.exists(watchedKey(ownerUserId))) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,12 +323,12 @@ export class EventSubscriptionStore extends PuterStore {
|
||||
* and one command whatever the depth of the tree.
|
||||
*/
|
||||
async watchedTokens(
|
||||
userId: number,
|
||||
ownerUserId: number,
|
||||
tokens: readonly string[],
|
||||
): Promise<string[]> {
|
||||
if (tokens.length === 0) return [];
|
||||
const flags = await this.clients.redis.smismember(
|
||||
watchedKey(userId),
|
||||
watchedKey(ownerUserId),
|
||||
...tokens,
|
||||
);
|
||||
return tokens.filter((_token, i) => Number(flags[i]) === 1);
|
||||
@@ -243,12 +336,13 @@ export class EventSubscriptionStore extends PuterStore {
|
||||
|
||||
/** The rows behind a set of watched tokens. */
|
||||
async getForTokens(
|
||||
userId: number,
|
||||
ownerUserId: number,
|
||||
tokens: readonly string[],
|
||||
): Promise<SessionSubscription[]> {
|
||||
if (tokens.length === 0) return [];
|
||||
const pipeline = this.clients.redis.pipeline();
|
||||
for (const token of tokens) pipeline.hvals(tokenKey(userId, token));
|
||||
for (const token of tokens)
|
||||
pipeline.hvals(tokenKey(ownerUserId, token));
|
||||
const results = (await pipeline.exec()) ?? [];
|
||||
|
||||
const subs: SessionSubscription[] = [];
|
||||
@@ -264,27 +358,55 @@ export class EventSubscriptionStore extends PuterStore {
|
||||
return subs;
|
||||
}
|
||||
|
||||
/** Everything one socket holds, newest first is not meaningful here. */
|
||||
/** Everything one socket holds, across every keyspace its rows live in. */
|
||||
async listForSocket(
|
||||
userId: number,
|
||||
holderUserId: number,
|
||||
socketId: string,
|
||||
): Promise<SessionSubscription[]> {
|
||||
const refs = await this.clients.redis.smembers(
|
||||
socketKey(userId, socketId),
|
||||
);
|
||||
const refs = (
|
||||
await this.clients.redis.smembers(socketKey(holderUserId, socketId))
|
||||
).map(parseSocketRef);
|
||||
if (refs.length === 0) return [];
|
||||
|
||||
const byToken = new Map<string, Set<string>>();
|
||||
for (const ref of refs) {
|
||||
const { token, subId } = parseSocketRef(ref);
|
||||
const ids = byToken.get(token) ?? new Set<string>();
|
||||
ids.add(subId);
|
||||
byToken.set(token, ids);
|
||||
const held: SessionSubscription[] = [];
|
||||
for (const [ownerUserId, owned] of byOwner(refs)) {
|
||||
const wanted = new Set(owned.map((ref) => ref.subId));
|
||||
const rows = await this.getForTokens(ownerUserId, [
|
||||
...new Set(owned.map((ref) => ref.token)),
|
||||
]);
|
||||
held.push(...rows.filter((row) => wanted.has(row.subId)));
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
const tokens = [...byToken.keys()];
|
||||
const rows = await this.getForTokens(userId, tokens);
|
||||
return rows.filter((row) => byToken.get(row.token)?.has(row.subId));
|
||||
/**
|
||||
* One row this socket holds, by id. An id the socket never held reads as
|
||||
* absent, which is what keeps unsubscribe from answering whether someone
|
||||
* else's id exists.
|
||||
*/
|
||||
async getForSocket(
|
||||
holderUserId: number,
|
||||
socketId: string,
|
||||
subId: string,
|
||||
): Promise<SessionSubscription | null> {
|
||||
const refs = await this.clients.redis.smembers(
|
||||
socketKey(holderUserId, socketId),
|
||||
);
|
||||
const ref = refs
|
||||
.map(parseSocketRef)
|
||||
.find((candidate) => candidate.subId === subId);
|
||||
if (!ref) return null;
|
||||
|
||||
const raw = await this.clients.redis.hget(
|
||||
tokenKey(ref.ownerUserId, ref.token),
|
||||
subId,
|
||||
);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as SessionSubscription;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Generation --------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user