mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-25 06:36:00 +00:00
feat: cross-user KV share grants and handles (PUT-1686) (#3690)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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);
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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-<uuid>`, 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`);
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ?? {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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 -------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, KvShareHandle>;
|
||||
|
||||
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<string, { uid: string; id: number; metadata?: unknown } | null>;
|
||||
|
||||
@@ -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> = {},
|
||||
): 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T extends object> =
|
||||
| ({ 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<MintedKvHandle> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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:<handle>:<key>` 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<ResolvedAnchor> {
|
||||
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<P extends ProjectedEvent>(
|
||||
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<string, Promise<boolean>>();
|
||||
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<boolean> {
|
||||
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<DurableSubscription[]> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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:<handle>:<relativeKey>` 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. */
|
||||
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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<boolean> => {
|
||||
if (!deps.enabled) return false;
|
||||
try {
|
||||
return await deps.checkPermission(actor, permission);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// -- Cross-app KV ------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<TestUser> => {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
return {
|
||||
actor: makeActor({ user: row as never }),
|
||||
username,
|
||||
id: row!.id,
|
||||
uuid: row!.uuid as string,
|
||||
};
|
||||
};
|
||||
|
||||
/** `kv.set` as the owner makes it: through the driver, as themselves. */
|
||||
const ownerWrites = (key: string, value: unknown): Promise<unknown> =>
|
||||
runWithContext({ actor: owner.actor }, () =>
|
||||
env.server.drivers.kvStore.set({ key, value }),
|
||||
);
|
||||
|
||||
const mint = (
|
||||
request: Record<string, unknown> = {},
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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:<ownerUserUuid>:<appUid>:<keySegment>...
|
||||
*
|
||||
* 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;
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -31,6 +31,7 @@ import { PermissionUtil } from '../permission/permissionUtil.js';
|
||||
* kv:<appUid>:<key> exact key
|
||||
* kv:<appUid>:<prefix>* trailing `*` only
|
||||
* kv:<key> sugar for the caller's own app namespace
|
||||
* kv:<handle>:<relativeKey> a region of another user's namespace
|
||||
* notif:<appUid>:<audience> a mailbox slice
|
||||
* notif:<audience> 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-<uuid>`) 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 },
|
||||
|
||||
@@ -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<string, unknown>): 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:
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, unknown>): 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<KvShareHandle> {
|
||||
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<number> {
|
||||
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<KvShareHandle | null> {
|
||||
const rows = await this.clients.db.pread(
|
||||
`SELECT ${SELECT_COLUMNS} FROM \`${TABLE}\` WHERE \`handle\` = ?`,
|
||||
[handle],
|
||||
);
|
||||
return rows.length > 0 ? toRow(rows[0]) : null;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface RecursiveRecord<T> {
|
||||
/** Namespace app component for an actor acting without an app. */
|
||||
export const KV_GLOBAL_APP_KEY = 'os-global';
|
||||
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${KV_GLOBAL_APP_KEY}`;
|
||||
const MAX_KEY_BYTES = 1024;
|
||||
export const MAX_KEY_BYTES = 1024;
|
||||
|
||||
/** Optimistic-concurrency counter every reserved-item write moves. */
|
||||
const RESERVED_VERSION_ATTR = 'version';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user