feat: revocation settles kv handles (PUT-1687) (#3691)

This commit is contained in:
Daniel Salazar
2026-09-03 19:43:27 -07:00
committed by GitHub
parent ac5446f877
commit f139588f89
11 changed files with 1002 additions and 49 deletions
@@ -19,9 +19,10 @@
import type { Request, Response } from 'express';
import type { Actor } from '../../core/actor.js';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
import { normalizeLimit } from '../../util/pagination.js';
import { PuterController } from '../types.js';
import {
@@ -156,6 +157,48 @@ export class EventsController extends PuterController {
);
}
/** GET /events/kv-handles — what this account has shared out. */
@Get('/kv-handles', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listKvHandles(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.services.events.listKvHandles(actor, {
limit: normalizeLimit(query.limit, {
cap: KV_HANDLE_LIST_LIMIT_CAP,
}),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
});
res.json({
items: page.items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/**
* DELETE /events/kv-handles/:handle — take a shared region back. A handle
* this account did not mint reads as absent.
*/
@Delete('/kv-handles/:handle', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async revokeKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.events.revokeKvHandle(actor, req.params.handle),
);
}
// -- Handlers ----------------------------------------------------
//
// Deploying an app's code, so the gate is the same as the verbs above plus
+94 -2
View File
@@ -51,6 +51,7 @@ import {
HANDLER_SETTLE_BATCH,
isSuspendedReason,
} from '../../stores/events/DurableSubscriptionStore.js';
import type { KvShareHandleListOptions } from '../../stores/events/KvShareHandleStore.js';
import {
HANDLER_NAME_MAX_LENGTH,
hashContent,
@@ -170,6 +171,7 @@ import {
import { SubscriptionCache } from './subscriptionCache.js';
import {
assertShareableAppUid,
assertShareablePermission,
assertShareablePrefix,
kvShareGrantCovers,
kvShareOwnerImplicator,
@@ -669,7 +671,7 @@ const handlerAppRequired = (): HttpError =>
* separate consent.
*/
const handleOwnerOnly = (): HttpError =>
new HttpError(403, 'Only an account session may mint a share handle', {
new HttpError(403, 'Only an account session may manage share handles', {
legacyCode: 'events_kv_handle_owner_only',
});
@@ -1713,7 +1715,9 @@ export class EventsService extends PuterService {
);
const grantee = await this.#resolveGrantee(request);
const permission = kvSharePermission(owner.uuid, appUid, keyPrefix);
const permission = assertShareablePermission(
kvSharePermission(owner.uuid, appUid, keyPrefix),
);
await this.services.permission.grantUserUserPermission(
actor,
grantee.username,
@@ -1732,6 +1736,94 @@ export class EventsService extends PuterService {
return { handle: row.handle, prefix: row.keyPrefix };
}
/**
* Take a shared region back.
*
* The grant goes first — the subtree with it, since a deeper grant would
* leave access to part of a region that has just been withdrawn — and the
* handle is retired after. Removing the grant is what actually settles the
* subscriptions, and it is the only half that stops delivery, so it is the
* half that must not be able to fail after the other has succeeded. In that
* order both steps are idempotent and a failure anywhere is a retry: the
* reverse leaves a handle nothing can address standing on a grant
* everything still passes, and no way to try again.
*
* Not gated on the feature flag. Withdrawing access only ever narrows, and
* an install that turned handles off must still be able to retire the ones
* it has.
*/
async revokeKvHandle(
actor: Actor,
handle: unknown,
): Promise<{ handle: string; revokedAt: number }> {
if (!this.enabled) throw disabled();
const owner = actor.user;
if (owner?.id === undefined) throw disabled();
if (actor.effectiveApp !== null) throw handleOwnerOnly();
await this.#spendHandleBudget(owner.id);
const named = typeof handle === 'string' ? handle : '';
const share = await this.stores.kvShareHandle.getByHandle(named);
// Unknown and somebody else's read the same way, so revoking cannot be
// used to find out that a handle exists.
if (!share || share.ownerUserId !== owner.id)
throw unknownKvShareHandle(named);
const grantee = await this.stores.user.getById(share.granteeUserId);
if (!grantee?.username)
throw new HttpError(500, 'The grantee of this handle is gone', {
legacyCode: 'internal_error',
});
await this.services.permission.revokeUserUserPermissionSubtree(
actor,
grantee.username,
share.permission,
{ reason: 'kv share handle revoked' },
);
const revoked = await this.stores.kvShareHandle.retire(named, owner.id);
if (!revoked?.revokedAt) throw unknownKvShareHandle(named);
return { handle: revoked.handle, revokedAt: revoked.revokedAt };
}
/**
* What this account has shared out of its key-value data, retired handles
* included: they are the only record of what was shared and when it
* stopped. The grantee has no listing of their own — what they hold is the
* handle they were given.
*/
async listKvHandles(
actor: Actor,
options: KvShareHandleListOptions = {},
): Promise<PageResult<KvShareHandleView>> {
if (!this.enabled) throw disabled();
const owner = actor.user;
if (owner?.id === undefined) throw disabled();
if (actor.effectiveApp !== null) throw handleOwnerOnly();
const page = await this.stores.kvShareHandle.listForOwner(
owner.id,
options,
);
const grantees = await this.stores.user.getByIds([
...new Set(page.items.map((row) => row.granteeUserId)),
]);
return {
...page,
items: page.items.map((row) => ({
handle: row.handle,
prefix: row.keyPrefix,
appUid: row.appUid,
granteeUsername:
grantees.get(row.granteeUserId)?.username ?? null,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
})),
};
}
/** Who a mint is for. Named by username or uuid; unknown reads as absent. */
async #resolveGrantee(
request: MintKvHandleRequest,
@@ -0,0 +1,483 @@
/*
* 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/>.
*/
/**
* Taking a shared key-value region back.
*
* Revoking a handle is not its own settle mechanism: it withdraws the grant,
* and the revocation pass that already handles an unshare does the rest. What
* these cases pin is that the handle really is 1:1 with what has to be settled
* the holder index answers it, nothing scans for the handle and that
* everything a revocation is supposed to take with it actually goes.
*/
import { v4 as uuidv4 } from 'uuid';
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 { runWithContext } from '../../core/context.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import type { DeliveryEnvelope } from './EventsService.js';
import { kvAnchorToken } from './subjects.js';
const BOOT_TIMEOUT_MS = 120_000;
const SOCKET_ID = 'kv-revoke-socket';
const TABLE = 'event_subscriptions';
const PREFIX = 'workspace:abc:';
interface TestUser {
actor: Actor;
username: string;
id: number;
uuid: string;
}
let env: PuterTestEnv;
let owner: TestUser;
let guest: TestUser;
let stranger: TestUser;
let delivered: DeliveryEnvelope[];
const events = () => env.server.services.events;
const settled = (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 userFor = async (username: string): Promise<TestUser> => {
const row = await env.server.stores.user.getByUsername(username);
return {
actor: makeActor({ user: row as never }),
username,
id: row!.id,
uuid: row!.uuid as string,
};
};
const ownerWrites = (key: string, value: unknown): Promise<unknown> =>
runWithContext({ actor: owner.actor }, () =>
env.server.drivers.kvStore.set({ key, value }),
);
const mint = (prefix = PREFIX) =>
events().mintKvHandle(owner.actor, {
granteeUsername: guest.username,
prefix,
});
/** A durable `single` row, which is the only kind that holds a backlog. */
const subscribeDurable = async (handle: string) =>
(
await events().subscribeDurable(guest.actor, {
subject: `kv:${handle}:*`,
delivery: 'single',
handlerName: 'onChange',
})
).sub;
const rowOf = async (subId: string) => {
const [row] = await env.server.clients.db.pread(
`SELECT \`suspended_reason\` FROM \`${TABLE}\` WHERE \`sub_id\` = ?`,
[subId],
);
return row as { suspended_reason: unknown } | undefined;
};
const suspendedRow = (subId: string) =>
vi.waitFor(
async () =>
expect((await rowOf(subId))?.suspended_reason).toBe(
'permission_revoked',
),
{ timeout: 5_000, interval: 25 },
);
/** Whether the shared region is still watched in its owner's keyspace. */
const ownerWatches = async (): Promise<boolean> => {
const watched = await env.server.stores.eventSubscription.watchedTokens(
owner.id,
[kvAnchorToken(owner.uuid, 'os-global', PREFIX)],
);
return watched.length > 0;
};
const clearRows = async () => {
await env.server.clients.db.write(`DELETE FROM \`${TABLE}\``, []);
for (const id of [owner.id, guest.id, stranger.id]) {
await events().reapSocket(id, SOCKET_ID);
events().invalidateUser(id);
await env.server.stores.eventSubscription.markRegionCold(id);
await env.server.stores.durableSubscription.warmRegion(id);
}
delivered.length = 0;
};
beforeAll(async () => {
env = await setupPuterTestEnv({
events: { enabled: true, kvHandles: true },
// Seeded accounts carry no email, which the plan machinery reads as a
// temporary account — and a temporary account holds no durable rows.
unlimitedMetering: true,
} as IConfig);
const strangerName = `kv-onlooker-${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, {
username: strangerName,
password: 'pw-test-1234',
});
owner = await userFor(env.users.user.username);
guest = await userFor(env.users.other.username);
stranger = await userFor(strangerName);
delivered = [];
events().onDelivered = (envelope) => delivered.push(envelope);
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
await env?.shutdown();
});
describe('revoking a handle', () => {
it('settles the subscriptions it was holding up, and takes their backlog', async () => {
await clearRows();
const { handle } = await mint();
const sub = await subscribeDurable(handle);
await ownerWrites(`${PREFIX}messages:1`, { body: 'before' });
await vi.waitFor(
async () =>
expect(
await env.server.stores.pendingDelivery.depth(sub.subId),
).toBeGreaterThan(0),
{ timeout: 5_000, interval: 25 },
);
expect(await ownerWatches()).toBe(true);
await events().revokeKvHandle(owner.actor, handle);
await suspendedRow(sub.subId);
// The anchor leaves the *owner's* watched set, which is the keyspace a
// write looks in.
expect(await ownerWatches()).toBe(false);
// Purged, not held: the backlog names keys its holder has just lost the
// right to see.
expect(await env.server.stores.pendingDelivery.depth(sub.subId)).toBe(
0,
);
delivered.length = 0;
await ownerWrites(`${PREFIX}messages:2`, { body: 'after' });
await quiet();
expect(delivered).toEqual([]);
});
it('stops a session subscription through the delivery re-check', async () => {
await clearRows();
const { handle } = await mint();
const { sub } = await events().subscribe(guest.actor, SOCKET_ID, {
subject: `kv:${handle}:*`,
});
await ownerWrites(`${PREFIX}live:1`, 1);
await settled();
expect(delivered[0].subId).toBe(sub.subId);
await events().revokeKvHandle(owner.actor, handle);
delivered.length = 0;
await ownerWrites(`${PREFIX}live:2`, 2);
await quiet();
expect(delivered).toEqual([]);
});
it('finds its subscriptions on the holder index, not by scanning', async () => {
await clearRows();
const { handle } = await mint();
const sub = await subscribeDurable(handle);
const store = env.server.stores.durableSubscription;
const byHolder = vi.spyOn(store, 'listActiveForHolder');
const settle = vi.spyOn(events(), 'settleRevokedGrant');
try {
await events().revokeKvHandle(owner.actor, handle);
await suspendedRow(sub.subId);
// One announcement, one settle pass, one indexed read of the
// holder's rows — no lookup keyed on the handle anywhere.
expect(settle).toHaveBeenCalledTimes(1);
expect(byHolder).toHaveBeenCalledTimes(1);
expect(byHolder).toHaveBeenCalledWith(guest.id, null);
} finally {
byHolder.mockRestore();
settle.mockRestore();
}
});
it('withdraws the grant, deeper grants included', async () => {
await clearRows();
const { handle } = await mint();
const deeper = await mint(`${PREFIX}messages:`);
await events().revokeKvHandle(owner.actor, handle);
// The subtree goes with the region: a grant left underneath would keep
// access to part of what was just taken back.
await expect(
events().subscribe(guest.actor, SOCKET_ID, {
subject: `kv:${deeper.handle}:*`,
}),
).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' });
});
it('lets a fresh handle over the same region work, and leaves the old one dead', async () => {
await clearRows();
const first = await mint();
await events().revokeKvHandle(owner.actor, first.handle);
const second = await mint();
expect(second.handle).not.toBe(first.handle);
const { sub } = await events().subscribe(guest.actor, SOCKET_ID, {
subject: `kv:${second.handle}:*`,
});
delivered.length = 0;
await ownerWrites(`${PREFIX}messages:3`, { body: 'again' });
await settled();
expect(delivered[0].subId).toBe(sub.subId);
await expect(
events().subscribe(guest.actor, SOCKET_ID, {
subject: `kv:${first.handle}:*`,
}),
).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' });
});
it.each([
['a handle nobody minted', async () => `kvh-${uuidv4()}`],
['one this account did not mint', async () => (await mint()).handle],
])('answers %s as absent', async (label, handleFor) => {
const handle = await handleFor();
const actor =
label === 'one this account did not mint'
? stranger.actor
: owner.actor;
await expect(
events().revokeKvHandle(actor, handle),
).rejects.toMatchObject({
legacyCode: 'subject_does_not_exist',
});
});
it('is idempotent, and keeps the moment it first stopped', async () => {
// The grant comes down before the handle is stamped, so a failure
// between them leaves a retry to finish the job — which only works if
// reaching a retired handle again succeeds.
const { handle } = await mint();
const first = await events().revokeKvHandle(owner.actor, handle);
const again = await events().revokeKvHandle(owner.actor, handle);
expect(again).toEqual(first);
});
it('leaves nothing standing when the handle stamp fails', async () => {
// The half that stops delivery is the grant, so a failure after it
// must not leave access behind — and the owner must be able to retry.
await clearRows();
const { handle } = await mint();
await events().subscribe(guest.actor, SOCKET_ID, {
subject: `kv:${handle}:*`,
});
const store = env.server.stores.kvShareHandle;
const retire = store.retire.bind(store);
const failing = vi
.spyOn(store, 'retire')
.mockRejectedValueOnce(new Error('write failed'));
await expect(
events().revokeKvHandle(owner.actor, handle),
).rejects.toThrow('write failed');
failing.mockRestore();
// The grant is already gone, so nothing is delivered even though the
// handle still reads as live.
delivered.length = 0;
await ownerWrites(`${PREFIX}messages:9`, { body: 'nope' });
await quiet();
expect(delivered).toEqual([]);
// And the retry converges rather than answering "no such handle".
const revoked = await events().revokeKvHandle(owner.actor, handle);
expect(revoked.handle).toBe(handle);
expect(await retire(handle, owner.id)).toMatchObject({
revokedAt: revoked.revokedAt,
});
});
it('refuses an app acting for the owner', async () => {
const { handle } = await mint();
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/`, owner.id],
);
const app = await env.server.stores.app.getByUid(uid);
const appActor = makeActor({
user: owner.actor.user as never,
app: { uid, id: app!.id },
});
await expect(
events().revokeKvHandle(appActor, handle),
).rejects.toMatchObject({
legacyCode: 'events_kv_handle_owner_only',
});
});
});
describe('the owner`s handle listing', () => {
it('shows what was shared, retired handles included', async () => {
const { handle } = await mint();
await events().revokeKvHandle(owner.actor, handle);
const page = await events().listKvHandles(owner.actor, {
includeTotal: true,
});
const row = page.items.find((one) => one.handle === handle);
expect(row).toMatchObject({
prefix: PREFIX,
appUid: 'os-global',
granteeUsername: guest.username,
});
expect(row?.revokedAt).toBeTypeOf('number');
expect(page.total).toBe(page.items.length + (page.cursor ? 1 : 0));
});
it('shows the grantee nothing of the owner`s', async () => {
await mint();
const page = await events().listKvHandles(guest.actor);
expect(page.items).toEqual([]);
});
it('pages, and the cursor picks up where it left off', async () => {
await mint();
await mint();
const first = await events().listKvHandles(owner.actor, { limit: 1 });
expect(first.items).toHaveLength(1);
expect(first.cursor).toBeTypeOf('string');
const second = await events().listKvHandles(owner.actor, {
limit: 1,
cursor: first.cursor,
});
expect(second.items).toHaveLength(1);
expect(second.items[0].handle).not.toBe(first.items[0].handle);
});
});
describe('the handle routes over HTTP', () => {
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
it('mints, lists and revokes over the wire', async () => {
const minted = await call(
'POST',
'/events/kv-handles',
env.users.user.token,
{ granteeUsername: guest.username, prefix: 'over-http:' },
);
expect(minted.status).toBe(200);
const { handle, prefix } = (await minted.json()) as {
handle: string;
prefix: string;
};
expect(prefix).toBe('over-http:');
const listed = await call(
'GET',
'/events/kv-handles?limit=100',
env.users.user.token,
);
const page = (await listed.json()) as {
items: Array<{ handle: string }>;
};
expect(page.items.some((row) => row.handle === handle)).toBe(true);
const revoked = await call(
'DELETE',
`/events/kv-handles/${handle}`,
env.users.user.token,
);
expect(revoked.status).toBe(200);
await expect(revoked.json()).resolves.toMatchObject({ handle });
});
it('shows one account nothing of another`s, and revokes nothing of theirs', async () => {
const { handle } = await mint();
const listed = await call(
'GET',
'/events/kv-handles',
env.users.other.token,
);
const page = (await listed.json()) as { items: unknown[] };
expect(page.items).toEqual([]);
const revoked = await call(
'DELETE',
`/events/kv-handles/${handle}`,
env.users.other.token,
);
expect(revoked.status).toBe(404);
});
it('turns an anonymous caller away', async () => {
const res = await fetch(new URL('/events/kv-handles', env.apiOrigin), {
method: 'GET',
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
+9 -4
View File
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
import { PermissionUtil } from '../permission/permissionUtil.js';
import {
assertShareableAppUid,
assertShareablePermission,
assertShareablePrefix,
keyPrefixSegments,
kvShareGrantCovers,
@@ -136,10 +137,14 @@ describe('granted prefixes', () => {
);
});
it('refuses a prefix past the key size limit', () => {
expect(() => assertShareablePrefix('a'.repeat(1025))).toThrow(
HttpError,
);
it('refuses a region too deep to fit the grant column', () => {
const deep = kvSharePermission(OWNER, APP, 'a'.repeat(300));
expect(() => assertShareablePermission(deep)).toThrow(HttpError);
expect(
assertShareablePermission(
kvSharePermission(OWNER, APP, 'workspace:abc:'),
),
).toBeTypeOf('string');
});
it('keeps the trailing delimiter optional', () => {
+17 -10
View File
@@ -19,8 +19,10 @@
import { randomUUID } from 'node:crypto';
import { HttpError } from '../../core/http/HttpError.js';
import { MAX_KEY_BYTES } from '../../stores/systemKv/SystemKVStore.js';
import { MANAGE_PERM_PREFIX } from '../permission/consts.js';
import {
MANAGE_PERM_PREFIX,
PERMISSION_MAX_LEN,
} from '../permission/consts.js';
import type { PermissionImplicator } from '../permission/permissionUtil.js';
import { PermissionUtil } from '../permission/permissionUtil.js';
import {
@@ -53,9 +55,6 @@ import {
/** Root of the cross-user key-value share namespace. */
export const KV_SHARE_PERMISSION_PREFIX = 'kv-share';
/** Longest key prefix a handle may be granted on, matching the key limit. */
export const KV_SHARE_PREFIX_MAX_BYTES = MAX_KEY_BYTES;
/** Width of the `app_uid` column the handle row stores its namespace in. */
export const KV_SHARE_APP_UID_MAX_LENGTH = 40;
@@ -143,11 +142,6 @@ export const assertShareablePrefix = (keyPrefix: unknown): string => {
throw invalidPrefix('`prefix` must be a string');
if (keyPrefix.includes('*') || keyPrefix.includes('?'))
throw invalidPrefix('A share prefix is a key prefix, not a pattern');
if (Buffer.byteLength(keyPrefix, 'utf8') > KV_SHARE_PREFIX_MAX_BYTES)
throw invalidPrefix(
`A share prefix may not exceed ${KV_SHARE_PREFIX_MAX_BYTES} bytes`,
);
// Normalizing drops empty segments, so `a::b:` would silently become a
// grant on `a:b:` — a region other than the one asked for. Refused rather
// than rewritten; only the trailing delimiter is optional.
@@ -177,6 +171,19 @@ export const assertShareableAppUid = (appUid: string): string => {
return appUid;
};
/**
* The grant a mint would issue, refused when it would not fit the column every
* permission table declares. Key prefixes run to a kilobyte and permissions do
* not, so the real bound on how deep a region may be is this one.
*/
export const assertShareablePermission = (permission: string): string => {
if (permission.length > PERMISSION_MAX_LEN)
throw invalidPrefix(
`A share prefix must leave the grant under ${PERMISSION_MAX_LEN} characters`,
);
return permission;
};
// -- Permission rules -------------------------------------------------
/**
@@ -26,6 +26,7 @@ import { PuterService } from '../types';
import {
FLAT_PERM_WARM_TTL_SECONDS,
MANAGE_PERM_PREFIX,
PERMISSION_MAX_LEN,
PERMISSION_SCAN_CACHE_TTL_SECONDS,
} from './consts';
import {
@@ -46,12 +47,6 @@ import {
} from '../../data/hardcoded-permissions.js';
import { UserRow } from '../../stores/user/UserStore';
/**
* Width of the `permission` column in the permission tables, which every
* dialect declares as `varchar(255)`.
*/
const PERMISSION_MAX_LEN = 255;
// -- Types ------------------------------------------------------------
export interface ScanOptions {
@@ -1004,6 +999,80 @@ export class PermissionService extends PuterService {
return acting?.uid ? { appUid: acting.uid } : null;
}
/**
* Withdraw a grant and everything the holder was given beneath it.
*
* Prefix implication is what makes the subtree part necessary: a grant on a
* region answers every check inside it, so leaving a deeper grant standing
* would leave access to part of a region that has just been taken back.
* Scoped to this issuer's grants to this holder a region two people were
* given is two grants, and one being withdrawn is not the other's
* business.
*
* Returns the permissions removed. Each is announced separately, because
* each is a distinct thing something may have been standing on.
*/
async revokeUserUserPermissionSubtree(
actor: Actor,
username: string,
permission: string,
meta: GrantMeta = {},
): Promise<string[]> {
permission = await this.rewritePermission(permission);
const user = await this.stores.user.getByUsername(username);
if (!user)
throw new HttpError(404, `user_does_not_exist: ${username}`, {
legacyCode: 'subject_does_not_exist',
});
if (!actor.user?.id)
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
const issuerId = actor.user.id;
const isSelfRevoke = user.id === issuerId;
if (
!isSelfRevoke &&
!(await this.canManagePermission(actor, permission))
) {
throw new HttpError(403, `permission_denied: ${permission}`, {
legacyCode: 'permission_denied',
});
}
const removed =
await this.stores.permission.deleteUserUserPermSubtreeForHolder(
user.id,
issuerId,
permission,
);
for (const removedPermission of removed) {
this.stores.permission
.auditUserUserPerm({
holder_user_id: user.id,
issuer_user_id: issuerId,
permission: removedPermission,
action: 'revoke',
reason: meta.reason ?? 'revoked via PermissionService',
extra: this.#auditActorContext(actor),
})
.catch((err) => {
console.warn(
'[PermissionService] failed to audit user-user revoke:',
err,
);
});
}
// Before the announcement, so whatever settles on it re-derives from a
// counter that has already moved.
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
for (const removedPermission of removed)
this.#announceRevoked(user.id, null, removedPermission);
return removed;
}
/**
* Rewrite a permission on its way into (or out of) a user-app row.
*
@@ -20,6 +20,13 @@
export const MANAGE_PERM_PREFIX = 'manage';
export const PERM_KEY_PREFIX = 'perm';
/**
* Width of the `permission` column in the permission tables, which every
* dialect declares as `varchar(255)`. Anything building a permission string
* from user-supplied parts has to fit inside it.
*/
export const PERMISSION_MAX_LEN = 255;
/**
* De-facto placeholder permission for permission rewrites that do not grant any
* access.
@@ -18,6 +18,11 @@
*/
import { mintKvHandleId } from '../../services/events/kvShares.js';
import {
decodeCursor,
encodeCursor,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types.js';
/**
@@ -31,6 +36,9 @@ import { PuterStore } from '../types.js';
const TABLE = 'kv_share_handles';
export const KV_HANDLE_LIST_DEFAULT_LIMIT = 50;
export const KV_HANDLE_LIST_LIMIT_CAP = 200;
export interface KvShareHandle {
handle: string;
ownerUserId: number;
@@ -44,6 +52,12 @@ export interface KvShareHandle {
revokedAt: number | null;
}
export interface KvShareHandleListOptions {
limit?: number;
cursor?: string;
includeTotal?: boolean;
}
export interface MintKvShareHandleInput {
ownerUserId: number;
granteeUserId: number;
@@ -54,6 +68,11 @@ export interface MintKvShareHandleInput {
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
const asNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toRow = (row: Record<string, unknown>): KvShareHandle => ({
handle: String(row.handle),
ownerUserId: Number(row.owner_user_id),
@@ -116,4 +135,86 @@ export class KvShareHandleStore extends PuterStore {
);
return rows.length > 0 ? toRow(rows[0]) : null;
}
/**
* Retire one handle, scoped to its owner. Returns the row as it now stands,
* or `null` when this owner has no handle by that name the one answer an
* unknown handle and somebody else's both get, so retiring cannot be used
* to find out that a handle exists.
*
* Idempotent: an already-retired handle keeps the timestamp it has. The
* caller withdraws the grant first and that step can fail, so this one has
* to be safe to reach twice.
*
* The `revoked_at IS NULL` predicate makes the read-then-write a
* compare-and-set: two callers racing means one stamps the row and the
* other reports the stamp it found, rather than both writing.
*/
async retire(
handle: string,
ownerUserId: number,
): Promise<KvShareHandle | null> {
const existing = await this.getByHandle(handle);
if (!existing || existing.ownerUserId !== ownerUserId) return null;
if (existing.revokedAt !== null) return existing;
const at = nowSeconds();
const result = await this.clients.db.write(
`UPDATE \`${TABLE}\` SET \`revoked_at\` = ? ` +
'WHERE `handle` = ? AND `owner_user_id` = ? ' +
'AND `revoked_at` IS NULL',
[at, handle, ownerUserId],
);
if (result?.anyRowsAffected === false)
return await this.getByHandle(handle);
return { ...existing, revokedAt: at };
}
/**
* What one owner has minted, revoked handles included they are the record
* of what was shared and when it stopped. Keyset-paginated on `id`.
*/
async listForOwner(
ownerUserId: number,
options: KvShareHandleListOptions = {},
): Promise<PageResult<KvShareHandle>> {
const limit = Math.min(
Math.max(
1,
Math.floor(options.limit ?? KV_HANDLE_LIST_DEFAULT_LIMIT),
),
KV_HANDLE_LIST_LIMIT_CAP,
);
const after = asNumber(decodeCursor(options.cursor)?.id);
const where = ['`owner_user_id` = ?'];
const params: unknown[] = [ownerUserId];
if (after !== null) {
where.push('`id` > ?');
params.push(after);
}
const rows = await this.clients.db.read(
`SELECT \`id\`, ${SELECT_COLUMNS} FROM \`${TABLE}\` ` +
`WHERE ${where.join(' AND ')} ORDER BY \`id\` LIMIT ?`,
[...params, limit + 1],
);
const page = rows.slice(0, limit);
const result: PageResult<KvShareHandle> = { items: page.map(toRow) };
if (rows.length > limit)
result.cursor = encodeCursor({
id: Number(page[page.length - 1].id),
});
if (options.includeTotal) {
const [count] = await this.clients.db.read(
`SELECT COUNT(*) AS \`total\` FROM \`${TABLE}\` ` +
'WHERE `owner_user_id` = ?',
[ownerUserId],
);
result.total = Number(count?.total ?? 0);
}
return result;
}
}
@@ -977,6 +977,108 @@ describe('PermissionStore', () => {
expect(rows[0].issuer_user_id).toBe(issuerB.id);
});
it('takes a subtree from one holder without touching another`s', async () => {
const issuer = await makeUser();
const holder = await makeUser();
const other = await makeUser();
const region = 'kv-share:owner:app:workspace:abc';
for (const user of [holder, other]) {
await store.upsertUserUserPerm(user.id, issuer.id, region, {});
await store.upsertUserUserPerm(
user.id,
issuer.id,
`${region}:messages`,
{},
);
}
// A neighbour that merely shares the text prefix.
await store.upsertUserUserPerm(
holder.id,
issuer.id,
'kv-share:owner:app:workspace:abcdef',
{},
);
const removed = await store.deleteUserUserPermSubtreeForHolder(
holder.id,
issuer.id,
region,
);
expect(removed.sort()).toEqual([region, `${region}:messages`]);
expect(
(
await store.readLinkedUserUserPerms(holder.id, [
'kv-share:owner:app:workspace:abcdef',
])
).map((row) => row.permission),
).toEqual(['kv-share:owner:app:workspace:abcdef']);
// The same region granted to somebody else is a different grant.
expect(
await store.readLinkedUserUserPerms(other.id, [
region,
`${region}:messages`,
]),
).toHaveLength(2);
});
it('leaves another issuer`s subtree grant standing', async () => {
const issuerA = await makeUser();
const issuerB = await makeUser();
const holder = await makeUser();
const region = 'kv-share:owner:app:shared';
await store.upsertUserUserPerm(holder.id, issuerA.id, region, {});
await store.upsertUserUserPerm(holder.id, issuerB.id, region, {});
expect(
await store.deleteUserUserPermSubtreeForHolder(
holder.id,
issuerA.id,
region,
),
).toEqual([region]);
const rows = await store.readLinkedUserUserPerms(holder.id, [
region,
]);
expect(rows).toHaveLength(1);
expect(rows[0].issuer_user_id).toBe(issuerB.id);
});
it('treats LIKE wildcards in the holder-scoped subtree as literal text', async () => {
const issuer = await makeUser();
const holder = await makeUser();
// Same hazard as deleteAppGrantsByPermissionPrefix, at the
// holder-scoped delete this family also uses.
await store.upsertUserUserPerm(
holder.id,
issuer.id,
'kv-share:owner:app:a_c',
{},
);
await store.upsertUserUserPerm(
holder.id,
issuer.id,
'kv-share:owner:app:abc',
{},
);
const removed = await store.deleteUserUserPermSubtreeForHolder(
holder.id,
issuer.id,
'kv-share:owner:app:a_c',
);
expect(removed).toEqual(['kv-share:owner:app:a_c']);
expect(
await store.readLinkedUserUserPerms(holder.id, [
'kv-share:owner:app:abc',
]),
).toHaveLength(1);
});
it('reports whether the delete matched a row', async () => {
const issuer = await makeUser();
const holder = await makeUser();
@@ -116,6 +116,29 @@ export interface FlatPermRef {
permission: string;
}
/**
* Match a permission and everything beneath it.
*
* `_` and `%` are LIKE wildcards, so an unescaped one would widen the match
* beyond the intended subtree. `!` as the escape character, matching
* FSEntryStore: a backslash one would have to be written `ESCAPE '\\'` in the
* SQL text, and MySQL processes backslash escapes inside string literals, so
* the `'\'` a JS `'\\'` produces reads as an escaped quote and leaves the
* literal unterminated. SQLite and Postgres accept it, which is why only MySQL
* would have seen the parse error.
*/
const subtreeClause = (
permissions: string[],
): { where: string; params: string[] } => ({
where: permissions
.map(() => "(`permission` = ? OR `permission` LIKE ? ESCAPE '!')")
.join(' OR '),
params: permissions.flatMap((permission) => [
permission,
`${permission.replace(/([!%_])/g, '!$1')}:%`,
]),
});
/**
* PermissionStore owns the _persistence_ side of permissions:
*
@@ -474,14 +497,7 @@ export class PermissionStore extends PuterStore {
}>
> {
if (permissions.length === 0) return [];
// See deleteAppGrantsByPermissionPrefix for why `!` is the escape.
const where = permissions
.map(() => "(`permission` = ? OR `permission` LIKE ? ESCAPE '!')")
.join(' OR ');
const params = permissions.flatMap((permission) => [
permission,
`${permission.replace(/([!%_])/g, '!$1')}:%`,
]);
const { where, params } = subtreeClause(permissions);
const rows = (await this.clients.db.read(
'SELECT `holder_user_id`, `issuer_user_id`, `permission` FROM `user_to_user_permissions` ' +
@@ -517,6 +533,46 @@ export class PermissionStore extends PuterStore {
return rows;
}
/**
* The same subtree delete, narrowed to one issuer's grants to one holder.
*
* Withdrawing a share names a person, not a region: two people granted on
* the same prefix hold two independent grants, and taking one back must not
* take the other's with it. Returns the permissions removed, so the caller
* can audit them and announce each one.
*/
async deleteUserUserPermSubtreeForHolder(
holderUserId: number,
issuerUserId: number,
permission: string,
): Promise<string[]> {
const { where, params } = subtreeClause([permission]);
const scope = '`holder_user_id` = ? AND `issuer_user_id` = ?';
const scoped = [holderUserId, issuerUserId, ...params];
const rows = (await this.clients.db.read(
'SELECT `permission` FROM `user_to_user_permissions` ' +
`WHERE ${scope} AND (${where})`,
scoped,
)) as Array<{ permission: string }>;
if (rows.length === 0) return [];
await this.clients.db.write(
`DELETE FROM \`user_to_user_permissions\` WHERE ${scope} AND (${where})`,
scoped,
);
const removed = rows.map((row) => String(row.permission));
await this.delFlatUserPerms(
removed.map((perm) => ({ holderUserId, permission: perm })),
);
await this.publishCacheKeys({
keys: [this.#u2uCacheKey(holderUserId)],
broadcast: true,
});
return removed;
}
async auditUserUserPerm(
entry: AuditEntry & {
holder_user_id: number;
@@ -738,18 +794,7 @@ export class PermissionStore extends PuterStore {
permission: string;
}>
> {
// `_` and `%` are LIKE wildcards, so an unescaped one would widen the
// match beyond the intended subtree.
//
// `!` as the escape character, matching FSEntryStore: a backslash one
// would have to be written `ESCAPE '\\'` in the SQL text, and MySQL
// processes backslash escapes inside string literals, so the `'\'` a
// JS `'\\'` produces reads as an escaped quote and leaves the literal
// unterminated. SQLite and Postgres accept it, which is why only MySQL
// would have seen the parse error.
const escaped = permission.replace(/([!%_])/g, '!$1');
const exact = permission;
const prefix = `${escaped}:%`;
const { where, params } = subtreeClause([permission]);
const removed: Array<{
table: 'user_to_app_permissions' | 'dev_to_app_permissions';
@@ -764,8 +809,8 @@ export class PermissionStore extends PuterStore {
] as const) {
const rows = (await this.clients.db.read(
`SELECT \`user_id\`, \`app_id\`, \`permission\` FROM \`${table}\` ` +
"WHERE `permission` = ? OR `permission` LIKE ? ESCAPE '!'",
[exact, prefix],
`WHERE ${where}`,
params,
)) as Array<{
user_id: number;
app_id: number;
@@ -774,9 +819,8 @@ export class PermissionStore extends PuterStore {
if (rows.length === 0) continue;
await this.clients.db.write(
`DELETE FROM \`${table}\` ` +
"WHERE `permission` = ? OR `permission` LIKE ? ESCAPE '!'",
[exact, prefix],
`DELETE FROM \`${table}\` WHERE ${where}`,
params,
);
for (const row of rows) removed.push({ table, ...row });
}
+1 -1
View File
@@ -137,7 +137,7 @@ export interface RecursiveRecord<T> {
/** Namespace app component for an actor acting without an app. */
export const KV_GLOBAL_APP_KEY = 'os-global';
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${KV_GLOBAL_APP_KEY}`;
export const MAX_KEY_BYTES = 1024;
const MAX_KEY_BYTES = 1024;
/** Optimistic-concurrency counter every reserved-item write moves. */
const RESERVED_VERSION_ATTR = 'version';