Files
puter/src/backend/core/actor.ts
T
Daniel Salazar 589a98b12c feat(kv): share KV prefixes with other users at watch/read/write, and bound what apps can be granted (#4331)
* feat(kv): share key-value prefixes with other users, and bound what apps can be granted

- KV share handles move out of Events into KvShareService, served at /kv/shares
  (the /events/kv-handles routes stay as an alias).
- Shares have watch, read and write levels, encoded in the permission root
  (kv-share, kv-share-read, kv-share-write) with write implying read and watch.
- Every puter.kv method takes optConfig.handle; keys are relative to the shared
  prefix, cursors are sealed, and private entries stay hidden.
- New puter.kv.share, unshare, listShared and listSharedByMe; optional label;
  kv.share.received notice in the app's mailbox only; recipient blocks apply.
- puter.kv.set gains onlyIfAbsent.
- fs and kv events gain viaWorker, and kv events report self from the writer.
- /auth/grant-user-app only accepts canonical permissions from a fixed set of
  families; the consent dialog, puter.perms and docs follow.
- Migrations: kv_share_handles gains revoked_by, label, and grantee and app
  indexes (mysql_mig_49, postgres_mig_38, sqlite 0095).

* fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans

- Listings of another namespace (cross-app or through a handle) resume after
  the last entry they returned, and their cursors are sealed, bound to the
  namespace or handle, and padded to one length. Old plain cross-app cursors
  are still read when they name the target namespace; other namespaces get 400.
- PermissionStore scans flat permissions a few roots at a time and skips
  roots nested under another root in the same call.
2026-10-25 08:16:19 -07:00

249 lines
8.6 KiB
TypeScript

/*
* 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 { UserRow } from '../stores/user/UserStore';
import { Context } from './context';
import { HttpError } from './http/HttpError';
import { assertVerifiedEmail } from './http/verifiedEmail';
export interface ActorApp {
uid: string;
id?: number;
}
/**
* Set when the actor acts through an access token issued by `issuer`; the
* token's `access_token_permissions` rows gate which of the issuer's
* permissions it may exercise.
*/
export interface ActorAccessToken {
uid: string;
issuer: Actor;
authorized?: Actor | null;
/**
* Personal access token (signed `full_access` claim): full API reach, but
* still rejected by `requireUserActor`, so never account management.
*/
fullAccess?: boolean;
/**
* The privileged app a full-access token was launched for. Attribution
* only: it never sets `effectiveApp`, so the token keeps the account's
* reach while revokes, audits and gate hooks can still name the app.
*/
godmodeApp?: ActorApp | null;
}
export interface Actor {
user: Partial<UserRow>;
/**
* The app this actor carries _directly_, empty on an access token an app
* issued. Answers "is this an app acting as itself" — `isAppActor` is
* usually the clearer way to ask — and never "which app is acting", which
* reads as "no app" for a token and falls open.
*/
app?: ActorApp | null;
/**
* The app this actor ultimately acts as: its own `app`, else the app of its
* access token's issuer. Gates asking "which app" must read this, not
* `app`. `null` means resolved to no app; `undefined` means the actor
* skipped `makeActor` and must not be read as "no app".
*/
effectiveApp?: ActorApp | null;
/** True for the system actor; skips metering / quota tracking. */
system?: boolean;
accessToken?: ActorAccessToken | null;
/**
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
*/
session?: { uid: string; kind?: string | null } | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
* derived actors keep it.
*/
handlerDepth?: number;
/**
* When the token behind `handlerDepth` expires, in unix seconds. An app
* token minted from this actor expires no later.
*/
handlerExpiresAt?: number;
}
/** UUID of the baked-in system user (see 0025 seed migration). */
export const SYSTEM_ACTOR_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
/** The default system actor used when no actor is supplied. */
export const SYSTEM_ACTOR: Actor = {
user: { uuid: SYSTEM_ACTOR_UUID, username: 'system' },
effectiveApp: null,
system: true,
};
/**
* Build an actor with `effectiveApp` derived; the issuer is already collapsed,
* so one hop suffices.
*/
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
...actor,
effectiveApp: actor.app ?? actor.accessToken?.issuer.effectiveApp ?? null,
});
/**
* Fail closed on an actor that skipped `makeActor`, at the edge rather than
* inside a gate reading `undefined` as "no app".
*/
export const assertResolvedActor = (actor: Actor): Actor => {
if (actor.effectiveApp === undefined) {
throw new Error(
'actor was built without `makeActor`: `effectiveApp` is unresolved, ' +
'and app-scoped gates would read that as "no app"',
);
}
return actor;
};
/** An actor acting for a user, as most driver methods require. */
export type UserActor = Actor & {
user: { id: number; uuid: string; username: string };
};
const authenticationRequired = () =>
new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
/** The request's actor; 401 when the request carries none. */
export const requireContextActor = (): Actor => {
const actor = Context.get('actor');
if (!actor) throw authenticationRequired();
return actor;
};
/** The request's actor, which must act for a user; 401 otherwise. */
export const requireContextUserActor = (): UserActor => {
const actor = Context.get('actor');
if (!actor?.user?.id) throw authenticationRequired();
return actor as UserActor;
};
/**
* The driver-level twin of the HTTP `requireVerified` gate, so `/drivers/call`
* can't bypass it. Inert unless `strict_email_verification_required` is set:
* installs without SMTP must not be bricked.
*/
export const assertActorEmailVerified = (
actor: Actor,
config: { strict_email_verification_required?: unknown },
): void => {
assertVerifiedEmail(
Boolean(config.strict_email_verification_required),
actor.user,
400,
);
};
/**
* Whether `actor` owns a row: its user is the row's owner, and an app actor is
* also the app that created it. Both halves matter, since `app_owner` alone is
* shared by every user of that app.
*/
export const actorOwnsRow = (
actor: Actor,
row: { ownerUserId: unknown; appOwnerId: unknown },
): boolean => {
const app = actor.effectiveApp;
if (app?.id && app.id !== row.appOwnerId) return false;
return actor.user?.id === row.ownerUserId;
};
export const isSystemActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.system || actor?.user?.uuid === SYSTEM_ACTOR_UUID;
};
export const isAppActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.app && !isAccessTokenActor(actor);
};
/**
* The account acting as itself, through nothing: no app and no token of any
* kind. Narrower than `isAccountContext`, which also admits a full-access token
* — use this where the distinction is "a browser session" rather than "the
* account's own reach".
*/
export const isPlainUserActor = (
actor: Pick<Actor, 'app' | 'accessToken'> | undefined | null,
): boolean => !!actor && !actor.app && !actor.accessToken;
export const isAccessTokenActor = (
actor: Actor | undefined | null,
): boolean => {
return !!actor?.accessToken;
};
/**
* Whether the actor holds the account's own reach: a plain session or a
* full-access token. Use this, not `effectiveApp === null`, which would also
* admit scoped access tokens. Unresolved actors answer no.
*/
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
if (!actor || actor.effectiveApp !== null) return false;
return !actor.accessToken || actor.accessToken.fullAccess === true;
};
/**
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
* cache) and for cycle detection.
*/
export const actorUid = (actor: Actor): string => {
if (actor.accessToken) {
const authorizedUid = actor.accessToken.authorized
? actorUid(actor.accessToken.authorized)
: '<none>';
return `access-token:${actorUid(actor.accessToken.issuer)}:${authorizedUid}:${actor.accessToken.uid}`;
}
if (isSystemActor(actor)) return 'system';
if (actor.app) return `app-under-user:${actor.user.uuid}:${actor.app.uid}`;
return `user:${actor.user.uuid}`;
};
/**
* Return a user-only actor for any app-under-user actor. For non-app actors,
* returns the actor unchanged.
*/
export const userRelatedActor = (actor: Actor): Actor => {
if (!actor.app && !actor.accessToken) return actor;
return {
user: actor.user,
effectiveApp: null,
...(actor.handlerDepth ? { handlerDepth: actor.handlerDepth } : {}),
};
};
/** How many events handler runs led to a write by this actor. */
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
actor?.handlerDepth ?? 0;
/**
* Whether a write by this actor was made by server-side code running as the
* account: a worker session, or an events handler.
*/
export const viaWorkerOf = (actor: Actor | undefined | null): boolean =>
actor?.session?.kind === 'worker' || handlerDepthOf(actor) > 0;