mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 22:32:26 +00:00
refactor(drivers): shared actor guards and crud-q concurrency policy
The apps, subdomains, workers and notifications drivers each carried a private #requireActor, two carried the same #requireVerified, and apps and subdomains the same owner/app_owner write predicate. These become requireContextActor, requireContextUserActor, assertActorEmailVerified and actorOwnsRow in core/actor.ts; each driver keeps its own error for the cases that differed (system actors still read apps; notifications still answer 403 for an actor without a user). The identical crud-q concurrency block is one CRUD_CONCURRENT constant. The metering, installedApps and appTelemetry extensions re-checked an actor their routes already require; those checks and the tests for them go.
This commit is contained in:
1 parent
d0100a5c66
commit
5008da6fed
12 files changed
+293
-225
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
||||
import type {
|
||||
@@ -133,11 +132,17 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) throw new HttpError(401, 'Authentication required');
|
||||
const ownsApp = await this.services.permission
|
||||
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
// `/drivers/call` requires auth, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
let ownsApp = false;
|
||||
try {
|
||||
ownsApp = await this.services.permission.check(
|
||||
actor,
|
||||
`apps-of-user:${owner.uuid}:write`,
|
||||
);
|
||||
} catch {
|
||||
// A failed check is a denial.
|
||||
}
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const appId = (app as { id: number }).id;
|
||||
|
||||
@@ -74,15 +74,6 @@ const grantInstalled = async (appId: number, userId: number) => {
|
||||
};
|
||||
|
||||
describe('installedApps extension — handleInstalledApps', () => {
|
||||
it('throws HttpError(401) when no actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleInstalledApps(makeReq({}), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
|
||||
@@ -28,8 +28,8 @@ export const handleInstalledApps = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireUserActor`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
||||
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
||||
|
||||
@@ -63,15 +63,6 @@ const seedUser = async () => {
|
||||
};
|
||||
|
||||
describe('metering extension — handleMeteringUsage', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsage(makeReq(), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('returns usage details merged with allowanceInfo for an authenticated user', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -166,18 +157,6 @@ describe('metering extension — handleMeteringUsage', () => {
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringUsageForApp', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName: 'any' } }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when no appId is supplied', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
|
||||
@@ -95,8 +95,8 @@ export const handleMeteringUsage = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireAuth`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
const [actorUsage, allowanceInfo] = await Promise.all([
|
||||
services.metering.getActorCurrentMonthUsageDetails(actor),
|
||||
@@ -146,8 +146,8 @@ export const handleMeteringUsageForApp = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireAuth`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
let appId = String(req.params.appIdOrName ?? '');
|
||||
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {
|
||||
actorOwnsRow,
|
||||
assertActorEmailVerified,
|
||||
makeActor,
|
||||
requireContextActor,
|
||||
requireContextUserActor,
|
||||
SYSTEM_ACTOR,
|
||||
type Actor,
|
||||
} from './actor.js';
|
||||
import { runWithContext } from './context.js';
|
||||
|
||||
const user = { id: 7, uuid: 'u-7', username: 'dev', email_confirmed: true };
|
||||
const plain = makeActor({ user });
|
||||
const asApp = (appId: number) =>
|
||||
makeActor({ user, app: { id: appId, uid: `app-${appId}` } });
|
||||
|
||||
describe('requireContextActor', () => {
|
||||
it('returns the request actor and 401s without one', () => {
|
||||
expect(runWithContext({ actor: plain }, requireContextActor)).toBe(
|
||||
plain,
|
||||
);
|
||||
expect(() =>
|
||||
runWithContext({ actor: undefined }, requireContextActor),
|
||||
).toThrow(expect.objectContaining({ statusCode: 401 }));
|
||||
});
|
||||
|
||||
it('accepts an actor without a user id', () => {
|
||||
expect(
|
||||
runWithContext({ actor: SYSTEM_ACTOR }, requireContextActor),
|
||||
).toBe(SYSTEM_ACTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireContextUserActor', () => {
|
||||
it('returns an actor acting for a user', () => {
|
||||
expect(runWithContext({ actor: plain }, requireContextUserActor)).toBe(
|
||||
plain,
|
||||
);
|
||||
});
|
||||
|
||||
it('401s without an actor or without a user id', () => {
|
||||
for (const actor of [undefined, SYSTEM_ACTOR]) {
|
||||
expect(() =>
|
||||
runWithContext({ actor }, requireContextUserActor),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
statusCode: 401,
|
||||
legacyCode: 'unauthorized',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertActorEmailVerified', () => {
|
||||
const unverified: Actor = makeActor({
|
||||
user: { ...user, email_confirmed: false },
|
||||
});
|
||||
|
||||
it('is inert unless strict verification is configured', () => {
|
||||
expect(() => assertActorEmailVerified(unverified, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unverified actor with 400 under strict verification', () => {
|
||||
const strict = { strict_email_verification_required: true };
|
||||
expect(() => assertActorEmailVerified(plain, strict)).not.toThrow();
|
||||
expect(() => assertActorEmailVerified(unverified, strict)).toThrow(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'account_is_not_verified',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actorOwnsRow', () => {
|
||||
it('grants the owning user acting as themselves', () => {
|
||||
expect(actorOwnsRow(plain, { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actorOwnsRow(plain, { ownerUserId: 8, appOwnerId: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('grants an app actor only the owner’s rows that app created', () => {
|
||||
expect(actorOwnsRow(asApp(3), { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actorOwnsRow(asApp(4), { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(actorOwnsRow(asApp(3), { ownerUserId: 8, appOwnerId: 3 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -116,6 +119,60 @@ export const assertResolvedActor = (actor: Actor): Actor => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { actorOwnsRow, requireContextActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
WEB_AND_EXTENSION_PROTOCOLS,
|
||||
} from '../../util/validation.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
|
||||
|
||||
/**
|
||||
* Shared by every method that writes an app row. Each one also allocates or
|
||||
@@ -176,16 +177,7 @@ export class AppDriver extends PuterDriver {
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('../meta.js').DriverConcurrentConfig} */
|
||||
concurrent = {
|
||||
default: {
|
||||
limit: 20,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 10,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
concurrent = CRUD_CONCURRENT;
|
||||
|
||||
get appStore() {
|
||||
return this.stores.app;
|
||||
@@ -202,7 +194,7 @@ export class AppDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
const fields = await this.#validateInput(object, { isCreate: true });
|
||||
@@ -286,7 +278,7 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async read({ uid, id, params = {}, ...rest } = {}) {
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextActor();
|
||||
const app = await this.#resolve({ uid, id });
|
||||
if (!app)
|
||||
throw new HttpError(404, 'App not found', {
|
||||
@@ -331,7 +323,7 @@ export class AppDriver extends PuterDriver {
|
||||
*/
|
||||
async select(args = {}) {
|
||||
const { predicate, params = {} } = args;
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 500;
|
||||
@@ -411,7 +403,7 @@ export class AppDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
const app = await this.#resolve({ uid, id });
|
||||
@@ -497,7 +489,7 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async delete({ uid, id } = {}) {
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
const app = await this.#resolve({ uid, id });
|
||||
@@ -755,15 +747,6 @@ export class AppDriver extends PuterDriver {
|
||||
|
||||
// -- Permission checks --------------------------------------------
|
||||
|
||||
#requireActor() {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
#requireUserOrAppActor(actor) {
|
||||
if (!actor.user)
|
||||
throw new HttpError(403, 'User actor required', {
|
||||
@@ -812,26 +795,15 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async #checkWriteAccess(app, actor) {
|
||||
// App actor matching app_owner
|
||||
const ownApp = actor.effectiveApp;
|
||||
let hasAccess = false;
|
||||
if (!ownApp?.id) {
|
||||
hasAccess = actor.user?.id === app.owner_user_id;
|
||||
} else if (ownApp.id === app.app_owner) {
|
||||
hasAccess = actor.user?.id === app.owner_user_id;
|
||||
}
|
||||
// System-wide write
|
||||
if (!hasAccess) {
|
||||
hasAccess = await this.permService.check(
|
||||
actor,
|
||||
'system:es:write-all-owners',
|
||||
);
|
||||
}
|
||||
if (!hasAccess) {
|
||||
throw new HttpError(403, 'Access denied', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const owns = actorOwnsRow(actor, {
|
||||
ownerUserId: app.owner_user_id,
|
||||
appOwnerId: app.app_owner,
|
||||
});
|
||||
if (owns) return;
|
||||
if (await this.permService.check(actor, 'system:es:write-all-owners')) {
|
||||
return;
|
||||
}
|
||||
throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
// -- Serialization ------------------------------------------------
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { assertResolvedActor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import {
|
||||
assertResolvedActor,
|
||||
requireContextActor,
|
||||
type UserActor,
|
||||
} from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { resolveNotifFetch } from '../../services/events/notifFetch.js';
|
||||
import {
|
||||
@@ -38,7 +41,8 @@ import {
|
||||
} from '../../services/notification/notificationTypes.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
import type { DriverRateLimitConfig } from '../meta.js';
|
||||
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
|
||||
|
||||
const MAX_SELECT_LIMIT = 200;
|
||||
|
||||
@@ -110,15 +114,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
},
|
||||
};
|
||||
|
||||
readonly concurrent: DriverConcurrentConfig = {
|
||||
default: {
|
||||
limit: 20,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 10,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
readonly concurrent = CRUD_CONCURRENT;
|
||||
|
||||
// -- Driver methods ----------------------------------------------
|
||||
|
||||
@@ -277,14 +273,8 @@ export class NotificationDriver extends PuterDriver {
|
||||
|
||||
// -- Permissions -------------------------------------------------
|
||||
|
||||
#requireActor(): Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
} {
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
#requireActor(): UserActor {
|
||||
const actor = requireContextActor();
|
||||
if (!actor.user?.id)
|
||||
throw new HttpError(403, 'User actor required', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -292,15 +282,11 @@ export class NotificationDriver extends PuterDriver {
|
||||
// Unresolved is not "no app", and reading it that way here is what
|
||||
// would hand an app the account-wide mailbox.
|
||||
assertResolvedActor(actor);
|
||||
return actor as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
return actor as UserActor;
|
||||
}
|
||||
|
||||
/** Writing a notification stays the holder's, per the token table. */
|
||||
#requireUserActor(): Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
} {
|
||||
#requireUserActor(): UserActor {
|
||||
const actor = this.#requireActor();
|
||||
if (actor.effectiveApp)
|
||||
throw new HttpError(403, 'App actors cannot create notifications', {
|
||||
|
||||
@@ -18,16 +18,20 @@
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
import {
|
||||
actorOwnsRow,
|
||||
assertActorEmailVerified,
|
||||
requireContextUserActor,
|
||||
type Actor,
|
||||
} from '../../core/actor.js';
|
||||
import type { DriverRateLimitConfig } from '../meta.js';
|
||||
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type { AppView } from '../../util/appView.js';
|
||||
@@ -124,15 +128,7 @@ export class SubdomainDriver extends PuterDriver {
|
||||
},
|
||||
};
|
||||
|
||||
readonly concurrent: DriverConcurrentConfig = {
|
||||
default: {
|
||||
limit: 20,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 10,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
readonly concurrent = CRUD_CONCURRENT;
|
||||
|
||||
// -- Driver methods ----------------------------------------------
|
||||
|
||||
@@ -144,9 +140,8 @@ export class SubdomainDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUser(actor);
|
||||
this.#requireVerified(actor);
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
|
||||
const subdomain = this.#validateSubdomain(object.subdomain);
|
||||
|
||||
@@ -250,7 +245,7 @@ export class SubdomainDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async read(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextUserActor();
|
||||
const row = await this.#resolve(args);
|
||||
// Worker deployments live in this table but aren't sites. `select`
|
||||
// excludes them and the workers driver serves them under its own
|
||||
@@ -271,8 +266,7 @@ export class SubdomainDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async select(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUser(actor);
|
||||
const actor = requireContextUserActor();
|
||||
|
||||
const predicate = args.predicate as unknown[] | string | undefined;
|
||||
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 5000;
|
||||
@@ -359,9 +353,8 @@ export class SubdomainDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUser(actor);
|
||||
this.#requireVerified(actor);
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
|
||||
const row = await this.#resolve(args);
|
||||
if (!row)
|
||||
@@ -430,9 +423,8 @@ export class SubdomainDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async delete(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUser(actor);
|
||||
this.#requireVerified(actor);
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
|
||||
const row = await this.#resolve(args);
|
||||
if (!row)
|
||||
@@ -519,40 +511,6 @@ export class SubdomainDriver extends PuterDriver {
|
||||
|
||||
// -- Permissions -------------------------------------------------
|
||||
|
||||
#requireActor(): Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
} {
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor?.user?.id)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
#requireUser(actor: Actor): void {
|
||||
if (!actor.user?.id)
|
||||
throw new HttpError(403, 'User actor required', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only
|
||||
* active when `strict_email_verification_required` is truthy, so self-
|
||||
* hosted installs without SMTP aren't bricked. Applied at the driver level
|
||||
* so /drivers/call can't bypass the gate the HTTP route enforces.
|
||||
*/
|
||||
#requireVerified(actor: Actor): void {
|
||||
assertVerifiedEmail(
|
||||
Boolean(this.config.strict_email_verification_required),
|
||||
actor.user,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
async #hasPermission(actor: Actor, permission: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.services.permission.check(actor, permission);
|
||||
@@ -626,26 +584,15 @@ export class SubdomainDriver extends PuterDriver {
|
||||
row: Record<string, unknown>,
|
||||
actor: Actor,
|
||||
): Promise<void> {
|
||||
// App actor matching app_owner
|
||||
const app = actor.effectiveApp;
|
||||
let hasAccess = false;
|
||||
if (!app?.id) {
|
||||
hasAccess = actor.user?.id === row.user_id;
|
||||
} else if (app.id === row.app_owner) {
|
||||
hasAccess = actor.user?.id === row.user_id;
|
||||
}
|
||||
// System-wide write
|
||||
if (!hasAccess) {
|
||||
hasAccess = await this.#hasPermission(
|
||||
actor,
|
||||
'system:es:write-all-owners',
|
||||
);
|
||||
}
|
||||
if (!hasAccess) {
|
||||
throw new HttpError(403, 'Access denied', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const owns = actorOwnsRow(actor, {
|
||||
ownerUserId: row.user_id,
|
||||
appOwnerId: row.app_owner,
|
||||
});
|
||||
if (owns) return;
|
||||
if (await this.#hasPermission(actor, 'system:es:write-all-owners')) {
|
||||
return;
|
||||
}
|
||||
throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
// -- Config ------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import type { DriverConcurrentConfig } from '../meta.js';
|
||||
|
||||
/**
|
||||
* In-flight cap shared by the crud-q drivers (apps, subdomains, notifications),
|
||||
* which inherited one pre-v2 `temp.es` / `user.es` policy.
|
||||
*/
|
||||
export const CRUD_CONCURRENT: DriverConcurrentConfig = {
|
||||
default: {
|
||||
limit: 20,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 10,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -21,10 +21,13 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { EventMetadata } from '../../clients/event/types.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import {
|
||||
assertActorEmailVerified,
|
||||
makeActor,
|
||||
requireContextUserActor,
|
||||
type Actor,
|
||||
} from '../../core/actor.js';
|
||||
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
|
||||
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
@@ -327,12 +330,12 @@ export class WorkerDriver extends PuterDriver {
|
||||
[INTERNAL_ADMISSION_BYPASS]?: boolean;
|
||||
[INTERNAL_DEPLOY_TARGET]?: InternalDeployTarget;
|
||||
}): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextUserActor();
|
||||
const skipAdmission = args[INTERNAL_ADMISSION_BYPASS] === true;
|
||||
const deployTarget = this.#validateDeployTarget(
|
||||
args[INTERNAL_DEPLOY_TARGET],
|
||||
);
|
||||
if (!skipAdmission) this.#requireVerified(actor);
|
||||
if (!skipAdmission) assertActorEmailVerified(actor, this.config);
|
||||
const workerName = String(args.workerName ?? '').toLowerCase();
|
||||
const filePath = String(args.filePath ?? '');
|
||||
// `effectiveApp`, not `app`: a token an app issued carries no app of
|
||||
@@ -539,8 +542,8 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async destroy(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
this.#requireVerified(actor);
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
const workerName = String(args.workerName ?? '').toLowerCase();
|
||||
if (!workerName)
|
||||
throw new HttpError(400, 'Missing `workerName`', {
|
||||
@@ -573,7 +576,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
const actor = requireContextUserActor();
|
||||
const workerName = args.workerName as string | undefined;
|
||||
|
||||
const limit = normalizeLimit(args.limit, { cap: 5000 });
|
||||
@@ -840,19 +843,6 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
#requireActor(): Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
} {
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor?.user?.id)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only in-process code can supply a deploy target, so this is not a trust
|
||||
* boundary — it is a guard against a caller putting a path separator or a
|
||||
@@ -1028,20 +1018,6 @@ export class WorkerDriver extends PuterDriver {
|
||||
return this.config.workers ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only
|
||||
* active when `strict_email_verification_required` is truthy, so self-
|
||||
* hosted installs without SMTP aren't bricked. Applied at the driver level
|
||||
* so /drivers/call can't bypass the gate the HTTP route enforces.
|
||||
*/
|
||||
#requireVerified(actor: Actor): void {
|
||||
assertVerifiedEmail(
|
||||
Boolean(this.config.strict_email_verification_required),
|
||||
actor.user,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Hot-reload: auto-redeploy on source file write --------------
|
||||
//
|
||||
// When a user saves a JS file that's tied to a worker subdomain,
|
||||
|
||||
Reference in new issue
Block a user