feat: cross-user KV share grants and handles (PUT-1686) (#3690)

This commit is contained in:
Daniel Salazar
2026-09-03 19:43:27 -07:00
committed by GitHub
parent adde6c3a29
commit ac5446f877
25 changed files with 2087 additions and 56 deletions
@@ -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 ───────────────────────────────────────────────────────
+16 -8
View File
@@ -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
+15
View File
@@ -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',
);
});
});
+349 -16
View File
@@ -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,
+47
View File
@@ -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. */
+56 -4
View File
@@ -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();
});
});
+215
View File
@@ -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' },
+96 -21
View File
@@ -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;
}
}
+10 -2
View File
@@ -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. */
+4
View File
@@ -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;
+1 -1
View File
@@ -137,7 +137,7 @@ export interface RecursiveRecord<T> {
/** Namespace app component for an actor acting without an app. */
export const KV_GLOBAL_APP_KEY = 'os-global';
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${KV_GLOBAL_APP_KEY}`;
const MAX_KEY_BYTES = 1024;
export const MAX_KEY_BYTES = 1024;
/** Optimistic-concurrency counter every reserved-item write moves. */
const RESERVED_VERSION_ATTR = 'version';
+6
View File
@@ -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;
};
+2
View File
@@ -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 |