Files
puter/src/backend/core/actor.ts
T
Juan Fernando CastroandDaniel Salazar d202be10a9 feat: let apps use another app's data with user consent (#3516)
* feat(perms): add cross-app app-data permission vocabulary

* feat(perms): sweep app grants by permission prefix

* feat(perms): resolve and withdraw cross-app data grants

* feat(kv): support an authorized namespace override and per-key privacy

* feat(kv): gate cross-app KV access behind app-data grants

* feat(fs): allow cross-app AppData access and require a scope to delete

* feat(auth): accept permission lists and gate app-data grants

* feat(perms): add requestAppData to the puter.js SDK

* feat(gui): carry permission lists through the IPC and popup transports

* feat(gui): describe cross-app data requests in the consent dialog

* docs: document requestAppData and per-entry KV privacy

* perf(perms): sweep cross-app grants only for origin-bootstrapped apps

* fix(gui): stop double-encoding cross-app consent text

* fix(perms): close three gaps in cross-app grant enforcement

* fix(kv): meter and batch the per-entry privacy probe

* fix(perms): resolve app identifiers and scopes more strictly in the SDK

* test(perms): cover the cross-app consent flow end to end

* fix: small missing token resolution for app

also adds the same exclusion for the batchPut api, small change

* fix: make resolved actor optional

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
2026-08-08 04:04:07 -07:00

163 lines
6.2 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';
export interface ActorApp {
uid: string;
id?: number;
}
/**
* Access-token wrapper. When set, this actor is acting _through_ an access
* token issued by `issuer`. The token's row in `access_token_permissions` gates
* which permissions of the issuer it can exercise.
*/
export interface ActorAccessToken {
uid: string;
issuer: Actor;
authorized?: Actor | null;
/**
* Full-API-access ("personal access token") flag, set from the signed
* `full_access` JWT claim. Such a token may exercise everything its issuing
* user can do via the API and is admitted past `requireNonAccessTokenGate`
* — but is still rejected by `requireUserActor` / web-session gates, so it
* can never manage the account. Normal (scoped) access tokens leave this
* false and remain blocked from non-`allowAccessToken` routes.
*/
fullAccess?: boolean;
}
export interface Actor {
user: Partial<UserRow>;
app?: ActorApp | null;
/**
* The app this actor ultimately acts as: its own `app`, or failing that the
* app of whoever issued its access token.
*
* Read this — not `app` — in any gate asking "which app is doing this?". An
* access-token actor carries no `app` of its own, so `app` alone reads as
* "no app" even for a token an app minted, and a gate keyed off it fails
* open exactly where it must not.
*
* `null` and `undefined` are not the same thing here:
*
* - `null` — resolved, and this actor is not acting as any app.
* - Absent — never resolved, because the actor skipped `makeActor`.
*
* A gate must not read the second as the first: that is the fail-open this
* field exists to prevent. Optional only so an actor literal that predates
* the field still compiles; `assertResolvedActor` is what keeps the request
* path honest, and `makeActor` is the one place the derivation lives.
*/
effectiveApp?: ActorApp | null;
/** True for the system actor; skips metering / quota tracking. */
system?: boolean;
accessToken?: ActorAccessToken | null;
/**
* Session reference when authenticated via a session token (user actors) or
* an app-under-user token that carries a session. Absent for system,
* raw-app, and pure access-token actors. Used for session introspection and
* targeted logout. `kind` mirrors the session row's kind (e.g. 'web',
* 'app', 'worker') so callers can gate on how the credential was minted
* without an extra session lookup.
*/
session?: { uid: string; kind?: string | null } | null;
}
/** 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, deriving `effectiveApp` from its own app and, failing that,
* from the app of whoever issued its access token.
*
* The issuer was itself built here, so its chain is already collapsed — one hop
* is enough, and no caller has to walk anything.
*/
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
...actor,
effectiveApp: actor.app ?? actor.accessToken?.issuer.effectiveApp ?? null,
});
/**
* Fail closed on an actor whose `effectiveApp` was never derived.
*
* Every actor on the request path is built by `AuthService` through
* `makeActor`, so this cannot fire in production — which is the point. It turns
* a future actor literal that skips the builder into a loud 500 at the edge
* rather than a silent bypass deep inside a gate that read `undefined` as "no
* app". Call it once, where the request actor is established.
*/
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;
};
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);
};
export const isAccessTokenActor = (
actor: Actor | undefined | null,
): boolean => {
return !!actor?.accessToken;
};
/**
* 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 };
};