diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 72eca95fe..d8f179269 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 75; +const CURRENT_SCHEMA_VERSION = 76; /** * These suites migrate real files on disk. Idle they finish in well under a diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 7dce5ff9a..5c5fa7a40 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -109,6 +109,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [72, ['0077_teams.sql']], [73, ['0078_jct-user-group-pair-unique.sql']], [74, ['0079_team-audit-and-group-shares.sql']], + [75, ['0080_kv-share-handles.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_35.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_35.sql new file mode 100644 index 000000000..28fcfed8b --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_35.sql @@ -0,0 +1,68 @@ +-- 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 . + +-- Opaque names for shared regions of a user's key-value namespace. See +-- sqlite/0080_kv-share-handles.sql for the column rationale. +-- +-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline, +-- as mig_28. There is no per-file applied-state tracking, so a replay has to +-- be a no-op. + +CREATE TABLE IF NOT EXISTS `kv_share_handles` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `handle` varchar(64) NOT NULL, + `owner_user_id` int unsigned NOT NULL, + `grantee_user_id` int unsigned NOT NULL, + -- Matches `apps`.`uid` exactly, charset included, so an equality against + -- one never falls back to a conversion. No foreign key: a handle outlives + -- the app it was minted against, which is what keeps it revocable. + `app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, + `key_prefix` varchar(1024) NOT NULL, + `permission` varchar(1024) NOT NULL, + `created_at` bigint NOT NULL, + `revoked_at` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_kv_share_handles_handle` (`handle`), + KEY `idx_kv_share_handles_owner` (`owner_user_id`, `id`), + CONSTRAINT `fk_kv_share_handles_owner` FOREIGN KEY (`owner_user_id`) + REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_kv_share_handles_grantee` FOREIGN KEY (`grantee_user_id`) + REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- A subscription made through a handle stores the grant string it was +-- authorized under rather than an access mode, and a grant string carries a +-- user uuid, an app uid and a key prefix. Guarded on the current length, as +-- mig_24: replaying a column change on a growing table rebuilds it every boot. + +DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission; +DELIMITER // +CREATE PROCEDURE _puter_widen_subscription_permission() +BEGIN + IF EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'event_subscriptions' + AND COLUMN_NAME = 'permission' + AND CHARACTER_MAXIMUM_LENGTH < 1024 + ) THEN + ALTER TABLE `event_subscriptions` MODIFY `permission` varchar(1024) NOT NULL; + END IF; +END // +DELIMITER ; +CALL _puter_widen_subscription_permission(); +DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_24.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_24.sql new file mode 100644 index 000000000..b30b0f42d --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_24.sql @@ -0,0 +1,47 @@ +-- 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 . + +-- Opaque names for shared regions of a user's key-value namespace. See +-- sqlite/0080_kv-share-handles.sql for the column rationale. +-- +-- Idempotent via IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS kv_share_handles ( + id BIGSERIAL PRIMARY KEY, + handle VARCHAR(64) NOT NULL, + owner_user_id INTEGER NOT NULL + REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE, + grantee_user_id INTEGER NOT NULL + REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE, + app_uid VARCHAR(40) NOT NULL, + key_prefix VARCHAR(1024) NOT NULL, + permission VARCHAR(1024) NOT NULL, + created_at BIGINT NOT NULL, + revoked_at BIGINT DEFAULT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_kv_share_handles_handle + ON kv_share_handles (handle); + +CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner + ON kv_share_handles (owner_user_id, id); + +-- A subscription made through a handle stores the grant string it was +-- authorized under rather than an access mode, and a grant string carries a +-- user uuid, an app uid and a key prefix. +ALTER TABLE event_subscriptions + ALTER COLUMN permission TYPE VARCHAR(1024); diff --git a/src/backend/clients/database/migrations/sqlite/0080_kv-share-handles.sql b/src/backend/clients/database/migrations/sqlite/0080_kv-share-handles.sql new file mode 100644 index 000000000..828de946b --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0080_kv-share-handles.sql @@ -0,0 +1,59 @@ +-- 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 . + +-- An opaque name for a region of one user's key-value namespace that somebody +-- else may watch. Key-value subjects carry no owner component, so without this +-- a shared key is unaddressable: the handle is what a filesystem node uid +-- already is, an owner-independent name for a shared root. +-- +-- - `handle` : `kvh-`, deliberately shaped unlike an app uid so +-- the subject parser can tell which slot it is in. It is +-- the only form the grantee ever sees, and it names +-- neither the owner's username nor their uuid. +-- - `permission` : the user-to-user grant this handle mirrors. Stored +-- rather than derived: it is the key revocation settling +-- matches subscriptions on, and deriving it would tie the +-- settle path to a lookup per row. +-- - `key_prefix` : the granted root, always ending on the key delimiter, +-- so `key_prefix + relative` is a whole key. +-- - `revoked_at` : set rather than deleted. A revoked handle stays visible +-- to its owner, which is the only record of what was +-- shared and when it stopped. +-- +-- `created_at` / `revoked_at` are unix seconds, matching `event_subscriptions`. + +CREATE TABLE IF NOT EXISTS `kv_share_handles` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "handle" TEXT NOT NULL, + "owner_user_id" INTEGER NOT NULL + REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "grantee_user_id" INTEGER NOT NULL + REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "app_uid" TEXT NOT NULL, + "key_prefix" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "created_at" INTEGER NOT NULL, -- unix seconds + "revoked_at" INTEGER DEFAULT NULL +); + +-- Resolving a subject is a lookup by handle, on the subscribe path. +CREATE UNIQUE INDEX IF NOT EXISTS `idx_kv_share_handles_handle` + ON `kv_share_handles` (`handle`); + +-- The owner's audit listing, and the scope check behind revoke. +CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner` + ON `kv_share_handles` (`owner_user_id`, `id`); diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index d8881bd31..ffd92854a 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -43,6 +43,7 @@ import { type TokenSource, } from '../../core/http/types.js'; import { PuterServer } from '../../server.js'; +import { kvSharePermission } from '../../services/events/kvShares.js'; import { FULL_API_ACCESS } from '../../services/permission/consts.js'; import { setupTestServer } from '../../testUtil.js'; import { resetCardVerificationStatusCache } from '../../util/cardFallback.js'; @@ -5243,6 +5244,60 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => }), ); }); + + it('list-permissions: never shows a holder the key-value share grant behind their handle', async () => { + const { user: owner, actor: ownerActor } = await makeUserAndActor(); + const { user: holder, actor: holderActor } = await makeUserAndActor(); + + // The grant a mint issues, seeded the same way: the owner implicator + // is what lets them issue it on their own namespace. + const permission = kvSharePermission( + owner.uuid as string, + 'os-global', + 'workspace:abc:', + ); + await inCtx(ownerActor, () => + server.services.permission.grantUserUserPermission( + ownerActor, + holder.username, + permission, + {}, + ), + ); + + const holderRes = makeRes(); + await controller.handleListPermissions( + makeReq({}, { actor: holderActor }), + holderRes, + ); + const holderBody = holderRes.body as { + user_to_myself: Array<{ user: string; permission: string }>; + }; + // Neither the grant nor the owner it names: the string carries their + // uuid and the granted prefix, and the handle is all the holder has. + expect(holderBody.user_to_myself).not.toContainEqual( + expect.objectContaining({ permission }), + ); + expect(JSON.stringify(holderBody.user_to_myself)).not.toContain( + owner.uuid, + ); + + // The owner's own side is untouched — it is their record of what they + // shared out. + const ownerRes = makeRes(); + await controller.handleListPermissions( + makeReq({}, { actor: ownerActor }), + ownerRes, + ); + expect( + (ownerRes.body as { myself_to_user: unknown[] }).myself_to_user, + ).toContainEqual( + expect.objectContaining({ + user: holder.username, + permission, + }), + ); + }); }); // ── Sessions ─────────────────────────────────────────────────────── diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index b13d1ec70..b90955cda 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -74,6 +74,7 @@ import { generateDefaultFsentries, promoteToVerifiedGroup, } from '../../util/userProvisioning.js'; +import { isKvSharePermission } from '../../services/events/kvShares.js'; import { APP_DATA_PERMISSION_PREFIX, appDataSharingAllowed, @@ -3579,14 +3580,21 @@ export class AuthController extends PuterController { ? JSON.parse(r.extra) : (r.extra ?? {}), })), - user_to_myself: (userPermsIn as Row[]).map((r) => ({ - user: r.username, - permission: r.permission, - extra: - typeof r.extra === 'string' - ? JSON.parse(r.extra) - : (r.extra ?? {}), - })), + // A key-value share grant names its owner's uuid and the exact + // key prefix it covers. The handle is the only name its holder is + // meant to have for that region, so the grant behind it is not + // part of what they are shown. The owner still sees their own side + // above, and in `GET /events/kv-handles`. + user_to_myself: (userPermsIn as Row[]) + .filter((r) => !isKvSharePermission(r.permission)) + .map((r) => ({ + user: r.username, + permission: r.permission, + extra: + typeof r.extra === 'string' + ? JSON.parse(r.extra) + : (r.extra ?? {}), + })), }); } diff --git a/src/backend/controllers/events/EventsController.ts b/src/backend/controllers/events/EventsController.ts index d6422e7cf..14a9cffb6 100644 --- a/src/backend/controllers/events/EventsController.ts +++ b/src/backend/controllers/events/EventsController.ts @@ -134,6 +134,28 @@ export class EventsController extends PuterController { res.json({}); } + // -- Cross-user key-value handles -------------------------------- + + /** + * POST /events/kv-handles — hand another user a watchable region of this + * account's key-value data. + * + * An account session only: minting on behalf of an app is delegation, and + * the service refuses an app-context actor rather than the gate doing it, + * because `effectiveApp` is where app-ness actually lives. + */ + @Post('/kv-handles', { + subdomain: 'api', + requireAuth: true, + allowAccessToken: true, + }) + async mintKvHandle(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + res.json( + await this.services.events.mintKvHandle(actor, this.#body(req)), + ); + } + // -- Handlers ---------------------------------------------------- // // Deploying an app's code, so the gate is the same as the verbs above plus diff --git a/src/backend/controllers/events/limits.ts b/src/backend/controllers/events/limits.ts index b1995b2ec..7f0631cbc 100644 --- a/src/backend/controllers/events/limits.ts +++ b/src/backend/controllers/events/limits.ts @@ -144,6 +144,21 @@ export const EVENTS_LIST_LIMIT = userWindow('events:list', 120); */ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600); +/** + * Share-handle mint + revoke calls per minute, per user. + * + * Each one issues or withdraws a grant and settles what stood on it, so it is + * budgeted with the subscribe verbs rather than the listings. + */ +export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60); + +/** + * Live share handles one account may hold out at a time. Each is a standing + * grant on part of the account's data, and revoking marks rather than deletes, + * so without a ceiling the rate limit alone lets the rows grow forever. + */ +export const EVENTS_KV_HANDLES_PER_USER = 200; + // -- Handler surface ------------------------------------------------- /** diff --git a/src/backend/services/events/EventsService.test.ts b/src/backend/services/events/EventsService.test.ts index e53faa78a..ef6107bc9 100644 --- a/src/backend/services/events/EventsService.test.ts +++ b/src/backend/services/events/EventsService.test.ts @@ -31,6 +31,7 @@ import { EventSubscriptionStore, type DurableSubscription, } from '../../stores/events/EventSubscriptionStore.js'; +import type { KvShareHandle } from '../../stores/events/KvShareHandleStore.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { UsageInput } from '../metering/types.js'; import type { IConfig } from '../../types.js'; @@ -44,8 +45,10 @@ import { type DeliveryEnvelope, type EventSocket, } from './EventsService.js'; +import { kvSharePermission } from './kvShares.js'; import { FILTER_EVALUATIONS_PER_EVENT } from './matcher.js'; import { SUBSCRIPTION_CACHE_TTL_MS } from './subscriptionCache.js'; +import { kvAnchorToken } from './subjects.js'; /** * The hot path is a cost claim before it is a behaviour claim, so the Redis @@ -184,6 +187,13 @@ const userStore = { }), }; +/** Share handles the kv resolver can be asked to resolve. */ +let handles: Map; + +const kvShareHandleStore = { + getByHandle: async (handle: string) => handles.get(handle) ?? null, +}; + /** Apps the cross-app gate can be asked about, and what they share. */ let apps: Map; @@ -201,6 +211,7 @@ const permissionService = () => ({ permissionChecks.push(permission); return grants.has(permission); }, + registerImplicator: () => undefined, }); /** @@ -266,6 +277,7 @@ const buildService = ( user: userStore, app: appStore, permission: permissionStore, + kvShareHandle: kvShareHandleStore, } as never, { eventForward: { @@ -429,6 +441,7 @@ beforeEach(() => { denied = new Map(); apps = new Map(); grants = new Set(); + handles = new Map(); permissionChecks = []; permissionGeneration = 1; hasCredits = true; @@ -1873,3 +1886,196 @@ describe('the cross-app kv gate', () => { expect(sent).toEqual([]); }); }); + +describe('cross-user kv handles', () => { + const PREFIX = 'workspace:abc:'; + let handle: string; + let guestId: number; + let permission: string; + + const handleService = () => + buildService({ + events: { enabled: true, kvHandles: true }, + } as IConfig); + + /** A live handle over the dispatching user's namespace, held by a guest. */ + const mintHandle = ( + overrides: Partial = {}, + ): KvShareHandle => { + const row: KvShareHandle = { + handle, + ownerUserId: userId, + granteeUserId: guestId, + appUid: OWN_APP, + keyPrefix: PREFIX, + permission, + createdAt: 0, + revokedAt: null, + ...overrides, + }; + handles.set(row.handle, row); + return row; + }; + + const subscribeAsGuest = (subject: string, on = service) => + on.subscribe(actorFor(guestId), socketId, { subject }); + + beforeEach(() => { + handle = `kvh-${userId}`; + guestId = userId + 500; + permission = kvSharePermission(`user-${userId}`, OWN_APP, PREFIX); + ({ service, sent, delivered } = { + ...handleService(), + delivered: [], + } as never); + grants.add(permission); + }); + + it('is off unless the config turns it on', async () => { + mintHandle(); + const { service: off } = buildService({ + events: { enabled: true }, + } as IConfig); + expect(off.kvHandlesEnabled).toBe(false); + expect(service.kvHandlesEnabled).toBe(true); + + await expect( + subscribeAsGuest(`kv:${handle}:*`, off), + ).rejects.toSatisfy( + (err: unknown) => + isHttpError(err) && + err.legacyCode === 'events_kv_handles_disabled', + ); + }); + + it('anchors where the owner`s own equivalent subject would', async () => { + mintHandle(); + const guest = (await subscribeAsGuest(`kv:${handle}:*`)).sub; + const owner = await subscribeKv(`kv:${OWN_APP}:${PREFIX}*`); + + // The socket set is keyed by the holder, and these two are different + // people watching one anchor. + const [guestRow] = await store.listForSocket(guestId, socketId); + const [ownerRow] = await store.listForSocket(userId, socketId); + expect(guestRow?.subId).toBe(guest.subId); + expect(ownerRow?.subId).toBe(owner.subId); + + expect(guestRow?.token).toBe(ownerRow?.token); + expect(guestRow?.token).toBe( + kvAnchorToken(`user-${userId}`, OWN_APP, PREFIX), + ); + // Keyed on the owner, because that is all a write knows about itself. + expect(guestRow?.ownerUserId).toBe(userId); + expect(guestRow?.holderUserId).toBe(guestId); + }); + + it('never hands the grantee the owner`s identity', async () => { + mintHandle(); + const { sub } = await subscribeAsGuest(`kv:${handle}:messages:*`); + + const wire = JSON.stringify(sub); + expect(sub.subject).toBe(`kv:${handle}:messages:*`); + expect(wire).not.toContain(`user-${userId}`); + expect(wire).not.toContain(`u${userId}`); + }); + + it('delivers every key under the granted region', async () => { + mintHandle(); + vi.useFakeTimers(); + const { sub } = await subscribeAsGuest(`kv:${handle}:*`); + + await dispatchKv([`${PREFIX}messages:1`, `${PREFIX}title`]); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + + expect(sent.map((out) => out.envelope.subId)).toEqual([ + sub.subId, + sub.subId, + ]); + // The write was the owner's, and the grantee is somebody else. + expect(sent[0].envelope.event).toMatchObject({ self: false }); + }); + + it('leaves a key outside the granted region alone', async () => { + mintHandle(); + vi.useFakeTimers(); + await subscribeAsGuest(`kv:${handle}:*`); + + await dispatchKv(['workspace:other:messages:1']); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + + expect(sent).toEqual([]); + }); + + it('narrows to a sub-region under the handle', async () => { + mintHandle(); + vi.useFakeTimers(); + await subscribeAsGuest(`kv:${handle}:messages:*`); + + await dispatchKv([`${PREFIX}title`]); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + expect(sent).toEqual([]); + + await dispatchKv([`${PREFIX}messages:1`]); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + expect(sent).toHaveLength(1); + }); + + it('asks once per subscription however many events arrive', async () => { + mintHandle(); + vi.useFakeTimers(); + await subscribeAsGuest(`kv:${handle}:*`); + permissionChecks.length = 0; + + for (let i = 0; i < 5; i++) { + await dispatchKv([`${PREFIX}messages:${i}`]); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + } + + expect(sent).toHaveLength(5); + // The handle is the granted root, so nothing under it can vary the + // answer — one evaluation covers the lot until a generation moves. + expect( + permissionChecks.filter((asked) => asked === permission), + ).toEqual([permission]); + }); + + it('stops delivering when the grant is gone', async () => { + mintHandle(); + vi.useFakeTimers(); + await subscribeAsGuest(`kv:${handle}:*`); + + grants.delete(permission); + permissionGeneration++; + await dispatchKv([`${PREFIX}messages:1`]); + await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1); + + expect(sent).toEqual([]); + }); + + it.each([ + ['an unknown handle', () => undefined], + ['a revoked handle', () => mintHandle({ revokedAt: 1 })], + ['a handle whose grant the caller does not hold', () => { + mintHandle(); + grants.delete(permission); + }], + ])('answers %s as absent', async (_case, setUp) => { + setUp(); + await expect(subscribeAsGuest(`kv:${handle}:*`)).rejects.toSatisfy( + (err: unknown) => + isHttpError(err) && + err.statusCode === 404 && + err.legacyCode === 'subject_does_not_exist', + ); + }); + + it('refuses a key that reads as leaving the region', async () => { + mintHandle(); + await expect( + subscribeAsGuest(`kv:${handle}:..:secrets`), + ).rejects.toSatisfy( + (err: unknown) => + isHttpError(err) && err.legacyCode === 'invalid_kv_handle_key', + ); + }); +}); diff --git a/src/backend/services/events/EventsService.ts b/src/backend/services/events/EventsService.ts index fcd31eb53..fcf284d1d 100644 --- a/src/backend/services/events/EventsService.ts +++ b/src/backend/services/events/EventsService.ts @@ -30,6 +30,8 @@ import { EVENTS_FETCH_LIMIT_DEFAULT, EVENTS_HANDLER_PUBLISH_BATCH, EVENTS_HANDLER_PUBLISH_LIMIT, + EVENTS_KV_HANDLE_LIMIT, + EVENTS_KV_HANDLES_PER_USER, EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT, EVENTS_SINGLE_DELIVERY_LIMIT, EVENTS_SUBSCRIBE_LIMIT, @@ -71,10 +73,14 @@ import { SESSION_TARGETS, isSubscriptionTarget, targetsAllowedForDelivery, + type SubscriptionPermission, type SubscriptionTarget, } from '../../stores/events/types.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; -import { parseKvNamespace } from '../../stores/systemKv/SystemKVStore.js'; +import { + KV_GLOBAL_APP_KEY, + parseKvNamespace, +} from '../../stores/systemKv/SystemKVStore.js'; import { decodeCursor, encodeCursor, @@ -97,6 +103,7 @@ import { PuterService } from '../types.js'; import { resolveFsAnchor, resolveKvAnchor, + resolveKvHandleAnchor, resolveNotifAnchor, type FsAnchorDeps, } from './anchors.js'; @@ -109,14 +116,18 @@ import { crossAppKvPermissions, deliveryGenerationTag, EVENTS_BACKGROUND_PERMISSION, + kvShareHandleDisabled, + kvSharedRegionAuthorized, needsBackgroundConsent, nodeDescriptor, resolveGrantActor, rowInActorScope, subscriptionTokenPermissions, SUBSCRIBE_MODE, + unknownKvShareHandle, type CrossAppKvDeps, type EventAclDeps, + type KvSharedRegionDeps, type SubscriptionGrant, } from './authorization.js'; import { DeliveryCoalescer } from './coalescer.js'; @@ -152,15 +163,25 @@ import { type MatchSpec, type NotifEventContext, type ProjectedEvent, + type ProjectedKvEvent, type ProjectedNotifEvent, type SubjectSpec, } from './registry.js'; import { SubscriptionCache } from './subscriptionCache.js'; +import { + assertShareableAppUid, + assertShareablePrefix, + kvShareGrantCovers, + kvShareOwnerImplicator, + kvSharePermission, + relativeToKvShareRoot, +} from './kvShares.js'; import { KV_MATCH_SEPARATOR, NOTIF_MATCH_SEPARATOR, fsAnchorToken, isKvToken, + kvHandleFromSubject, parseSubject, type FsOp, type ParsedSubject, @@ -327,6 +348,35 @@ export interface DurableSubscriptionView extends SubscriptionView { suspendedReason: string | null; } +/** Body of the handle-minting surface. The grantee is named either way. */ +export interface MintKvHandleRequest { + granteeUsername?: unknown; + granteeUid?: unknown; + appUid?: unknown; + prefix?: unknown; +} + +/** + * What minting returns. Only the handle and the region it covers: the owner + * already knows the rest, and the grantee is handed this verbatim, so anything + * else here would be something the handle exists to not say. + */ +export interface MintedKvHandle { + handle: string; + prefix: string; +} + +/** + * One handle as its owner sees it, revoked ones included — they are the record + * of what was shared and when it stopped. + */ +export interface KvShareHandleView extends MintedKvHandle { + appUid: string; + granteeUsername: string | null; + createdAt: number; + revokedAt: number | null; +} + export type VerbAck = | ({ ok: true } & T) | { ok: false; error: { code: string; message: string } }; @@ -413,7 +463,7 @@ interface ResolvedAnchor { match: string | null; op: FsOp | null; ownerUserId: number; - permission: AclMode; + permission: SubscriptionPermission; /** Fully-qualified wire form, which is what the row records. */ subject: string; } @@ -613,6 +663,16 @@ const handlerAppRequired = (): HttpError => * its user's ownership of some other app. Same answer for an app that is not * there: which apps exist is not this surface's to disclose. */ +/** + * Minting is the user disposing of a region of their own data. An app doing it + * on their behalf is delegation, which is a `manage:` grant's job and a + * separate consent. + */ +const handleOwnerOnly = (): HttpError => + new HttpError(403, 'Only an account session may mint a share handle', { + legacyCode: 'events_kv_handle_owner_only', + }); + const handlerAppForbidden = (): HttpError => new HttpError(403, 'Only the app owner may publish its handlers', { legacyCode: 'events_handler_forbidden', @@ -954,6 +1014,10 @@ export class EventsService extends PuterService { }); }); + // Owning a key-value namespace is holding every share grant over it, + // which is what lets its owner mint a handle on their own data. + this.services.permission.registerImplicator(kvShareOwnerImplicator()); + this.#armExpirySweep(); this.#armPendingSweep(); this.#armCreditSweep(); @@ -1005,6 +1069,16 @@ export class EventsService extends PuterService { return this.config.events?.crossAppKv === true; } + /** + * Whether one user may hand another a watchable region of their key-value + * namespace. Off by default, and read on the mint, the subscribe and the + * delivery re-check alike — turning it off stops rows already made rather + * than only new ones. + */ + get kvHandlesEnabled(): boolean { + return this.config.events?.kvHandles === true; + } + /** * Whether notification delivery runs through dispatch. Off, notifications * take the path they always have and nothing here sees them. @@ -1604,6 +1678,112 @@ export class EventsService extends PuterService { return { name, removed: removed !== null, suspended }; } + // -- Cross-user key-value handles -------------------------------- + + /** + * Hand another user a watchable region of this account's key-value data. + * + * Two writes, in this order: the grant, which is the authorization and + * carries its own `manage:` check and its own refusal to grant to yourself, + * and then the handle, which is only a name for it. A handle whose grant + * never landed would be a name for nothing; a grant whose handle never + * landed is unaddressable, since a handle is the only thing that can name + * this family. + */ + async mintKvHandle( + actor: Actor, + request: MintKvHandleRequest, + ): Promise { + if (!this.enabled) throw disabled(); + if (!this.kvHandlesEnabled) throw kvShareHandleDisabled(); + + const owner = actor.user; + if (!owner?.uuid || owner.id === undefined) throw disabled(); + // `undefined` is an app that could not be resolved, not the absence of + // one — reading it as an account session is what would hand an app the + // surface this refuses it. + if (actor.effectiveApp !== null) throw handleOwnerOnly(); + + await this.#spendHandleBudget(owner.id); + await this.#assertHandleCeiling(owner.id); + + const keyPrefix = assertShareablePrefix(request?.prefix); + const appUid = assertShareableAppUid( + parseAppUid(request?.appUid) ?? KV_GLOBAL_APP_KEY, + ); + const grantee = await this.#resolveGrantee(request); + + const permission = kvSharePermission(owner.uuid, appUid, keyPrefix); + await this.services.permission.grantUserUserPermission( + actor, + grantee.username, + permission, + {}, + { reason: 'kv share handle' }, + ); + + const row = await this.stores.kvShareHandle.mint({ + ownerUserId: owner.id, + granteeUserId: grantee.id, + appUid, + keyPrefix, + permission, + }); + return { handle: row.handle, prefix: row.keyPrefix }; + } + + /** Who a mint is for. Named by username or uuid; unknown reads as absent. */ + async #resolveGrantee( + request: MintKvHandleRequest, + ): Promise<{ id: number; username: string }> { + const username = + typeof request?.granteeUsername === 'string' + ? request.granteeUsername.trim() + : ''; + const uid = + typeof request?.granteeUid === 'string' + ? request.granteeUid.trim() + : ''; + if (!username && !uid) + throw badRequest( + 'Name the grantee with `granteeUsername` or `granteeUid`', + 'bad_request', + ); + + const user = username + ? await this.stores.user.getByUsername(username) + : await this.stores.user.getByUuid(uid); + if (!user?.username || user.id === undefined) + throw new HttpError(404, 'user_does_not_exist', { + legacyCode: 'subject_does_not_exist', + }); + return { id: user.id, username: user.username }; + } + + /** + * Whether this account may hold out another share handle. Retired ones do + * not count: the row stays as the record of what was shared, not as a + * slot. + */ + async #assertHandleCeiling(userId: number): Promise { + const live = await this.stores.kvShareHandle.countLiveForOwner(userId); + if (live >= EVENTS_KV_HANDLES_PER_USER) + throw new HttpError( + 409, + `An account may hold out ${EVENTS_KV_HANDLES_PER_USER} share handles at a time`, + { legacyCode: 'events_kv_handle_limit_reached' }, + ); + } + + async #spendHandleBudget(userId: number): Promise { + const ok = await checkRateLimit( + `${EVENTS_KV_HANDLE_LIMIT.scope}:${userId}`, + EVENTS_KV_HANDLE_LIMIT.limit, + EVENTS_KV_HANDLE_LIMIT.window, + ); + if (!ok) throw tooManyCalls(); + } + // -- Suspension -------------------------------------------------- /** @@ -2160,6 +2340,13 @@ export class EventsService extends PuterService { const user = actor.user; if (!user) throw disabled(); + if (parsed.anchorRef.kind === 'kvHandle') + return this.#resolveKvHandleSubscribe( + actor, + parsed, + parsed.anchorRef.handle, + ); + const anchor = resolveKvAnchor(parsed, { userUuid: user.uuid, appUid: actor.effectiveApp?.uid ?? null, @@ -2191,6 +2378,63 @@ export class EventsService extends PuterService { }; } + /** + * Resolve and authorize a `kv::` subject. + * + * The handle resolves to the region it was granted on and the key is + * composed onto it, so the row lands on exactly the anchor the owner's own + * subject would — same token, same keyspace, no dispatch change. Which + * keyspace that is matters: the row is indexed under the **owner**, because + * a write only ever knows whose namespace it touched. + * + * Authority is the grant, never the handle: an actor holding the mirrored + * permission may subscribe, and one who does not is told the handle is not + * there rather than that it is theirs to want. + */ + async #resolveKvHandleSubscribe( + actor: Actor, + parsed: ParsedSubject, + handle: string, + ): Promise { + if (!this.kvHandlesEnabled) throw kvShareHandleDisabled(); + + const share = await this.stores.kvShareHandle.getByHandle(handle); + if (!share || share.revokedAt !== null) + throw unknownKvShareHandle(handle); + + const held = await kvSharedRegionAuthorized( + actor, + share.permission, + this.#kvShareDeps(), + ); + if (!held) throw unknownKvShareHandle(handle); + + const owner = await this.stores.user.getById(share.ownerUserId); + if (!owner?.uuid) throw unknownKvShareHandle(handle); + + const anchor = resolveKvHandleAnchor(parsed, { + ownerUserUuid: owner.uuid, + appUid: share.appUid, + keyPrefix: share.keyPrefix, + }); + + if (anchor.match) + compileMatch(anchor.match, { separator: KV_MATCH_SEPARATOR }); + + return { + token: anchor.token, + uid: anchor.appUid, + path: anchor.prefix, + match: anchor.match, + op: null, + ownerUserId: share.ownerUserId, + // The grant string rather than a mode: it is what a revoke names, + // and what the delivery re-check asks again. + permission: share.permission, + subject: anchor.subject, + }; + } + /** * Resolve and authorize a `notif:` subject. * @@ -2525,13 +2769,16 @@ export class EventsService extends PuterService { let seq = 0; for (const row of matched) { - const event = subject.project({ - ...context, - self: - actingUserId === undefined || - actingUserId === row.holderUserId, - seq: seq++, - }); + const event = this.#asRowAddressesIt( + row, + subject.project({ + ...context, + self: + actingUserId === undefined || + actingUserId === row.holderUserId, + seq: seq++, + }), + ); // A `single` is owed rather than sent: it is queued, and never // coalesced or broadcast — collapsing two of them would drop one @@ -2577,6 +2824,30 @@ export class EventsService extends PuterService { ); } + /** + * One event named the way the row that receives it addresses things. Only a + * share-handle row differs: the projection names the owner's namespace and + * an absolute key, neither of which its holder can address or was told + * about, so both are re-based on the handle. Every other row is untouched. + */ + #asRowAddressesIt

( + row: DispatchSubscription, + event: P, + ): P { + // Asked of every delivery, so the families that can never answer are + // turned away on a token comparison rather than a subject parse. + if (!isKvToken(row.token)) return event; + const handle = kvHandleFromSubject(row.subject); + if (handle === null) return event; + + const key = relativeToKvShareRoot( + row.permission, + (event as ProjectedKvEvent).key, + ); + if (key === null) return event; + return { ...event, subject: `kv:${handle}:${key}`, key }; + } + /** Op filter first — a comparison, where the glob is not. */ #passes( row: DispatchSubscription, @@ -2659,7 +2930,7 @@ export class EventsService extends PuterService { if (!decision) { decision = checkDeliveryAuthorized( resolved.actor, - row.permission, + row.permission as AclMode, node, this.#aclDeps(), ); @@ -2692,6 +2963,11 @@ export class EventsService extends PuterService { const decisions = new Map>(); const allowed = await Promise.all( rows.map((row) => { + // A row on a shared region is authorized by its grant, not by + // whose namespace it names — and that is one question per + // subscription, because the handle *is* the granted root. + if (kvHandleFromSubject(row.subject) !== null) + return this.#kvShareHolds(row); if (!isCrossAppKvRow(row.appUid, targetAppUid)) return Promise.resolve(true); const key = `${row.holderUserId}|${row.appUid ?? ''}`; @@ -2706,6 +2982,35 @@ export class EventsService extends PuterService { return rows.filter((_row, i) => allowed[i]); } + /** + * Whether one row's holder may still watch the region it was made on. + * + * Held in the cross-event cache under the row's anchor, which for a shared + * region is the whole of what it can address: nothing above the handle is + * nameable, so the answer does not vary by key and one evaluation covers + * every event under it until a grant or a revoke moves the generation. + */ + async #kvShareHolds(row: DispatchSubscription): Promise { + const identity = await this.#grantIdentity(row); + if (!identity) return false; + + const key = { + subId: row.subId, + generation: identity.generation, + nodeUid: row.anchorUid, + }; + const cached = this.#deliveryAuth.read(key); + if (cached !== null) return cached; + + const allowed = await kvSharedRegionAuthorized( + identity.actor, + row.permission, + this.#kvShareDeps(), + ); + this.#deliveryAuth.write(key, allowed); + return allowed; + } + /** Whether one row's holder may still be told about `targetAppUid`'s data. */ async #kvGrantHolds( row: SubscriptionGrant, @@ -2905,19 +3210,39 @@ export class EventsService extends PuterService { ): Promise { const settling: DurableSubscription[] = []; for (const row of rows) { - const covered = isKvToken(row.token) - ? crossAppKvPermissions(row.anchorUid).includes(permission) - : this.services.acl - .permissionsFor(row.anchorUid, row.permission) - .includes(permission); + const covered = this.#coveredByRevocation(row, permission); if (covered && !(await this.#anchorStillReachable(row))) settling.push(row); } return settling; } + /** + * Whether one withdrawn grant is one of the ones a row was standing on. + * Narrowing only — the question is asked again for real below. + * + * A shared key-value region is the exact string match plus prefix + * implication, which is what makes handle to subscriptions a lookup over + * rows the holder index already returned rather than a scan for the + * handle. + */ + #coveredByRevocation( + row: DurableSubscription, + permission: string, + ): boolean { + if (kvHandleFromSubject(row.subject) !== null) + return kvShareGrantCovers(permission, row.permission); + if (isKvToken(row.token)) + return crossAppKvPermissions(row.anchorUid).includes(permission); + return this.services.acl + .permissionsFor(row.anchorUid, row.permission as AclMode) + .includes(permission); + } + /** Whether a row's holder can still reach its anchor, asked fresh. */ async #anchorStillReachable(row: DurableSubscription): Promise { + if (kvHandleFromSubject(row.subject) !== null) + return this.#kvShareHolds(row); if (isKvToken(row.token)) { if (!isCrossAppKvRow(row.appUid, row.anchorUid)) return true; return this.#kvGrantHolds(row, row.anchorUid); @@ -2938,7 +3263,7 @@ export class EventsService extends PuterService { if (!actor) return false; return checkDeliveryAuthorized( actor, - row.permission, + row.permission as AclMode, nodeDescriptor({ uid: at.anchorUid, path: at.anchorPath }, deps), deps, ); @@ -3851,6 +4176,14 @@ export class EventsService extends PuterService { }; } + #kvShareDeps(): KvSharedRegionDeps { + return { + enabled: this.kvHandlesEnabled, + checkPermission: (actor, permission) => + this.services.permission.check(actor, permission), + }; + } + #kvDeps(): CrossAppKvDeps { return { enabled: this.crossAppKvEnabled, diff --git a/src/backend/services/events/anchors.ts b/src/backend/services/events/anchors.ts index 580695d3b..931742cba 100644 --- a/src/backend/services/events/anchors.ts +++ b/src/backend/services/events/anchors.ts @@ -25,6 +25,7 @@ import { expandTildePath, normalizeAbsolutePath } from '../fs/resolveNode.js'; import { relativeTo } from './matcher.js'; import { fsAnchorToken, + kvAnchorFor, kvAnchorToken, notifAnchorToken, notifMatchOn, @@ -188,6 +189,52 @@ export function resolveKvAnchor( }; } +/** The shared region a handle resolves to, as the resolver needs it. */ +export interface KvShareRegion { + ownerUserUuid: string; + appUid: string; + /** The granted root, ending on the key delimiter. */ + keyPrefix: string; +} + +/** + * Resolve a `kv::` subject against the region the handle + * was granted on. + * + * The key is composed onto the granted prefix and then anchored by exactly the + * path an owner's own subject takes, which is what makes the two produce the + * same token: the handle is an address for a region, not a second kind of + * subscription. Nothing here can reach above the region — the composition is a + * concatenation onto the prefix, so being at-or-below the grant is structural + * rather than checked. + */ +export function resolveKvHandleAnchor( + parsed: ParsedSubject, + region: KvShareRegion, +): ResolvedKvAnchor { + const { anchorRef } = parsed; + if (anchorRef.kind !== 'kvHandle') + throw new HttpError(400, 'Not a key-value handle subject', { + legacyCode: 'invalid_subject', + }); + + const { prefix, rawMatch } = kvAnchorFor( + `${region.keyPrefix}${anchorRef.key}`, + ); + + return { + token: kvAnchorToken(region.ownerUserUuid, region.appUid, prefix), + appUid: region.appUid, + prefix, + match: rawMatch, + // The wire form stays the one the grantee wrote: it names the handle, + // and the handle is all they are ever told about where the data lives. + subject: `kv:${anchorRef.handle}:${anchorRef.key}`, + // The gate is the share grant, not the cross-app one. + crossApp: false, + }; +} + /** Who a `notif:` subject is resolved on behalf of. */ export interface NotifAnchorActor { /** The recipient — you only ever read your own mailbox. */ diff --git a/src/backend/services/events/authorization.ts b/src/backend/services/events/authorization.ts index b58484b6b..553cc2041 100644 --- a/src/backend/services/events/authorization.ts +++ b/src/backend/services/events/authorization.ts @@ -20,7 +20,10 @@ import { actorUid, makeActor, type Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import type { UserRow } from '../../stores/user/UserStore.js'; -import type { SubscriptionTarget } from '../../stores/events/types.js'; +import type { + SubscriptionPermission, + SubscriptionTarget, +} from '../../stores/events/types.js'; import type { AclError, AclMode, @@ -31,7 +34,7 @@ import { appDataSharingAllowed, } from '../permission/appDataScopes.js'; import { PermissionUtil } from '../permission/permissionUtil.js'; -import { isKvToken } from './subjects.js'; +import { isKvToken, kvHandleFromSubject } from './subjects.js'; /** * Who may subscribe to an anchor, who may still be delivered from it, and which @@ -65,7 +68,7 @@ export interface AuthorizedNode { export interface SubscriptionGrant { holderUserId: number; appUid: string | null; - permission: AclMode; + permission: SubscriptionPermission; } export interface EventAclDeps { @@ -240,16 +243,65 @@ export const backgroundConsentRequired = (): HttpError => */ export const subscriptionTokenPermissions = (row: { token: string; + subject: string; anchorUid: string; appUid: string | null; - permission: AclMode; + permission: SubscriptionPermission; }): string[] => { if (!isKvToken(row.token)) return [PermissionUtil.join('fs', row.anchorUid, row.permission)]; + // A row on a shared region reaches it through the share grant and nothing + // else, so that is the whole of what its token may carry. + if (kvHandleFromSubject(row.subject) !== null) return [row.permission]; if (row.appUid === null || row.appUid === row.anchorUid) return []; return [appDataPermission(row.anchorUid, 'kv', CROSS_APP_KV_CLASS)]; }; +// -- Cross-user KV ----------------------------------------------------- + +/** + * Whether this actor may still watch a shared key-value region. + * + * The permission row is the source of truth and the handle is an address for + * it: a subscription is authorized by holding the grant, never by being named + * on the handle. That is what makes a handle passed on to a delegate work the + * same way it does for the person it was minted for, and what makes revoking + * the grant the one thing that stops it. + */ +export interface KvSharedRegionDeps { + /** Whether share handles are available on this install at all. */ + enabled: boolean; + checkPermission: (actor: Actor, permission: string) => Promise; +} + +export const kvShareHandleDisabled = (): HttpError => + new HttpError(403, 'kv: share handles are not available', { + legacyCode: 'events_kv_handles_disabled', + }); + +/** + * A handle that is unknown, retired, or not the caller's to use reads the same + * way: absent. Distinguishing them would turn subscribe into a way to ask + * whether a handle exists and who holds it. + */ +export const unknownKvShareHandle = (handle: string): HttpError => + new HttpError(404, `No such handle: ${handle}`, { + legacyCode: 'subject_does_not_exist', + }); + +export const kvSharedRegionAuthorized = async ( + actor: Actor, + permission: string, + deps: KvSharedRegionDeps, +): Promise => { + if (!deps.enabled) return false; + try { + return await deps.checkPermission(actor, permission); + } catch { + return false; + } +}; + // -- Cross-app KV ------------------------------------------------------ /** diff --git a/src/backend/services/events/kvHandles.integration.test.ts b/src/backend/services/events/kvHandles.integration.test.ts new file mode 100644 index 000000000..aac35d66b --- /dev/null +++ b/src/backend/services/events/kvHandles.integration.test.ts @@ -0,0 +1,402 @@ +/* + * 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 . + */ + +/** + * Cross-user key-value shares against the real grant machinery. + * + * The unit suite drives the resolver with handles as data; this pins what only + * the wiring can get wrong — that minting issues a grant the permission service + * actually answers, that prefix implication reaches a deeper key without a rule + * of its own, and that a write through the key-value driver reaches somebody + * who is not the person who made it. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + EVENTS_COALESCE_WINDOW_MS, + EVENTS_KV_HANDLES_PER_USER, +} 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 { kvSharePermission } from './kvShares.js'; +import { isKvHandleId, kvAnchorToken } from './subjects.js'; + +const BOOT_TIMEOUT_MS = 120_000; +const SOCKET_ID = 'kv-handle-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 => { + 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, + }; +}; + +/** `kv.set` as the owner makes it: through the driver, as themselves. */ +const ownerWrites = (key: string, value: unknown): Promise => + runWithContext({ actor: owner.actor }, () => + env.server.drivers.kvStore.set({ key, value }), + ); + +const mint = ( + request: Record = {}, + actor: Actor = owner.actor, +) => + events().mintKvHandle(actor, { + granteeUsername: guest.username, + prefix: PREFIX, + ...request, + }); + +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-stranger-${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('minting a handle', () => { + it('returns an opaque name for the region and nothing else', async () => { + const minted = await mint(); + + expect(isKvHandleId(minted.handle)).toBe(true); + expect(minted).toEqual({ handle: minted.handle, prefix: PREFIX }); + const wire = JSON.stringify(minted); + expect(wire).not.toContain(owner.uuid); + expect(wire).not.toContain(owner.username); + }); + + it('issues a grant the permission service answers, prefix and all', async () => { + await mint(); + const permission = kvSharePermission(owner.uuid, 'os-global', PREFIX); + + await expect( + env.server.services.permission.check(guest.actor, permission), + ).resolves.toBe(true); + // Prefix implication: the grant on the region answers a key beneath it + // with no rule of its own. + await expect( + env.server.services.permission.check( + guest.actor, + kvSharePermission( + owner.uuid, + 'os-global', + `${PREFIX}messages:1`, + ), + ), + ).resolves.toBe(true); + await expect( + env.server.services.permission.check(stranger.actor, permission), + ).resolves.toBe(false); + }); + + it('never answers a parent check from a grant on its child', async () => { + // The mirror image of prefix implication: holding only the deeper + // grant must not satisfy a check on the shallower path it sits under. + // Disjoint from `PREFIX` (which other tests in this file grant + // broadly), so no earlier grant already covers the parent by nesting + // under it. + const parent = 'probe-parent-check:'; + await mint({ prefix: `${parent}child:` }); + + await expect( + env.server.services.permission.check( + guest.actor, + kvSharePermission(owner.uuid, 'os-global', parent), + ), + ).resolves.toBe(false); + }); + + it('refuses to share with yourself', async () => { + await expect( + mint({ granteeUsername: owner.username }), + ).rejects.toMatchObject({ legacyCode: 'bad_request' }); + }); + + it('refuses a grantee nobody has', async () => { + await expect( + mint({ granteeUsername: 'nobody-by-that-name' }), + ).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' }); + }); + + it('refuses the whole namespace', async () => { + await expect(mint({ prefix: '' })).rejects.toMatchObject({ + legacyCode: 'invalid_kv_share_prefix', + }); + }); + + it('refuses a namespace longer than the column that stores it', async () => { + await expect(mint({ appUid: 'a'.repeat(41) })).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('refuses a prefix with an empty key segment', async () => { + // Normalizing would grant `probe:gap:` — a region other than the one + // asked for. + await expect(mint({ prefix: 'probe::gap:' })).rejects.toMatchObject({ + legacyCode: 'invalid_kv_share_prefix', + }); + }); + + it('stops at the number of handles one account may hold out', async () => { + const seeded: string[] = []; + try { + const live = + await env.server.stores.kvShareHandle.countLiveForOwner( + owner.id, + ); + for (let i = live; i < EVENTS_KV_HANDLES_PER_USER; i++) { + const row = await env.server.stores.kvShareHandle.mint({ + ownerUserId: owner.id, + granteeUserId: guest.id, + appUid: 'os-global', + keyPrefix: `ceiling:${i}:`, + permission: kvSharePermission( + owner.uuid, + 'os-global', + `ceiling:${i}:`, + ), + }); + seeded.push(row.handle); + } + + await expect( + mint({ prefix: 'over:the:line:' }), + ).rejects.toMatchObject({ + legacyCode: 'events_kv_handle_limit_reached', + }); + + // Retiring one frees a slot: the ceiling counts live handles, not + // the audit trail. + await env.server.clients.db.write( + 'UPDATE `kv_share_handles` SET `revoked_at` = ? WHERE `handle` = ?', + [Math.floor(Date.now() / 1000), seeded[0]], + ); + await expect(mint({ prefix: 'over:the:line:' })).resolves.toEqual( + expect.objectContaining({ prefix: 'over:the:line:' }), + ); + } finally { + await env.server.clients.db.write( + 'DELETE FROM `kv_share_handles` WHERE `owner_user_id` = ?', + [owner.id], + ); + } + }); + + it('refuses an app minting on its user`s behalf', async () => { + 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(mint({}, appActor)).rejects.toMatchObject({ + legacyCode: 'events_kv_handle_owner_only', + }); + }); +}); + +describe('subscribing through a handle', () => { + it('anchors where the owner`s own subject would', async () => { + await clearRows(); + const { handle } = await mint(); + + const guestSub = ( + await events().subscribe(guest.actor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }) + ).sub; + const ownerSub = ( + await events().subscribe(owner.actor, SOCKET_ID, { + subject: `kv:os-global:${PREFIX}*`, + }) + ).sub; + + const [guestRow] = + await env.server.stores.eventSubscription.listForSocket( + guest.id, + SOCKET_ID, + ); + const [ownerRow] = + await env.server.stores.eventSubscription.listForSocket( + owner.id, + SOCKET_ID, + ); + + expect(guestRow.subId).toBe(guestSub.subId); + expect(ownerRow.subId).toBe(ownerSub.subId); + expect(guestRow.token).toBe(ownerRow.token); + expect(guestRow.token).toBe( + kvAnchorToken(owner.uuid, 'os-global', PREFIX), + ); + // Indexed under the owner: a write only knows whose namespace it hit. + expect(guestRow.ownerUserId).toBe(owner.id); + }); + + it('refuses a user the handle was not granted to', async () => { + await clearRows(); + const { handle } = await mint(); + await expect( + events().subscribe(stranger.actor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }), + ).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' }); + }); + + it('refuses a handle nobody minted', async () => { + await expect( + events().subscribe(guest.actor, SOCKET_ID, { + subject: `kv:kvh-${uuidv4()}:*`, + }), + ).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' }); + }); +}); + +describe('a write in the shared region', () => { + it('reaches the grantee`s session subscription', async () => { + await clearRows(); + const { handle } = await mint(); + const { sub } = await events().subscribe(guest.actor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }); + delivered.length = 0; + + await ownerWrites(`${PREFIX}messages:1`, { body: 'hello' }); + await settled(); + + expect(delivered).toHaveLength(1); + expect(delivered[0].subId).toBe(sub.subId); + // Named the way the grantee addresses it: the handle is the granted + // root, so the key is relative to it and the subject names the handle. + expect(delivered[0].event).toMatchObject({ + subject: `kv:${handle}:messages:1`, + key: 'messages:1', + op: 'set', + self: false, + }); + expect(JSON.stringify(delivered[0])).not.toContain(PREFIX); + expect(JSON.stringify(delivered[0])).not.toContain(owner.uuid); + }); + + it('reaches a durable subscription, key after key', async () => { + await clearRows(); + const { handle } = await mint(); + const { sub } = await events().subscribeDurable(guest.actor, { + subject: `kv:${handle}:*`, + }); + delivered.length = 0; + + await ownerWrites(`${PREFIX}messages:2`, { body: 'one' }); + await ownerWrites(`${PREFIX}title`, 'two'); + await settled(2); + + expect(delivered.map((one) => one.subId)).toEqual([ + sub.subId, + sub.subId, + ]); + }); + + it('stays inside the region the handle was granted on', async () => { + await clearRows(); + const { handle } = await mint(); + await events().subscribe(guest.actor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }); + delivered.length = 0; + + await ownerWrites('workspace:other:messages:1', { body: 'nope' }); + await quiet(); + + expect(delivered).toEqual([]); + }); +}); diff --git a/src/backend/services/events/kvShares.test.ts b/src/backend/services/events/kvShares.test.ts new file mode 100644 index 000000000..e0ac44505 --- /dev/null +++ b/src/backend/services/events/kvShares.test.ts @@ -0,0 +1,262 @@ +/* + * 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 . + */ + +import { describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import { PermissionUtil } from '../permission/permissionUtil.js'; +import { + assertShareableAppUid, + assertShareablePrefix, + keyPrefixSegments, + kvShareGrantCovers, + kvShareOwnerImplicator, + kvSharePermission, + mintKvHandleId, + normalizeKeyPrefix, + relativeToKvShareRoot, +} from './kvShares.js'; +import { isKvHandleId, kvHandleFromSubject } from './subjects.js'; + +const OWNER = '2a1b0c9d-0000-4000-8000-000000000001'; +const OTHER = '2a1b0c9d-0000-4000-8000-000000000002'; +const APP = 'app-1234'; + +const userActor = (uuid: string): Actor => + ({ user: { uuid }, effectiveApp: null }) as unknown as Actor; + +describe('the share permission family', () => { + it('names the owner, the app and the granted prefix', () => { + expect(kvSharePermission(OWNER, APP, 'workspace:abc:')).toBe( + `kv-share:${OWNER}:${APP}:workspace:abc`, + ); + }); + + it('makes key segments components, so a deeper key is a descendant', () => { + const granted = kvSharePermission(OWNER, APP, 'workspace:abc:'); + const deeper = kvSharePermission( + OWNER, + APP, + 'workspace:abc:messages:1', + ); + + // The exploder walks parents of the string being checked, so implication + // over key segments needs nothing of its own. + expect(deeper.startsWith(`${granted}:`)).toBe(true); + }); + + it('escapes a `:` inside a component rather than splitting on it', () => { + const permission = kvSharePermission(OWNER, 'app:odd', 'cart:'); + expect(PermissionUtil.split(permission)).toEqual([ + 'kv-share', + OWNER, + 'app:odd', + 'cart', + ]); + }); + + it('reads a withdrawn grant as covering itself and everything under it', () => { + const granted = kvSharePermission(OWNER, APP, 'workspace:abc:'); + expect(kvShareGrantCovers(granted, granted)).toBe(true); + expect( + kvShareGrantCovers( + granted, + kvSharePermission(OWNER, APP, 'workspace:abc:messages:'), + ), + ).toBe(true); + // A sibling that merely shares a text prefix is a different region. + expect( + kvShareGrantCovers( + granted, + kvSharePermission(OWNER, APP, 'workspace:abcdef:'), + ), + ).toBe(false); + expect( + kvShareGrantCovers( + granted, + kvSharePermission(OTHER, APP, 'workspace:abc:'), + ), + ).toBe(false); + }); + + it('never reads a withdrawn child as covering its own parent', () => { + // Revoking the deeper of two grants must not read as covering the + // shallower one — coverage only ever runs downward. + const child = kvSharePermission(OWNER, APP, 'workspace:abc:messages:'); + const parent = kvSharePermission(OWNER, APP, 'workspace:abc:'); + expect(kvShareGrantCovers(child, parent)).toBe(false); + }); +}); + +describe('granted prefixes', () => { + it('always ends on the key delimiter', () => { + expect(normalizeKeyPrefix('workspace:abc')).toBe('workspace:abc:'); + expect(normalizeKeyPrefix('workspace:abc:')).toBe('workspace:abc:'); + expect(keyPrefixSegments('workspace:abc:')).toEqual([ + 'workspace', + 'abc', + ]); + }); + + it.each([ + ['', 'the whole namespace'], + [':', 'delimiters only'], + ['workspace:*', 'a pattern'], + ['workspace:a?c', 'a single-character pattern'], + // Normalizing would drop the empty segment and grant `a:b:` instead — + // a region other than the one asked for. + ['workspace::abc:', 'an empty key segment'], + [':workspace:abc:', 'a leading empty segment'], + ])('refuses %s (%s)', (prefix) => { + let thrown: unknown; + try { + assertShareablePrefix(prefix); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(HttpError); + expect((thrown as HttpError).legacyCode).toBe( + 'invalid_kv_share_prefix', + ); + }); + + it('refuses a prefix past the key size limit', () => { + expect(() => assertShareablePrefix('a'.repeat(1025))).toThrow( + HttpError, + ); + }); + + it('keeps the trailing delimiter optional', () => { + expect(assertShareablePrefix('workspace:abc')).toBe('workspace:abc:'); + expect(assertShareablePrefix('workspace:abc:')).toBe('workspace:abc:'); + }); +}); + +describe('granted namespaces', () => { + it('takes an app uid, and the app-less namespace', () => { + expect(assertShareableAppUid(APP)).toBe(APP); + expect(assertShareableAppUid('os-global')).toBe('os-global'); + }); + + it('refuses one past the column it is stored in', () => { + // A longer one reaches the insert as a write the column cannot hold. + let thrown: unknown; + try { + assertShareableAppUid('a'.repeat(41)); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(HttpError); + expect((thrown as HttpError).statusCode).toBe(400); + }); + + it('refuses a handle, which shares the slot but is not a namespace', () => { + expect(() => assertShareableAppUid(mintKvHandleId())).toThrow( + HttpError, + ); + }); +}); + +describe('keys inside a granted region', () => { + const permission = kvSharePermission(OWNER, APP, 'workspace:abc:'); + + it('are named relative to the granted root', () => { + expect( + relativeToKvShareRoot(permission, 'workspace:abc:messages:1'), + ).toBe('messages:1'); + }); + + it('read as outside when they are', () => { + expect( + relativeToKvShareRoot(permission, 'workspace:abcdef:x'), + ).toBeNull(); + expect(relativeToKvShareRoot(permission, 'workspace:abc')).toBeNull(); + }); +}); + +describe('handle ids', () => { + it('are told apart from an app uid in the same slot', () => { + const handle = mintKvHandleId(); + expect(isKvHandleId(handle)).toBe(true); + expect(isKvHandleId(APP)).toBe(false); + expect(isKvHandleId('kvh-')).toBe(false); + }); + + it('carry nothing about the owner', () => { + const handle = mintKvHandleId(); + expect(handle).not.toContain(OWNER); + expect(mintKvHandleId()).not.toBe(handle); + }); + + it('are recoverable from the subject a row stored', () => { + const handle = mintKvHandleId(); + expect(kvHandleFromSubject(`kv:${handle}:messages:*`)).toBe(handle); + expect(kvHandleFromSubject(`kv:${APP}:messages:*`)).toBeNull(); + expect(kvHandleFromSubject(`fs:${handle}:write`)).toBeNull(); + }); +}); + +describe('the owner implicator', () => { + const implicator = kvShareOwnerImplicator(); + const permission = kvSharePermission(OWNER, APP, 'workspace:abc:'); + + it('answers the share family and its manage arm', () => { + expect(implicator.matches(permission)).toBe(true); + expect(implicator.matches(`manage:${permission}`)).toBe(true); + expect(implicator.matches(`fs:${OWNER}:read`)).toBe(false); + }); + + it('holds for the owner named in the permission, and nobody else', () => { + expect( + implicator.check({ actor: userActor(OWNER), permission }), + ).toEqual({}); + expect( + implicator.check({ + actor: userActor(OWNER), + permission: `manage:${permission}`, + }), + ).toEqual({}); + expect( + implicator.check({ actor: userActor(OTHER), permission }), + ).toBeUndefined(); + }); + + it('reads the owner past a `manage` key segment', () => { + // Stripping every `manage:` rather than the leading one would rewrite + // the string the owner is read out of. + const nested = kvSharePermission(OWNER, APP, 'manage:secrets:'); + expect(implicator.matches(nested)).toBe(true); + expect( + implicator.check({ actor: userActor(OWNER), permission: nested }), + ).toEqual({}); + expect( + implicator.check({ actor: userActor(OTHER), permission: nested }), + ).toBeUndefined(); + }); + + it('never answers for an app or a token acting through the owner', () => { + const appActor = { + user: { uuid: OWNER }, + app: { uid: APP }, + } as unknown as Actor; + expect( + implicator.check({ actor: appActor, permission }), + ).toBeUndefined(); + }); +}); diff --git a/src/backend/services/events/kvShares.ts b/src/backend/services/events/kvShares.ts new file mode 100644 index 000000000..8fc6530a6 --- /dev/null +++ b/src/backend/services/events/kvShares.ts @@ -0,0 +1,215 @@ +/* + * 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 . + */ + +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 type { PermissionImplicator } from '../permission/permissionUtil.js'; +import { PermissionUtil } from '../permission/permissionUtil.js'; +import { + isKvHandleId, + KV_HANDLE_PREFIX, + KV_KEY_SEPARATOR, +} from './subjects.js'; + +/** + * Letting one user watch a region of another's key-value namespace. + * + * The grant is an ordinary user-to-user permission, which is the whole point: + * `manage:` answers "may I delegate this", the exploder's prefix implication + * makes a grant on `workspace:abc:` answer every key beneath it, and revocation + * settling and the delivery re-check key off the stored string exactly as they + * do for a shared folder. Putting the ACL on the key-value item instead would + * take part in none of that, and would tax every write to the item besides. + * + * The permission names the owner, so its components are the three things a + * shared region is: + * + * kv-share:::... + * + * Key segments are components rather than one escaped blob because that is what + * makes prefix implication work without a rule of its own: the check a deep key + * runs is a descendant of the grant string, and the existing parent walk finds + * it. The grantee never sees this string — the handle is the name on the wire. + */ + +/** 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; + +export const mintKvHandleId = (): string => + `${KV_HANDLE_PREFIX}${randomUUID()}`; + +/** The key segments a prefix contributes to a permission string. */ +export const keyPrefixSegments = (keyPrefix: string): string[] => + keyPrefix.split(KV_KEY_SEPARATOR).filter((segment) => segment.length > 0); + +/** + * A granted prefix as it is stored: always ending on the key delimiter, so + * `keyPrefix + relative` is a whole key and the region never accidentally + * includes the key that names it. + */ +export const normalizeKeyPrefix = (keyPrefix: string): string => { + const segments = keyPrefixSegments(keyPrefix); + return segments.length === 0 + ? '' + : `${segments.join(KV_KEY_SEPARATOR)}${KV_KEY_SEPARATOR}`; +}; + +export const kvSharePermission = ( + ownerUserUuid: string, + appUid: string, + keyPrefix: string, +): string => + PermissionUtil.join( + KV_SHARE_PERMISSION_PREFIX, + ownerUserUuid, + appUid, + ...keyPrefixSegments(keyPrefix), + ); + +export const isKvSharePermission = (permission: string): boolean => + permission === KV_SHARE_PERMISSION_PREFIX || + permission.startsWith(`${KV_SHARE_PERMISSION_PREFIX}:`); + +/** + * Whether withdrawing `revoked` takes `held` with it. Prefix implication read + * backwards: a grant answers every check at or beneath it, so withdrawing it + * puts every one of those in question. + */ +export const kvShareGrantCovers = (revoked: string, held: string): boolean => + held === revoked || held.startsWith(`${revoked}:`); + +/** The granted root a share grant names, read back as a key prefix. */ +export const kvShareGrantPrefix = (permission: string): string => + normalizeKeyPrefix( + PermissionUtil.split(permission).slice(3).join(KV_KEY_SEPARATOR), + ); + +/** + * A key inside a granted region, named the way its holder addresses it, or + * `null` for one outside. The handle is the granted root, so everything the + * holder is shown is relative to it — an absolute key would name a namespace + * they cannot address and were never told about. + */ +export const relativeToKvShareRoot = ( + permission: string, + key: string, +): string | null => { + const prefix = kvShareGrantPrefix(permission); + if (!prefix || !key.startsWith(prefix)) return null; + return key.slice(prefix.length); +}; + +// -- Minting input ---------------------------------------------------- + +export const invalidPrefix = (message: string): HttpError => + new HttpError(400, message, { legacyCode: 'invalid_kv_share_prefix' }); + +export const invalidAppUid = (message: string): HttpError => + new HttpError(400, message, { legacyCode: 'bad_request' }); + +/** + * The region a handle may be minted on. A pattern is refused because a handle + * names a region rather than selecting within one, and the namespace root is + * refused because a handle over everything the app holds is not a bounded + * capability — it is the app's data, and sharing that is a different decision + * with a different consent. + */ +export const assertShareablePrefix = (keyPrefix: unknown): string => { + if (typeof keyPrefix !== '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. + const written = keyPrefix.split(KV_KEY_SEPARATOR); + if (written[written.length - 1] === '') written.pop(); + if (written.some((segment) => segment === '')) + throw invalidPrefix('A share prefix may not have an empty key segment'); + + const normalized = normalizeKeyPrefix(keyPrefix); + if (normalized === '') + throw invalidPrefix('A share prefix may not be the whole namespace'); + return normalized; +}; + +/** + * The namespace a handle may be minted over: the caller's own app slot, or the + * app-less one. Bounded by the column that stores it, and never handle-shaped, + * since the two share the app slot of a `kv:` subject. + */ +export const assertShareableAppUid = (appUid: string): string => { + if (appUid.length > KV_SHARE_APP_UID_MAX_LENGTH) + throw invalidAppUid( + `\`appUid\` may not exceed ${KV_SHARE_APP_UID_MAX_LENGTH} characters`, + ); + if (isKvHandleId(appUid)) + throw invalidAppUid('A share handle does not name a namespace'); + return appUid; +}; + +// -- Permission rules ------------------------------------------------- + +/** + * The permission a `manage:` arm delegates over. Only the leading component is + * stripped: a key segment may itself be `manage`, and taking those out would + * change which string the owner is read from. + */ +const withoutManageArm = (permission: string): string => + permission.startsWith(`${MANAGE_PERM_PREFIX}:`) + ? permission.slice(MANAGE_PERM_PREFIX.length + 1) + : permission; + +/** + * Owning the namespace is holding every share grant over it, and being able to + * issue them. Without this the owner cannot mint a handle on their own data: + * `grantUserUserPermission` asks `canManagePermission` first, and there is no + * row anywhere saying a user may manage what is already theirs. + * + * Restricted to plain user actors, as the filesystem's `is-owner` is. An app + * disposing of a region of its user's namespace is delegation, which is the + * `manage:` grant's job rather than this one's. + */ +export const kvShareOwnerImplicator = (): PermissionImplicator => ({ + id: 'kv-share-is-owner', + shortcut: true, + matches: (permission: string): boolean => + isKvSharePermission(withoutManageArm(permission)), + check: ({ actor, permission }): unknown => { + if (actor.app || actor.accessToken) return undefined; + const uuid = actor.user?.uuid; + if (!uuid) return undefined; + + const owner = PermissionUtil.split(withoutManageArm(permission))[1]; + return owner === uuid ? {} : undefined; + }, +}); diff --git a/src/backend/services/events/subjects.test.ts b/src/backend/services/events/subjects.test.ts index a0d06ea89..9c3c6c797 100644 --- a/src/backend/services/events/subjects.test.ts +++ b/src/backend/services/events/subjects.test.ts @@ -34,6 +34,7 @@ import { } from './subjects.js'; const APP = 'app-1234'; +const HANDLE = 'kvh-9f1c2d3e'; interface SubjectRow { subject: string; @@ -133,6 +134,24 @@ const ACCEPTED: SubjectRow[] = [ op: null, rawMatch: null, }, + { + subject: `kv:${HANDLE}:messages:*`, + anchorRef: { kind: 'kvHandle', handle: HANDLE, key: 'messages:*' }, + op: null, + rawMatch: null, + }, + { + subject: `kv:${HANDLE}:*`, + anchorRef: { kind: 'kvHandle', handle: HANDLE, key: '*' }, + op: null, + rawMatch: null, + }, + { + subject: `kv:${HANDLE}:messages:1`, + anchorRef: { kind: 'kvHandle', handle: HANDLE, key: 'messages:1' }, + op: null, + rawMatch: null, + }, { subject: 'notif:account', anchorRef: { kind: 'notifScope', ref: null, audience: 'account' }, @@ -164,6 +183,12 @@ const REJECTED: Array<{ subject: string; code: string }> = [ { subject: `kv:${APP}:ca*rt`, code: 'invalid_kv_pattern' }, { subject: `kv:${APP}:cart:*:items`, code: 'invalid_kv_pattern' }, { subject: `kv:${APP}:car?`, code: 'invalid_kv_pattern' }, + { subject: `kv:${HANDLE}:..:secrets`, code: 'invalid_kv_handle_key' }, + { subject: `kv:${HANDLE}:..`, code: 'invalid_kv_handle_key' }, + { subject: `kv:${HANDLE}::absolute`, code: 'invalid_kv_handle_key' }, + { subject: `kv:${HANDLE}`, code: 'invalid_kv_handle_key' }, + { subject: `kv:${HANDLE}:`, code: 'invalid_subject' }, + { subject: `kv:${HANDLE}:mes*ages`, code: 'invalid_kv_pattern' }, { subject: 'notif:', code: 'invalid_subject' }, { subject: `notif:${APP}:`, code: 'invalid_subject' }, { subject: 'notif:everyone', code: 'invalid_subject_audience' }, diff --git a/src/backend/services/events/subjects.ts b/src/backend/services/events/subjects.ts index 9f24d7e3c..86a3d4172 100644 --- a/src/backend/services/events/subjects.ts +++ b/src/backend/services/events/subjects.ts @@ -31,6 +31,7 @@ import { PermissionUtil } from '../permission/permissionUtil.js'; * kv:: exact key * kv::* trailing `*` only * kv: sugar for the caller's own app namespace + * kv:: a region of another user's namespace * notif:: a mailbox slice * notif: sugar for the caller's own app, or account * @@ -64,6 +65,13 @@ export type AnchorRef = /** The key pattern as written, which the canonical subject reuses. */ key: string; } + | { + kind: 'kvHandle'; + /** Opaque name of the shared region; resolved server-side. */ + handle: string; + /** Key pattern relative to the region the handle was granted on. */ + key: string; + } | { kind: 'notifScope'; /** @@ -124,6 +132,17 @@ const FS_TOKEN_PREFIX = 'f#'; const KV_TOKEN_PREFIX = 'k#'; const NOTIF_TOKEN_PREFIX = 'n#'; +/** + * What marks the app slot of a `kv:` subject as a share handle rather than an + * app. Deliberately unlike an app uid (`app-`) so the two can never be + * confused for one another in the same position. + */ +export const KV_HANDLE_PREFIX = 'kvh-'; + +export const isKvHandleId = (value: string): boolean => + value.startsWith(KV_HANDLE_PREFIX) && + value.length > KV_HANDLE_PREFIX.length; + /** Audiences a `notif:` subject may name, in wire form. */ export const NOTIF_AUDIENCES: readonly NotificationAudience[] = Object.freeze([ 'account', @@ -176,6 +195,18 @@ export const notifMatchOn = ( export const isKvToken = (token: string): boolean => token.startsWith(KV_TOKEN_PREFIX); +/** + * The handle a stored row was made through, or `null` for one on the holder's + * own namespace. Read off the subject rather than a column of its own: the + * subject is stored as the client wrote it, and a handle is the only thing that + * can sit in its app slot. + */ +export const kvHandleFromSubject = (subject: string): string | null => { + const parts = PermissionUtil.split(subject); + if (parts[0] !== 'kv' || parts.length < 3) return null; + return isKvHandleId(parts[1]) ? parts[1] : null; +}; + /** * The delimiter-aligned prefixes a key can be watched under, shallowest first * and capped at {@link KV_TOKEN_SEGMENT_CAP}. The empty prefix is the whole @@ -290,6 +321,57 @@ const parseFsSubject = (subject: string, parts: string[]): ParsedSubject => { }; }; +const assertKvPattern = (key: string): void => { + const starIndex = key.indexOf('*'); + if (key.includes('?') || (starIndex !== -1 && starIndex !== key.length - 1)) + throw new HttpError(400, 'KV subjects widen with a trailing `*` only', { + legacyCode: 'invalid_kv_pattern', + }); +}; + +/** + * The anchor a key pattern keys on, and the filter its members are tested by. A + * handle-rooted subject runs this over the key it composes, so a shared region + * resolves to exactly the anchor its owner's own subject would. + */ +export const kvAnchorFor = ( + key: string, +): { prefix: string; rawMatch: string | null } => { + const widened = key.endsWith('*'); + const literal = widened ? key.slice(0, -1) : key; + + // A `*` that doesn't land on a delimiter isn't enumerable from a key at + // write time, so the anchor backs off to the last delimiter and the whole + // pattern becomes the filter. + const onDelimiter = + !widened || literal.length === 0 || literal.endsWith(':'); + const prefix = onDelimiter + ? literal + : literal.slice(0, literal.lastIndexOf(':') + 1); + + const capped = capPrefix(prefix); + if (capped !== prefix) return { prefix: capped, rawMatch: key }; + return { prefix, rawMatch: onDelimiter ? null : key }; +}; + +const invalidHandleKey = (message: string): HttpError => + new HttpError(400, message, { legacyCode: 'invalid_kv_handle_key' }); + +/** + * A key under a handle is relative to the region the handle was granted on, so + * anything that reads as an attempt to leave it is refused rather than + * composed. Nothing here is reachable — the composition is a string + * concatenation onto the granted prefix — but a subject that means to escape is + * a subject written against the wrong model, and answering it is worse than + * failing it. + */ +const assertRelativeHandleKey = (key: string): void => { + if (key.startsWith(KV_KEY_SEPARATOR)) + throw invalidHandleKey('A key under a handle is relative to it'); + if (key.split(KV_KEY_SEPARATOR).includes('..')) + throw invalidHandleKey('A key under a handle may not name `..`'); +}; + const parseKvSubject = (subject: string, parts: string[]): ParsedSubject => { if (parts.length < 2) throw invalidSubject(subject); @@ -304,31 +386,24 @@ const parseKvSubject = (subject: string, parts: string[]): ParsedSubject => { const key = relative ? parts[1] : parts.slice(2).join(KV_KEY_SEPARATOR); if ((!relative && !appUid) || !key) throw invalidSubject(subject); - const starIndex = key.indexOf('*'); - if (key.includes('?') || (starIndex !== -1 && starIndex !== key.length - 1)) - throw new HttpError(400, 'KV subjects widen with a trailing `*` only', { - legacyCode: 'invalid_kv_pattern', - }); + if (relative && isKvHandleId(key)) + throw invalidHandleKey('A handle names a region, not a key'); - const widened = starIndex !== -1; - const literal = widened ? key.slice(0, -1) : key; + assertKvPattern(key); - // A `*` that doesn't land on a delimiter isn't enumerable from a key at - // write time, so the anchor backs off to the last delimiter and the whole - // pattern becomes the filter. - const onDelimiter = - !widened || literal.length === 0 || literal.endsWith(':'); - let prefix = onDelimiter - ? literal - : literal.slice(0, literal.lastIndexOf(':') + 1); - let rawMatch = onDelimiter ? null : key; - - const capped = capPrefix(prefix); - if (capped !== prefix) { - prefix = capped; - rawMatch = key; + if (appUid !== null && isKvHandleId(appUid)) { + assertRelativeHandleKey(key); + return { + family: 'kv', + anchorRef: { kind: 'kvHandle', handle: appUid, key }, + op: null, + // The anchor is composed once the handle resolves to a prefix, and + // the filter with it. + rawMatch: null, + }; } + const { prefix, rawMatch } = kvAnchorFor(key); return { family: 'kv', anchorRef: { kind: 'kvPrefix', appUid, prefix, key }, diff --git a/src/backend/stores/events/DurableSubscriptionStore.ts b/src/backend/stores/events/DurableSubscriptionStore.ts index 1da6c0868..2c653eec8 100644 --- a/src/backend/stores/events/DurableSubscriptionStore.ts +++ b/src/backend/stores/events/DurableSubscriptionStore.ts @@ -23,7 +23,6 @@ import { type SubscriptionQuota, } from '../../controllers/events/limits.js'; import { HttpError } from '../../core/http/HttpError.js'; -import type { AclMode } from '../../services/acl/ACLService.js'; import type { DeliveryClass } from '../../services/events/registry.js'; import type { FsOp } from '../../services/events/subjects.js'; import { @@ -38,6 +37,7 @@ import { SUBSCRIPTION_TARGETS, targetsAllowedForDelivery, type DurableSubscription, + type SubscriptionPermission, type SubscriptionTarget, } from './types.js'; @@ -93,7 +93,7 @@ export interface DurableSubscriptionInput { targets: SubscriptionTarget[]; handlerName: string | null; context: string | null; - permission: AclMode; + permission: SubscriptionPermission; expiresAt: number | null; /** * Plan-resolved caps this subscribe is held to. Omitted falls back to the @@ -233,7 +233,7 @@ const toRow = (row: Record): DurableSubscription => ({ row.app_uid === null || row.app_uid === undefined ? null : String(row.app_uid), - permission: String(row.permission) as AclMode, + permission: String(row.permission), delivery: String(row.delivery) as DeliveryClass, targets: parseTargets(row.targets), handlerName: diff --git a/src/backend/stores/events/KvShareHandleStore.ts b/src/backend/stores/events/KvShareHandleStore.ts new file mode 100644 index 000000000..a673610cb --- /dev/null +++ b/src/backend/stores/events/KvShareHandleStore.ts @@ -0,0 +1,119 @@ +/* + * 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 . + */ + +import { mintKvHandleId } from '../../services/events/kvShares.js'; +import { PuterStore } from '../types.js'; + +/** + * Opaque names for shared regions of a user's key-value namespace. + * + * A handle is an addressing alias, not an authorization: the user-to-user grant + * it mirrors is what any check actually reads. What the row adds is a name the + * grantee can use that says nothing about who the owner is, and a record the + * owner can audit — which is why revoking marks rather than deletes. + */ + +const TABLE = 'kv_share_handles'; + +export interface KvShareHandle { + handle: string; + ownerUserId: number; + granteeUserId: number; + appUid: string; + /** The granted root, ending on the key delimiter. */ + keyPrefix: string; + /** The user-to-user grant this handle mirrors. */ + permission: string; + createdAt: number; + revokedAt: number | null; +} + +export interface MintKvShareHandleInput { + ownerUserId: number; + granteeUserId: number; + appUid: string; + keyPrefix: string; + permission: string; +} + +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + +const toRow = (row: Record): KvShareHandle => ({ + handle: String(row.handle), + ownerUserId: Number(row.owner_user_id), + granteeUserId: Number(row.grantee_user_id), + appUid: String(row.app_uid), + keyPrefix: String(row.key_prefix), + permission: String(row.permission), + createdAt: Number(row.created_at) || 0, + revokedAt: row.revoked_at === null ? null : Number(row.revoked_at), +}); + +const SELECT_COLUMNS = + '`handle`, `owner_user_id`, `grantee_user_id`, `app_uid`, ' + + '`key_prefix`, `permission`, `created_at`, `revoked_at`'; + +export class KvShareHandleStore extends PuterStore { + async mint(input: MintKvShareHandleInput): Promise { + const row: KvShareHandle = { + handle: mintKvHandleId(), + ownerUserId: input.ownerUserId, + granteeUserId: input.granteeUserId, + appUid: input.appUid, + keyPrefix: input.keyPrefix, + permission: input.permission, + createdAt: nowSeconds(), + revokedAt: null, + }; + await this.clients.db.insert(TABLE, { + handle: row.handle, + owner_user_id: row.ownerUserId, + grantee_user_id: row.granteeUserId, + app_uid: row.appUid, + key_prefix: row.keyPrefix, + permission: row.permission, + created_at: row.createdAt, + revoked_at: null, + }); + return row; + } + + /** Live handles this owner is holding out, for the per-account ceiling. */ + async countLiveForOwner(ownerUserId: number): Promise { + const [row] = await this.clients.db.pread( + `SELECT COUNT(*) AS \`total\` FROM \`${TABLE}\` ` + + 'WHERE `owner_user_id` = ? AND `revoked_at` IS NULL', + [ownerUserId], + ); + return Number(row?.total ?? 0); + } + + /** + * One handle by name. Primary: this is what a subscribe resolves against, + * and a replica a moment behind would report a handle that was just minted + * as absent, or a revoked one as live. + */ + async getByHandle(handle: string): Promise { + const rows = await this.clients.db.pread( + `SELECT ${SELECT_COLUMNS} FROM \`${TABLE}\` WHERE \`handle\` = ?`, + [handle], + ); + return rows.length > 0 ? toRow(rows[0]) : null; + } +} diff --git a/src/backend/stores/events/types.ts b/src/backend/stores/events/types.ts index 1d2cc90e1..c0a585e69 100644 --- a/src/backend/stores/events/types.ts +++ b/src/backend/stores/events/types.ts @@ -67,6 +67,14 @@ export const targetsAllowedForDelivery = ( (!targets.includes('push') && (appUid === null || targets.includes('worker'))); +/** + * What a row's delivery re-check runs against. An access mode for a filesystem + * row, which composes with its anchor uid; the whole grant string for a row on + * a shared key-value region, where the grant is what a revoke names and there + * is no anchor to compose it with. + */ +export type SubscriptionPermission = AclMode | string; + /** What dispatch needs from a subscription, whichever store it came from. */ export interface DispatchSubscription { subId: string; @@ -94,8 +102,8 @@ export interface DispatchSubscription { 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; + /** What the subscribe check passed under; re-checked per delivery. */ + permission: SubscriptionPermission; /** Transports this row's deliveries may take. */ targets?: SubscriptionTarget[]; /** Session rows only: the connection a delivery is addressed at. */ diff --git a/src/backend/stores/index.ts b/src/backend/stores/index.ts index 237d65326..a8c9a2a47 100644 --- a/src/backend/stores/index.ts +++ b/src/backend/stores/index.ts @@ -24,6 +24,7 @@ import { GroupStore } from './group/GroupStore.js'; import { DurableSubscriptionStore } from './events/DurableSubscriptionStore.js'; import { EventHandlerStore } from './events/EventHandlerStore.js'; import { EventSubscriptionStore } from './events/EventSubscriptionStore.js'; +import { KvShareHandleStore } from './events/KvShareHandleStore.js'; import { PendingDeliveryStore } from './events/PendingDeliveryStore.js'; import { CreditHoldStore } from './metering/CreditHoldStore.js'; import { MeteringBufferStore } from './metering/MeteringBufferStore.js'; @@ -70,6 +71,7 @@ declare module './types.js' { eventSubscription: EventSubscriptionStore; durableSubscription: DurableSubscriptionStore; eventHandler: EventHandlerStore; + kvShareHandle: KvShareHandleStore; pendingDelivery: PendingDeliveryStore; presence: PresenceStore; } @@ -109,6 +111,8 @@ export const puterStores = { durableSubscription: DurableSubscriptionStore, // Table only, and reads the subscription table for its dependent counts. eventHandler: EventHandlerStore, + // Table only. + kvShareHandle: KvShareHandleStore, // Writes presence rows through `kv`'s reserved-item path, so it follows it. presence: PresenceStore, } satisfies IPuterStoreRegistry; diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index a19ed0f9d..84c4d8a28 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -137,7 +137,7 @@ export interface RecursiveRecord { /** 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}`; -const MAX_KEY_BYTES = 1024; +export const MAX_KEY_BYTES = 1024; /** Optimistic-concurrency counter every reserved-item write moves. */ const RESERVED_VERSION_ATTR = 'version'; diff --git a/src/backend/types.ts b/src/backend/types.ts index 1e5d37960..2373f71c1 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -1142,11 +1142,17 @@ interface IConfigOptional { * nowhere to dispatch from with the surface itself switched off. The * socket wire is identical either way — the flag decides which layer * produced the delivery, not what the desktop receives. + * - `kvHandles` — whether one user may hand another a watchable region of + * their key-value namespace. Absent means off: minting and subscribing + * through a handle both reject with `events_kv_handles_disabled`, and a + * handle row already made stops delivering. Nothing on the write path + * reads it. */ events?: { enabled?: boolean; crossAppKv?: boolean; notificationsFoldIn?: boolean; + kvHandles?: boolean; /** How long a handler has to answer an invocation. Default 30 s. */ invokeTimeoutMs?: number; }; diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 6f215bf7e..5c0e59d85 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -173,6 +173,8 @@ A temporary (anonymous) account cannot create durable subscriptions at all — ` | Subscriptions per connection | 50 | | `subscribe` / `unsubscribe` calls per minute | 60 | | Subscription listings per minute | 120 | +| Key-value share-handle calls per minute | 60 | +| Live key-value share handles per account | 200 | | Missed-event fetches per minute | 120 | | Events per fetch page | 200 | | Matched subscriptions per event | 50 |