mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
fix(security): close the residual gaps in the remaining advisories + the LOCAL-provider attachment bug
A re-verification of every draft and published advisory against develop found 9 fully closed and 8
with residuals. This closes the code-fixable ones. Three patterns cover almost all of them:
1. `{ id, ...body }` — the body wins. A path id spread BEFORE the body is overridden by a body `id`,
and save()/create() with an existing PK is an UPDATE of that row: every authorization check ran
against the path id while the write hit the body id. On PUT /user/:id that is account takeover
(a PROFILE_EDIT employee posts {"id":"<SUPER_ADMIN>","hash":"..."}). The id is now pinned LAST in
all 25 controllers/handlers with that shape, UserService.updateProfile pins entity.id = id, and
TenantAwareCrudService.create/save/createMany/saveMany refuse an entity whose id already names a
row of ANOTHER tenant (or a tenant-less row) — the update-through-create endpoints had no other
ownership check. (GHSA-x4mv-fhwj-g3rp, GHSA-gwpq-mmw7-vx85)
2. A client-supplied value decides an authorization branch. The register handler gated "only a
SUPER_ADMIN may register a SUPER_ADMIN" on input.user.role.name, and POST /user had no gate at
all. Both now resolve EVERY role identifier (the flat roleId and the role relation — the relation
wins on persist) from the database in the caller's tenant and fail closed on an id that does not
resolve. (GHSA-hjcg-633x-qq74, GHSA-x4mv-fhwj-g3rp)
3. Only the root row is tenant-scoped. SharedEntity turned caller-supplied shareRules.relations
straight into TypeORM relations on a @Public() token route, so a share of an OWNED Organization
could pivot featureOrganizations -> feature -> featureOrganizations -> tenant -> organizations ->
employees -> user into every tenant. Relations are now validated against entity metadata (each hop
must exist AND target a tenant-scoped entity), depth-bounded, joined rows are scope-filtered,
tenant-less roots are refused, and create/update bodies are whitelisted. (GHSA-cx2q-xmh2-pc38,
GHSA-gpg5-qwjc-8hqh)
Also:
- /invite/accept mass-assignment: AuthService.register strips id/hash/emailVerifiedAt/emailToken/
code/codeExpireAt/refreshToken from input.user, honours createdByUserId only for the authenticated
caller, pins user.tenantId to the trusted tenant; invite accept pins the invited email.
(GHSA-929w-5p4w-cxjp)
- TimeOffStatusHandler used raw repositories with no tenant scope (an admin of tenant A could
approve/deny tenant B's requests); equipment-sharing deleted the request_approval row unscoped, and
its status change went through an update() that deletes and re-inserts — a { status }-only body
replaced the record with a stub. (GHSA-gwpq-mmw7-vx85)
- Hubstaff /refresh-token no longer returns the refresh token. (GHSA-3rqg-gpm9-gx84)
- Upload filters on the endpoints that had none: POST /import (archive allowlist), POST
/ai-chat/attachments and the 5 registry upload routes (script-capable-extension denylist).
(GHSA-p334-cm7f-php5)
- docker-compose defaults NODE_ENV to production so the insecure-secret guard actually fires (compose
`environment:` overrode .env.compose and the image ENV); render blueprints generate their secrets
instead of shipping secretKey/refreshSecretKey/gauzy, and the CORP policy is overridable
(CORP_POLICY) because API and webapp live on two different *.onrender.com sites.
(GHSA-chm8-2ggf-pgjq)
And a functional bug found on the way: POST /ai-chat/attachments was broken on the default LOCAL file
provider. It used Nest's @UploadedFile(), which hands over multer's diskStorage object — no `key` (only
core's @UploadedFileStorage() maps it through provider.mapUploadedFile, where LOCAL derives key from
path) — so the service threw 400 AFTER the bytes were written, leaving an orphan. It now uses the core
decorator, never puts a browser-renderable extension on the stored object name, and deletes the stored
object when the upload is rejected (service) or when sniffFile rejects it (docs chat-capture).
PUT /product-types/:id was likewise a silent 400 for every caller (a DTO instance was passed to
EntityManager.save, which resolves metadata from the constructor); it now saves with an explicit
entity target under the verified id.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ad7b675c8b
commit
84032fd11c
@@ -99,6 +99,11 @@ REDIS_URL=redis://redis:6379
|
||||
# The API refuses to start in production (NODE_ENV=production and DEMO != true)
|
||||
# while any of these is empty or left at a well-known default value, because
|
||||
# shared/default secrets let anyone forge authentication tokens and sessions.
|
||||
#
|
||||
# docker-compose now runs the API with NODE_ENV=production by default, so a stack
|
||||
# started with these left blank will STOP with an "INSECURE SECRETS" error instead
|
||||
# of silently serving on the publicly known defaults. Fill them in (or, for a
|
||||
# throwaway local stack only, export NODE_ENV=development or DEMO=true).
|
||||
# ============================================================================
|
||||
JWT_SECRET=
|
||||
EXPRESS_SESSION_SECRET=
|
||||
|
||||
@@ -29,11 +29,13 @@ services:
|
||||
- key: CLIENT_BASE_URL
|
||||
value: https://ever-gauzy-webapp.onrender.com
|
||||
- key: EXPRESS_SESSION_SECRET
|
||||
value: gauzy
|
||||
generateValue: true
|
||||
- key: JWT_SECRET
|
||||
value: secretKey
|
||||
generateValue: true
|
||||
- key: JWT_REFRESH_TOKEN_SECRET
|
||||
value: refreshSecretKey
|
||||
generateValue: true
|
||||
- key: JWT_VERIFICATION_TOKEN_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_REFRESH_TOKEN_EXPIRATION_TIME
|
||||
value: 86400
|
||||
healthCheckPath: /api/health
|
||||
|
||||
@@ -203,7 +203,7 @@ Please refer to our official [Platform Documentation](https://docs.gauzy.co) and
|
||||
|
||||
#### Production
|
||||
|
||||
- Edit `.env.compose` (if needed) to use your custom settings, e.g. DB type.
|
||||
- Edit `.env.compose`: you **must** set `JWT_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `JWT_VERIFICATION_TOKEN_SECRET` and `EXPRESS_SESSION_SECRET` to strong, unique values (e.g. `openssl rand -hex 64`). The API refuses to start on the shipped blank/default secrets — shared defaults let anyone forge authentication tokens and sessions. Adjust any other settings (e.g. DB type) there too.
|
||||
- Run `docker-compose up -d`, if you want to run the platform in minimal production configuration using our prebuilt Docker images. _(Note: Docker Compose will use latest images pre-build automatically from head of `master` branch using GitHub CI/CD.)_
|
||||
|
||||
Note: we recommend using Kubernetes for production workloads instead of Docker Compose!
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
environment:
|
||||
API_HOST: ${API_HOST:-api}
|
||||
API_PORT: ${API_PORT:-3000}
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DB_HOST: db
|
||||
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
|
||||
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ services:
|
||||
environment:
|
||||
API_HOST: ${API_HOST:-api}
|
||||
API_PORT: ${API_PORT:-3000}
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DB_HOST: db
|
||||
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
|
||||
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
environment:
|
||||
WEB_HOST: ${WEB_HOST:-webapp}
|
||||
WEB_PORT: ${WEB_PORT:-4200}
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
|
||||
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export { bootstrap, registerPluginConfig } from './lib/bootstrap';
|
||||
export * from './lib/core';
|
||||
export {
|
||||
ALLOWED_ARCHIVE_EXTENSIONS,
|
||||
ALLOWED_ARCHIVE_MIME_TYPES,
|
||||
ALLOWED_AUDIO_EXTENSIONS,
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
ALLOWED_IMAGE_EXTENSIONS,
|
||||
@@ -11,13 +13,16 @@ export {
|
||||
ALLOWED_VIDEO_EXTENSIONS,
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
BLOCKED_UPLOAD_EXTENSIONS,
|
||||
SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS,
|
||||
FileStorage,
|
||||
FileStorageFactory,
|
||||
MARKUP_SCAN_MAX_BYTES,
|
||||
UploadedFileStorage,
|
||||
archiveUploadFileFilter,
|
||||
assertNotMarkupContent,
|
||||
audioUploadFileFilter,
|
||||
createUploadFileFilter,
|
||||
documentUploadFileFilter,
|
||||
imageUploadFileFilter,
|
||||
isMarkupContent,
|
||||
shouldScanForMarkup,
|
||||
|
||||
@@ -171,10 +171,7 @@ export class AccountingTemplateController extends CrudController<AccountingTempl
|
||||
@Body() input: IAccountingTemplateUpdateInput
|
||||
): Promise<IAccountingTemplate> {
|
||||
try {
|
||||
await this.accountingTemplateService.create({
|
||||
id,
|
||||
...input
|
||||
});
|
||||
await this.accountingTemplateService.create({ ...input, id });
|
||||
return await this.findById(id);
|
||||
} catch (error) {
|
||||
throw new BadRequestException();
|
||||
|
||||
@@ -1204,6 +1204,25 @@ export class AuthService extends SocialAuthService {
|
||||
let tenant = input.user.tenant;
|
||||
const { organizationId } = input;
|
||||
|
||||
// -1. This is a CREATION sink reachable from public routes (/auth/register, /invite/accept,
|
||||
// /invite/contact) whose bodies are attacker-controlled. Strip everything that identifies or
|
||||
// privileges an EXISTING account before the payload is spread into create()/save():
|
||||
// a body `user.id` would turn the save into an UPDATE of that row (account takeover), and
|
||||
// hash / verification / token columns must only ever be derived server-side.
|
||||
if (input.user) {
|
||||
const {
|
||||
id: _id,
|
||||
hash: _hash,
|
||||
emailVerifiedAt: _emailVerifiedAt,
|
||||
emailToken: _emailToken,
|
||||
code: _code,
|
||||
codeExpireAt: _codeExpireAt,
|
||||
refreshToken: _refreshToken,
|
||||
...safeUser
|
||||
} = input.user as any;
|
||||
input.user = safeUser;
|
||||
}
|
||||
|
||||
// 0. Validate the terms acceptance BEFORE anything irreversible happens.
|
||||
//
|
||||
// The register form gates its submit button on a hard-required terms
|
||||
@@ -1230,7 +1249,13 @@ export class AuthService extends SocialAuthService {
|
||||
input.user.roleId = input.user.role.id;
|
||||
}
|
||||
|
||||
// 1. If createdByUserId is provided, get the creating user and use their tenant
|
||||
// 1. If createdByUserId is provided, get the creating user and use their tenant — but only when
|
||||
// it names the AUTHENTICATED caller. On the public invite routes the field is attacker-controlled
|
||||
// and used to override the invite's tenant with any user's tenant (cross-tenant registration).
|
||||
const authenticatedUserId = RequestContext.currentUserId();
|
||||
if (input.createdByUserId && (!authenticatedUserId || String(input.createdByUserId) !== String(authenticatedUserId))) {
|
||||
delete input.createdByUserId;
|
||||
}
|
||||
if (input.createdByUserId) {
|
||||
const creatingUser = await this.userService.findOneByIdString(input.createdByUserId, {
|
||||
relations: {
|
||||
@@ -1239,6 +1264,10 @@ export class AuthService extends SocialAuthService {
|
||||
});
|
||||
tenant = creatingUser.tenant;
|
||||
}
|
||||
// Keep the flat FK consistent with the trusted tenant relation (mirrors the roleId pin above).
|
||||
if (tenant?.id && input.user) {
|
||||
input.user.tenantId = tenant.id;
|
||||
}
|
||||
|
||||
// 2. Register new user
|
||||
let user: User;
|
||||
|
||||
@@ -30,29 +30,44 @@ export class AuthRegisterHandler implements ICommandHandler<AuthRegisterCommand>
|
||||
const { input, languageCode } = command;
|
||||
let targetRoleName: string | null = null;
|
||||
|
||||
if (input.user?.roleId) {
|
||||
// The target role may arrive as `roleId` or as a `role` object; resolve BOTH from the DATABASE.
|
||||
// A client-supplied `role.name` must never feed the SUPER_ADMIN gate below (a role object with
|
||||
// only an id, or a spoofed name, used to skip it), and checking only the first of the two is not
|
||||
// enough either: the `role` RELATION wins over the flat `roleId` when the row is persisted
|
||||
// (AuthService.register pins roleId = role.id), so a body pairing a harmless `roleId` with a
|
||||
// privileged `role: { id }` would be validated as the harmless one and registered as the
|
||||
// privileged one.
|
||||
const targetRoleIds = [input.user?.roleId, input.user?.role?.id].filter((roleId) => !!roleId);
|
||||
if (input.user?.role && !targetRoleIds.length) {
|
||||
throw new BadRequestException('The specified role does not reference a valid role.');
|
||||
}
|
||||
|
||||
if (targetRoleIds.length) {
|
||||
// Get tenant id from request context
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
|
||||
// Resolve role entity to get the name
|
||||
try {
|
||||
const whereCondition = {
|
||||
id: input.user.roleId,
|
||||
...(tenantId ? { tenantId } : {})
|
||||
};
|
||||
for (const targetRoleId of targetRoleIds) {
|
||||
// Resolve role entity to get the name
|
||||
try {
|
||||
const whereCondition = {
|
||||
id: targetRoleId,
|
||||
...(tenantId ? { tenantId } : {})
|
||||
};
|
||||
|
||||
const role: IRole =
|
||||
getORMType() === MultiORMEnum.MikroORM
|
||||
? await this.mikroOrmRoleRepository.findOneOrFail(whereCondition)
|
||||
: await this.typeOrmRoleRepository.findOneByOrFail(whereCondition);
|
||||
const role: IRole =
|
||||
getORMType() === MultiORMEnum.MikroORM
|
||||
? await this.mikroOrmRoleRepository.findOneOrFail(whereCondition)
|
||||
: await this.typeOrmRoleRepository.findOneByOrFail(whereCondition);
|
||||
|
||||
targetRoleName = role.name;
|
||||
} catch {
|
||||
throw new BadRequestException('The specified roleId does not reference a valid role.');
|
||||
// The strictest of the candidates decides: if ANY of them is SUPER_ADMIN, the creator
|
||||
// check below must run.
|
||||
if (role.name === RolesEnum.SUPER_ADMIN || !targetRoleName) {
|
||||
targetRoleName = role.name;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException('The specified roleId does not reference a valid role.');
|
||||
}
|
||||
}
|
||||
} else if (input.user?.role?.name) {
|
||||
// Role name provided directly via role object
|
||||
targetRoleName = input.user.role.name;
|
||||
}
|
||||
|
||||
// Check if the target role is SUPER_ADMIN and require 'createdByUserId' for verification
|
||||
|
||||
@@ -110,9 +110,6 @@ export class AvailabilitySlotsController extends CrudController<AvailabilitySlot
|
||||
@Param('id', UUIDValidationPipe) id: ID,
|
||||
@Body() entity: IAvailabilitySlot
|
||||
): Promise<IAvailabilitySlot> {
|
||||
return this.availabilitySlotsService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return this.availabilitySlotsService.create({ ...entity, id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,9 +190,16 @@ export async function bootstrap(pluginConfig?: Partial<ApplicationPluginConfig>)
|
||||
contentSecurityPolicy: isProduction
|
||||
? undefined // use Helmet's strict default CSP in production
|
||||
: false, // disable CSP in dev/stage so Swagger/Scalar inline scripts work
|
||||
crossOriginResourcePolicy: isProduction
|
||||
? { policy: 'same-site' } // Prod: same-site lets *.gauzy.co (e.g. app.gauzy.co) embed API-served assets like /public icons, while still blocking third-party origins
|
||||
: { policy: 'cross-origin' }, // Relaxed in dev/stage — resources served from API
|
||||
crossOriginResourcePolicy: {
|
||||
// Prod default: same-site lets *.gauzy.co (e.g. app.gauzy.co) embed API-served assets like
|
||||
// /public icons, while still blocking third-party origins; relaxed in dev/stage — resources
|
||||
// served from API. Overridable because a deployment may legitimately serve its API and web
|
||||
// app from two DIFFERENT sites (e.g. *.onrender.com, a public suffix), where 'same-site'
|
||||
// would block every API-served image.
|
||||
policy:
|
||||
(process.env.CORP_POLICY as 'same-site' | 'cross-origin' | 'same-origin') ??
|
||||
(isProduction ? 'same-site' : 'cross-origin')
|
||||
},
|
||||
crossOriginEmbedderPolicy: isProduction // strict in production, relaxed in dev/stage for Electron/desktop
|
||||
})
|
||||
);
|
||||
|
||||
+1
-4
@@ -34,9 +34,6 @@ export class FeedbackUpdateHandler implements ICommandHandler<FeedbackUpdateComm
|
||||
}
|
||||
|
||||
public async update(id: string, entity: ICandidateFeedbackCreateInput): Promise<ICandidateFeedback> {
|
||||
return this.candidateFeedbackService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return this.candidateFeedbackService.create({ ...entity, id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ export class CandidateController extends CrudController<Candidate> {
|
||||
@Put('/:id')
|
||||
@UseValidationPipe({ transform: true })
|
||||
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateCandidateDTO): Promise<ICandidate> {
|
||||
return await this.commandBus.execute(new CandidateUpdateCommand({ id, ...entity }));
|
||||
return await this.commandBus.execute(new CandidateUpdateCommand({ ...entity, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,10 +18,7 @@ export class CandidateUpdateHandler
|
||||
try {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
return await this.candidateService.create({
|
||||
id,
|
||||
...input
|
||||
});
|
||||
return await this.candidateService.create({ ...input, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { DeleteResult, FindOptionsWhere, In, Repository, UpdateResult } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { ID, IPagination, IUser, PermissionsEnum } from '@gauzy/contracts';
|
||||
import { isNotEmpty } from '@gauzy/utils';
|
||||
import { LegacyFindManyOptions, LegacyFindOneOptions } from '../utils';
|
||||
import { LegacyFindManyOptions, LegacyFindOneOptions, MultiORMEnum } from '../utils';
|
||||
import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository';
|
||||
import { RequestContext } from '../context';
|
||||
import { TenantBaseEntity } from '../entities/internal';
|
||||
@@ -367,6 +367,77 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to persist an entity whose id already names a row of ANOTHER tenant.
|
||||
*
|
||||
* create()/save() with an id are upserts: TypeORM's save() looks the row up by primary key only and
|
||||
* then UPDATEs it, while this service merely stamps the caller's tenantId onto the payload. A body
|
||||
* that smuggled a foreign id in (`{ id, ...body }` spreads, un-whitelisted update DTOs) therefore
|
||||
* overwrote — and re-tenanted — another tenant's row (GHSA-gwpq-mmw7-vx85 / GHSA-x4mv-fhwj-g3rp
|
||||
* class). Rows the caller's tenant owns, and ids that do not exist yet, are untouched.
|
||||
*
|
||||
* @param entity - The payload about to be persisted.
|
||||
* @param tenantId - The caller's tenant.
|
||||
*/
|
||||
protected async assertNotForeignRow(entity: IPartialEntity<T>, tenantId: ID | null): Promise<void> {
|
||||
const id = (entity as any)?.id;
|
||||
if (!id || !tenantId || !this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId')) {
|
||||
return;
|
||||
}
|
||||
let existing: unknown;
|
||||
let existingTenantId: ID | null | undefined;
|
||||
switch (this.ormType) {
|
||||
case MultiORMEnum.MikroORM: {
|
||||
existing = await this.mikroOrmRepository.findOne({ id } as any, { fields: ['id', 'tenantId'] as any });
|
||||
existingTenantId = (existing as any)?.tenantId;
|
||||
break;
|
||||
}
|
||||
case MultiORMEnum.TypeORM:
|
||||
default: {
|
||||
existing = await this.typeOrmRepository.findOne({
|
||||
where: { id } as FindOptionsWhere<T>,
|
||||
select: { id: true, tenantId: true } as any,
|
||||
withDeleted: true
|
||||
});
|
||||
existingTenantId = (existing as any)?.tenantId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fail CLOSED on a tenant-less row too: on the update-through-create endpoints this guard is the
|
||||
// only ownership check, so a row with a NULL tenantId (legacy / global / written without a
|
||||
// request context) must not be overwritable — and re-tenantable — by a tenant user.
|
||||
if (existing && String(existingTenantId ?? '') !== String(tenantId)) {
|
||||
throw new ForbiddenException('The record belongs to another tenant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch form of {@link assertNotForeignRow} for createMany()/saveMany() (one lookup for all ids).
|
||||
*/
|
||||
protected async assertNotForeignRows(entities: IPartialEntity<T>[], tenantId: ID | null): Promise<void> {
|
||||
const ids = (entities ?? []).map((entity) => (entity as any)?.id).filter((id) => !!id);
|
||||
if (!ids.length || !tenantId || !this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId')) {
|
||||
return;
|
||||
}
|
||||
let existing: any[];
|
||||
switch (this.ormType) {
|
||||
case MultiORMEnum.MikroORM:
|
||||
existing = await this.mikroOrmRepository.find({ id: { $in: ids } } as any, { fields: ['id', 'tenantId'] as any });
|
||||
break;
|
||||
case MultiORMEnum.TypeORM:
|
||||
default:
|
||||
existing = await this.typeOrmRepository.find({
|
||||
where: { id: In(ids) } as FindOptionsWhere<T>,
|
||||
select: { id: true, tenantId: true } as any,
|
||||
withDeleted: true
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (existing.some((row) => String(row?.tenantId ?? '') !== String(tenantId))) {
|
||||
throw new ForbiddenException('One of the records belongs to another tenant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new entity instance and copies all entity properties from this object into a new entity.
|
||||
* Note that it copies only properties that are present in entity schema.
|
||||
@@ -377,6 +448,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
|
||||
public async create(entity: IPartialEntity<T>): Promise<T> {
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
const employeeId = RequestContext.currentEmployeeId();
|
||||
await this.assertNotForeignRow(entity, tenantId);
|
||||
|
||||
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
|
||||
const hasEmployeeColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('employeeId');
|
||||
@@ -408,6 +480,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
|
||||
*/
|
||||
public async createMany(entities: IPartialEntity<T>[]): Promise<T[]> {
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
await this.assertNotForeignRows(entities, tenantId);
|
||||
const employeeId = RequestContext.currentEmployeeId();
|
||||
|
||||
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
|
||||
@@ -435,6 +508,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
|
||||
public async save(entity: IPartialEntity<T>): Promise<T> {
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
|
||||
await this.assertNotForeignRow(entity, tenantId);
|
||||
|
||||
return await super.save({
|
||||
...entity,
|
||||
@@ -470,6 +544,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
|
||||
*/
|
||||
public async saveMany(entities: IPartialEntity<T>[]): Promise<T[]> {
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
await this.assertNotForeignRows(entities, tenantId);
|
||||
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
|
||||
|
||||
const enriched = entities.map((entity) => ({
|
||||
|
||||
@@ -94,6 +94,59 @@ export const videoUploadFileFilter = createUploadFileFilter(ALLOWED_VIDEO_MIME_T
|
||||
/** Multer `fileFilter` accepting only audio files. */
|
||||
export const audioUploadFileFilter = createUploadFileFilter(ALLOWED_AUDIO_MIME_TYPES, ALLOWED_AUDIO_EXTENSIONS);
|
||||
|
||||
/** MIME types browsers/clients send for a ZIP archive. */
|
||||
export const ALLOWED_ARCHIVE_MIME_TYPES = [
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-zip',
|
||||
'multipart/x-zip',
|
||||
'application/octet-stream'
|
||||
] as const;
|
||||
|
||||
/** Extensions accepted by the archive (import) endpoints. */
|
||||
export const ALLOWED_ARCHIVE_EXTENSIONS = ['.zip'] as const;
|
||||
|
||||
/** Multer `fileFilter` accepting only ZIP archives (the data-import format). */
|
||||
export const archiveUploadFileFilter = createUploadFileFilter(ALLOWED_ARCHIVE_MIME_TYPES, ALLOWED_ARCHIVE_EXTENSIONS);
|
||||
|
||||
/**
|
||||
* Extensions that are script-capable when served by extension yet have no legitimate use on the
|
||||
* open-ended DOCUMENT upload endpoints (chat attachments, knowledge ingestion): those endpoints must
|
||||
* accept `.html`/`.xml` (they are ingested), so an allowlist is impossible there and this is the
|
||||
* backstop for the rest. `/public` additionally serves every asset with `nosniff` + a `sandbox` CSP.
|
||||
*/
|
||||
export const SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS = [
|
||||
'.svg',
|
||||
'.svgz',
|
||||
'.xhtml',
|
||||
'.xht',
|
||||
'.mhtml',
|
||||
'.mht',
|
||||
'.xsl',
|
||||
'.xslt',
|
||||
'.shtml',
|
||||
'.shtm',
|
||||
'.hta',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.vbs',
|
||||
'.wsf'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Multer `fileFilter` for open-ended document uploads: refuses {@link SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS}
|
||||
* and accepts everything else. Callers still store the bytes behind `/public`'s `sandbox` CSP.
|
||||
*/
|
||||
export function documentUploadFileFilter(_req: unknown, file: { originalname?: string }, callback: MulterFileFilterCallback): void {
|
||||
const name = String(file?.originalname ?? '').toLowerCase();
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot >= 0 ? name.slice(dot) : '';
|
||||
if ((SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS as readonly string[]).includes(extension)) {
|
||||
return callback(new BadRequestException(`Files of type "${extension}" are not allowed`), false);
|
||||
}
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the given file content is markup (SVG / XML / HTML / XHTML).
|
||||
*
|
||||
|
||||
@@ -33,10 +33,7 @@ export class EmployeeLevelController extends CrudController<EmployeeLevel> {
|
||||
...options: any[]
|
||||
): Promise<IEmployeeLevel> {
|
||||
try {
|
||||
return this.employeeLevelService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return this.employeeLevelService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
+4
-2
@@ -39,7 +39,9 @@ export class EquipmentSharingStatusHandler implements ICommandHandler<EquipmentS
|
||||
// If a corresponding request approval exists, update its status as well.
|
||||
await this._requestApprovalService.update({ requestId: id }, { status });
|
||||
|
||||
// Persist and return the updated equipment sharing record.
|
||||
return await this._equipmentSharingService.update(id, { status });
|
||||
// Persist and return the updated equipment sharing record. NOT update(): that one deletes the row
|
||||
// and re-inserts the payload, so approving/refusing (a { status }-only body) replaced the record
|
||||
// with a stub. updateStatusEquipmentSharingByAdmin loads the tenant-scoped row and saves it back.
|
||||
return await this._equipmentSharingService.updateStatusEquipmentSharingByAdmin(id, status);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -27,8 +27,9 @@ export class EquipmentSharingUpdateHandler implements ICommandHandler<EquipmentS
|
||||
this._requestApprovalService.delete({ requestId: id })
|
||||
]);
|
||||
|
||||
// Save the updated Equipment Sharing record.
|
||||
const equipmentSharing = await this._equipmentSharingService.create(input);
|
||||
// Save the updated Equipment Sharing record under the path id (a body-supplied id must not
|
||||
// retarget the write; the raw body is not DTO-validated).
|
||||
const equipmentSharing = await this._equipmentSharingService.create({ ...input, id });
|
||||
|
||||
// Create a new request approval record for the updated equipment sharing.
|
||||
await this._requestApprovalService.create({
|
||||
|
||||
@@ -167,8 +167,10 @@ export class EquipmentSharingService extends TenantAwareCrudService<EquipmentSha
|
||||
// Use parent's tenant-scoped delete instead of direct repository access
|
||||
await super.delete(id);
|
||||
|
||||
// Save the new equipment sharing data with tenant scoping
|
||||
const equipmentSharing = await this.save(input);
|
||||
// Save the new equipment sharing data with tenant scoping, under the SAME id: the body is not
|
||||
// guaranteed to carry one, and a delete-then-insert without it replaced the record with a stub
|
||||
// (approve/refuse goes through here).
|
||||
const equipmentSharing = await this.save({ ...input, id });
|
||||
|
||||
// Return the newly saved record
|
||||
return equipmentSharing;
|
||||
@@ -189,12 +191,17 @@ export class EquipmentSharingService extends TenantAwareCrudService<EquipmentSha
|
||||
*/
|
||||
async delete(id: ID): Promise<DeleteResult> {
|
||||
try {
|
||||
// Execute both deletion operations concurrently.
|
||||
// Use parent's tenant-scoped delete instead of direct repository access
|
||||
const [equipmentSharing] = await Promise.all([
|
||||
super.delete(id),
|
||||
this.typeOrmRequestApprovalRepository.delete({ requestId: id })
|
||||
]);
|
||||
// The equipment-sharing delete is tenant-scoped (parent); the approval-row delete runs on a RAW
|
||||
// repository, so it must be tenant-scoped explicitly and only run once the sharing row was
|
||||
// really ours — otherwise a foreign UUID deleted another tenant's request_approval row.
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
const equipmentSharing = await super.delete(id);
|
||||
if (equipmentSharing?.affected) {
|
||||
await this.typeOrmRequestApprovalRepository.delete({
|
||||
requestId: id,
|
||||
...(tenantId ? { tenantId } : {})
|
||||
});
|
||||
}
|
||||
|
||||
// Return the result from the equipment sharing deletion.
|
||||
return equipmentSharing;
|
||||
|
||||
@@ -92,9 +92,6 @@ export class EquipmentController extends CrudController<Equipment> {
|
||||
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: UpdateEquipmentDTO): Promise<IEquipment> {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
return await this.equipmentService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.equipmentService.create({ ...entity, id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +134,6 @@ export class EventTypeController extends CrudController<EventType> {
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@Put(':id')
|
||||
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: EventType): Promise<IEventType> {
|
||||
return this.eventTypeService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return this.eventTypeService.create({ ...entity, id });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -16,10 +16,7 @@ export class ExpenseCategoryUpdateHandler
|
||||
) {
|
||||
const { id, input } = command;
|
||||
try {
|
||||
return await this._expenseCategoryService.create({
|
||||
id,
|
||||
...input
|
||||
});
|
||||
return await this._expenseCategoryService.create({ ...input, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@ export class ExpenseUpdateHandler implements ICommandHandler<ExpenseUpdateComman
|
||||
let { id, entity } = command;
|
||||
try {
|
||||
await this.expenseService.findOneByIdString(id);
|
||||
const expense = await this.expenseService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
const expense = await this.expenseService.create({ ...entity, id });
|
||||
let averageExpense = 0;
|
||||
if (isNotEmpty(expense.employeeId)) {
|
||||
const { employeeId } = expense;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CommandBus } from '@nestjs/cqrs';
|
||||
import { ImportStatusEnum, ImportTypeEnum, PermissionsEnum, UploadedFile } from '@gauzy/contracts';
|
||||
import { ImportService } from './import.service';
|
||||
import { RequestContext } from '../../core/context';
|
||||
import { FileStorage, UploadedFileStorage } from '../../core/file-storage';
|
||||
import { archiveUploadFileFilter, FileStorage, UploadedFileStorage } from '../../core/file-storage';
|
||||
import { PermissionGuard, TenantPermissionGuard } from '../../shared/guards';
|
||||
import { Permissions } from '../../shared/decorators';
|
||||
import { ImportHistoryCreateCommand } from '../import-history';
|
||||
@@ -29,7 +29,10 @@ export class ImportController {
|
||||
storage: new FileStorage().storage({
|
||||
dest: path.join('import'),
|
||||
prefix: 'import'
|
||||
})
|
||||
}),
|
||||
// The import format is a ZIP of CSVs; the local provider keeps the client's extension and
|
||||
// the file lands under /public, so anything else is refused before it is stored.
|
||||
fileFilter: archiveUploadFileFilter
|
||||
})
|
||||
)
|
||||
@ApiOperation({ summary: 'Imports templates records.' })
|
||||
|
||||
@@ -75,10 +75,7 @@ export class GoalGeneralSettingController extends CrudController<GoalGeneralSett
|
||||
try {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
return await this.goalGeneralSettingService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.goalGeneralSettingService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -74,10 +74,7 @@ export class GoalKpiController extends CrudController<GoalKPI> {
|
||||
@Put(':id')
|
||||
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: GoalKPI): Promise<IKPI> {
|
||||
try {
|
||||
return await this.goalKpiService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.goalKpiService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -93,10 +93,7 @@ export class GoalTimeFrameController extends CrudController<GoalTimeFrame> {
|
||||
@Body() entity: UpdateGoalTimeFrameDTO
|
||||
): Promise<IGoalTimeFrame> {
|
||||
try {
|
||||
return await this.goalTimeFrameService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.goalTimeFrameService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -75,10 +75,7 @@ export class GoalController extends CrudController<Goal> {
|
||||
try {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
return await this.goalService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.goalService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
|
||||
@@ -19,10 +19,7 @@ export class IncomeUpdateHandler implements ICommandHandler<IncomeUpdateCommand>
|
||||
const { id, entity } = command;
|
||||
try {
|
||||
await this.incomeService.findOneByIdString(id);
|
||||
const income = await this.incomeService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
const income = await this.incomeService.create({ ...entity, id });
|
||||
|
||||
let averageIncome = 0;
|
||||
let averageBonus = 0;
|
||||
|
||||
@@ -56,6 +56,9 @@ export class InviteAcceptHandler implements ICommandHandler<InviteAcceptCommand>
|
||||
// would let an invitee accept with `user.roleId` of any role (e.g. SUPER_ADMIN).
|
||||
input['user']['role'] = role;
|
||||
input['user']['roleId'] = role.id;
|
||||
// The account is created for the INVITED address (the one the token/code was validated
|
||||
// against) — never for a different, auto-verified address supplied in the body.
|
||||
input['user']['email'] = invite.email;
|
||||
input['inviteId'] = inviteId;
|
||||
|
||||
// Invite accept for employee, candidate & user
|
||||
|
||||
@@ -167,7 +167,7 @@ export class InvoiceController extends CrudController<Invoice> {
|
||||
@Param('id', UUIDValidationPipe) id: IInvoice['id'],
|
||||
@Body() entity: UpdateInvoiceDTO
|
||||
): Promise<Invoice> {
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +197,7 @@ export class InvoiceController extends CrudController<Invoice> {
|
||||
@Param('id', UUIDValidationPipe) id: IInvoice['id'],
|
||||
@Body() entity: UpdateEstimateInvoiceDTO
|
||||
) {
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +224,7 @@ export class InvoiceController extends CrudController<Invoice> {
|
||||
@Put('/:id/action')
|
||||
@UseValidationPipe({ transform: true, whitelist: true })
|
||||
async updateAction(@Param('id', UUIDValidationPipe) id: IInvoice['id'], @Body() entity: UpdateInvoiceActionDTO) {
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
|
||||
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,10 +90,7 @@ export class KeyResultUpdateController extends CrudController<KeyResultUpdate> {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
try {
|
||||
return await this.keyResultUpdateService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.keyResultUpdateService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
|
||||
@@ -99,10 +99,7 @@ export class KeyResultController extends CrudController<KeyResult> {
|
||||
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateKeyResultDTO): Promise<IKeyResult> {
|
||||
//We are using create here because create calls the method save()
|
||||
//We need save() to save ManyToMany relations
|
||||
return await this.keyResultService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.keyResultService.create({ ...entity, id });
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
|
||||
+1
-4
@@ -16,9 +16,6 @@ export class OrganizationDepartmentUpdateHandler implements ICommandHandler<Orga
|
||||
const { id, input } = command;
|
||||
|
||||
//This will call save() with the id so that members[] also get saved accordingly
|
||||
return this.organizationDepartmentService.create({
|
||||
id,
|
||||
...input
|
||||
});
|
||||
return this.organizationDepartmentService.create({ ...input, id });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -55,10 +55,7 @@ export class OrganizationEmploymentTypeController extends CrudController<Organiz
|
||||
@Body() entity: OrganizationEmploymentType
|
||||
): Promise<IOrganizationEmploymentType> {
|
||||
try {
|
||||
return this.organizationEmploymentTypeService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return this.organizationEmploymentTypeService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -57,10 +57,7 @@ export class OrganizationPositionController extends CrudController<OrganizationP
|
||||
@Body() body: UpdateOrganizationPositionDTO
|
||||
): Promise<IOrganizationPosition> {
|
||||
try {
|
||||
return this.organizationPositionService.create({
|
||||
id,
|
||||
...body
|
||||
});
|
||||
return this.organizationPositionService.create({ ...body, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -61,10 +61,7 @@ export class OrganizationVendorController extends CrudController<OrganizationVen
|
||||
@Param('id', UUIDValidationPipe) id: string,
|
||||
@Body() body: OrganizationVendor
|
||||
): Promise<IOrganizationVendor> {
|
||||
return this.organizationVendorService.create({
|
||||
id,
|
||||
...body
|
||||
});
|
||||
return this.organizationVendorService.create({ ...body, id });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -169,10 +169,7 @@ export class PaymentController extends CrudController<Payment> {
|
||||
@UseValidationPipe({ transform: true, whitelist: true })
|
||||
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: UpdatePaymentDTO): Promise<IPayment> {
|
||||
try {
|
||||
return await this.paymentService.create({
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this.paymentService.create({ ...entity, id });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ export class ProductCategoryService extends TenantAwareCrudService<ProductCatego
|
||||
async updateProductCategory(id: ID, entity: ProductCategory): Promise<ProductCategory> {
|
||||
try {
|
||||
await super.delete(id);
|
||||
return this.save(entity);
|
||||
// Persist under the verified path id, never a body-supplied one (save() with an existing PK
|
||||
// updates THAT row).
|
||||
return this.save({ ...entity, id });
|
||||
} catch (err) {
|
||||
throw new BadRequestException(err);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,14 @@ export class ProductTypeService extends TenantAwareCrudService<ProductType> {
|
||||
async updateProductType(id: ID, entity: ProductType): Promise<ProductType> {
|
||||
try {
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
// Persist under the verified path id, never a body-supplied one (save() with an existing PK
|
||||
// updates THAT row — with a foreign id, another tenant's).
|
||||
entity.id = id;
|
||||
|
||||
if (this.ormType === MultiORMEnum.TypeORM) {
|
||||
// The transactional manager below is raw: TenantAwareCrudService's create/save guards do not
|
||||
// run, so the ownership check has to be explicit here.
|
||||
await this.assertNotForeignRow({ id } as any, tenantId);
|
||||
return await this.typeOrmRepository.manager.transaction(async (transactionalEntityManager) => {
|
||||
// 1. Ensure delete is scoped to the current tenant
|
||||
await transactionalEntityManager.delete(ProductType, {
|
||||
@@ -50,16 +56,19 @@ export class ProductTypeService extends TenantAwareCrudService<ProductType> {
|
||||
...(isNotEmpty(tenantId) ? { tenantId } : {})
|
||||
});
|
||||
|
||||
// 2. Ensure the entity injected has the correct tenantId before reproduction
|
||||
if (isNotEmpty(tenantId)) {
|
||||
entity.tenantId = tenantId;
|
||||
}
|
||||
|
||||
return await transactionalEntityManager.save(entity);
|
||||
// 2. Save with an EXPLICIT entity target and a plain payload. The route validates with
|
||||
// `transform: true`, so `entity` is a ProductTypeDTO instance; EntityManager.save()
|
||||
// resolves metadata from the constructor and threw EntityMetadataNotFoundError for it —
|
||||
// rolling the transaction back and turning every update into a 400.
|
||||
return await transactionalEntityManager.save(ProductType, {
|
||||
...entity,
|
||||
id,
|
||||
...(isNotEmpty(tenantId) ? { tenantId } : {})
|
||||
});
|
||||
});
|
||||
}
|
||||
await super.delete(id);
|
||||
return await this.save(entity);
|
||||
return await this.save({ ...entity, id });
|
||||
} catch (err) {
|
||||
throw new BadRequestException(err);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class SharedEntityController extends CrudController<SharedEntity> {
|
||||
})
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post()
|
||||
@UseValidationPipe()
|
||||
@UseValidationPipe({ whitelist: true })
|
||||
async create(@Body() entity: CreateSharedEntityDTO): Promise<SharedEntity> {
|
||||
return await this.commandBus.execute(new SharedEntityCreateCommand(entity));
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export class SharedEntityController extends CrudController<SharedEntity> {
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Put(':id')
|
||||
@UseValidationPipe()
|
||||
@UseValidationPipe({ whitelist: true })
|
||||
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateSharedEntityDTO): Promise<SharedEntity> {
|
||||
return await this.commandBus.execute(new SharedEntityUpdateCommand(id, entity));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { FindOptionsRelations, FindOptionsSelect } from "typeorm";
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
import { EntityMetadata, FindOptionsRelations, FindOptionsSelect } from "typeorm";
|
||||
import { isEmpty, isNotEmpty } from "@gauzy/utils";
|
||||
import { IShareRule } from "@gauzy/contracts";
|
||||
import { ID, IShareRule } from "@gauzy/contracts";
|
||||
|
||||
/**
|
||||
* Field names that must never be exposed through a shared-entity link, regardless of the
|
||||
@@ -23,18 +24,83 @@ export const SHARED_ENTITY_FORBIDDEN_FIELDS: ReadonlySet<string> = new Set([
|
||||
'twoFactorRecoveryCode'
|
||||
]);
|
||||
|
||||
/** How many relation hops a share may traverse from the root entity. */
|
||||
export const SHARED_ENTITY_MAX_RELATION_DEPTH = 2;
|
||||
|
||||
/**
|
||||
* The tenant/organization scope a share was created in; joined rows outside it are dropped.
|
||||
*/
|
||||
export interface ISharedEntityScope {
|
||||
tenantId: ID;
|
||||
organizationId?: ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `shareRules.relations` against the entity metadata: every hop must be a real relation,
|
||||
* every hop's TARGET must be tenant-scoped (carry a `tenantId` column), and the depth is bounded.
|
||||
*
|
||||
* Why: the token route is @Public() and only the ROOT row is tenant-scoped, so a share of an owned
|
||||
* Organization could pivot through a GLOBAL entity's inverse relations into every other tenant
|
||||
* (`Organization.featureOrganizations -> feature -> featureOrganizations -> tenant -> ...`,
|
||||
* `languages -> language -> organizationLanguages -> organization`, `reportOrganizations -> report
|
||||
* -> reportOrganizations`) — GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38.
|
||||
*
|
||||
* @param metadata - Metadata of the entity the rules apply to (root, then each hop's target).
|
||||
* @param rules - The share rules to validate.
|
||||
* @param depth - Current depth (internal).
|
||||
*/
|
||||
export function assertShareRulesAreSafe(metadata: EntityMetadata, rules: IShareRule, depth = 0): void {
|
||||
if (!rules || typeof rules !== 'object') {
|
||||
throw new BadRequestException('shareRules must be an object');
|
||||
}
|
||||
if (!Array.isArray(rules.fields)) {
|
||||
throw new BadRequestException('shareRules.fields must be an array');
|
||||
}
|
||||
if (isEmpty(rules.relations)) {
|
||||
return;
|
||||
}
|
||||
if (typeof rules.relations !== 'object') {
|
||||
throw new BadRequestException('shareRules.relations must be an object');
|
||||
}
|
||||
if (depth >= SHARED_ENTITY_MAX_RELATION_DEPTH) {
|
||||
throw new BadRequestException(
|
||||
`shareRules.relations may not be nested deeper than ${SHARED_ENTITY_MAX_RELATION_DEPTH} levels`
|
||||
);
|
||||
}
|
||||
for (const [relationName, subRules] of Object.entries(rules.relations)) {
|
||||
const relation = metadata.findRelationWithPropertyPath(relationName);
|
||||
if (!relation) {
|
||||
throw new BadRequestException(`Unknown relation "${relationName}" on ${metadata.name}`);
|
||||
}
|
||||
const target = relation.inverseEntityMetadata;
|
||||
// A hop into a global (tenant-less) entity — Tenant, Language, Feature, Report, Currency,
|
||||
// Country, Integration, ... — cannot be tenant-filtered and is where cross-tenant pivots start.
|
||||
if (!target.findColumnWithPropertyPath('tenantId')) {
|
||||
throw new ForbiddenException(
|
||||
`Relation "${relationName}" (${target.name}) is not tenant-scoped and cannot be shared`
|
||||
);
|
||||
}
|
||||
assertShareRulesAreSafe(target, (subRules ?? { fields: [] }) as IShareRule, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the select for the shared entity.
|
||||
*
|
||||
* @param rules - The share rules for the shared entity.
|
||||
* @param metadata - Metadata of the entity the rules apply to; when given, the tenant/organization
|
||||
* scope columns are always selected (never returned unless requested) so joined rows can be
|
||||
* filtered to the share's scope after the query — see {@link filterSharedEntity}.
|
||||
* @returns The select for the shared entity.
|
||||
*/
|
||||
export function buildSharedEntitySelect(rules: IShareRule): FindOptionsSelect<any> {
|
||||
export function buildSharedEntitySelect(rules: IShareRule, metadata?: EntityMetadata): FindOptionsSelect<any> {
|
||||
const select: FindOptionsSelect<any> = {
|
||||
id: true // Always include the primary key for TypeORM to work correctly
|
||||
};
|
||||
|
||||
if (metadata?.findColumnWithPropertyPath('tenantId')) select['tenantId'] = true;
|
||||
if (metadata?.findColumnWithPropertyPath('organizationId')) select['organizationId'] = true;
|
||||
|
||||
// Add the fields to the select (never expose forbidden/sensitive columns)
|
||||
for (const field of rules.fields) {
|
||||
if (SHARED_ENTITY_FORBIDDEN_FIELDS.has(field)) continue;
|
||||
@@ -44,7 +110,8 @@ export function buildSharedEntitySelect(rules: IShareRule): FindOptionsSelect<an
|
||||
// Add the relations to the select
|
||||
if (isNotEmpty(rules.relations)) {
|
||||
for (const [relation, subRules] of Object.entries(rules.relations)) {
|
||||
select[relation] = buildSharedEntitySelect(subRules as IShareRule);
|
||||
const target = metadata?.findRelationWithPropertyPath(relation)?.inverseEntityMetadata;
|
||||
select[relation] = buildSharedEntitySelect(subRules as IShareRule, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,14 +139,35 @@ export function buildSharedEntityRelations(rules: IShareRule): FindOptionsRelati
|
||||
return relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a joined row belongs to the share's scope. Rows that carry a tenantId must match the
|
||||
* share's tenant; rows that carry an organizationId must match the share's organization when the
|
||||
* share has one. Rows without those columns cannot be judged and are kept (the relation validator
|
||||
* already refuses hops into tenant-less entities).
|
||||
*/
|
||||
function isWithinScope(row: any, scope?: ISharedEntityScope): boolean {
|
||||
if (!scope || !row || typeof row !== 'object') return true;
|
||||
if ('tenantId' in row && row.tenantId && String(row.tenantId) !== String(scope.tenantId)) return false;
|
||||
if (
|
||||
scope.organizationId &&
|
||||
'organizationId' in row &&
|
||||
row.organizationId &&
|
||||
String(row.organizationId) !== String(scope.organizationId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the entity based on the share rules.
|
||||
*
|
||||
* @param entity - The entity to filter.
|
||||
* @param rules - The share rules for the shared entity.
|
||||
* @param scope - The share's tenant/organization; joined rows outside it are dropped.
|
||||
* @returns The filtered entity.
|
||||
*/
|
||||
export function filterSharedEntity(entity: any, rules: IShareRule): any {
|
||||
export function filterSharedEntity(entity: any, rules: IShareRule, scope?: ISharedEntityScope): any {
|
||||
const result: any = {};
|
||||
|
||||
for (const field of rules.fields) {
|
||||
@@ -92,17 +180,17 @@ export function filterSharedEntity(entity: any, rules: IShareRule): any {
|
||||
if (!entity[relation]) continue;
|
||||
|
||||
if (Array.isArray(entity[relation])) {
|
||||
result[relation] = entity[relation].map(item =>
|
||||
filterSharedEntity(item, subRules as IShareRule)
|
||||
);
|
||||
} else {
|
||||
result[relation] = filterSharedEntity(entity[relation], subRules as IShareRule);
|
||||
result[relation] = entity[relation]
|
||||
.filter((item: any) => isWithinScope(item, scope))
|
||||
.map((item: any) => filterSharedEntity(item, subRules as IShareRule, scope));
|
||||
} else if (isWithinScope(entity[relation], scope)) {
|
||||
result[relation] = filterSharedEntity(entity[relation], subRules as IShareRule, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the result
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, HttpException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, FindOneOptions, Repository } from "typeorm";
|
||||
import { DataSource, FindOneOptions, Repository, UpdateResult } from "typeorm";
|
||||
|
||||
import { BaseEntityEnum, ID, ISharedEntityCreateInput, IShareRule } from "@gauzy/contracts";
|
||||
import { RequestContext } from "../core/context";
|
||||
@@ -8,7 +8,13 @@ import { TenantAwareCrudService } from "../core/crud";
|
||||
import { MikroOrmSharedEntityRepository } from "./repository/mikro-orm-shared-entity.repository";
|
||||
import { TypeOrmSharedEntityRepository } from "./repository/type-orm-shared-entity.repository";
|
||||
import { SharedEntity } from "./shared-entity.entity";
|
||||
import { buildSharedEntityRelations, buildSharedEntitySelect, filterSharedEntity, generateSharedEntityToken } from "./shared-entity.helper";
|
||||
import {
|
||||
assertShareRulesAreSafe,
|
||||
buildSharedEntityRelations,
|
||||
buildSharedEntitySelect,
|
||||
filterSharedEntity,
|
||||
generateSharedEntityToken
|
||||
} from "./shared-entity.helper";
|
||||
|
||||
@Injectable()
|
||||
export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
@@ -38,6 +44,8 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
// share-link pointing at another tenant's record by its id (cross-tenant IDOR -
|
||||
// GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
|
||||
const targetRepository = this.resolveRepository(input.entity);
|
||||
// Only real, tenant-scoped relations, to a bounded depth (see assertShareRulesAreSafe).
|
||||
assertShareRulesAreSafe(targetRepository.metadata, this.parseShareRules(input.shareRules));
|
||||
const owned = await targetRepository.findOne({
|
||||
where: this.buildScopedWhere(targetRepository, input.entityId, tenantId, organizationId)
|
||||
});
|
||||
@@ -84,10 +92,13 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
throw new NotFoundException('Shared entity not found');
|
||||
}
|
||||
|
||||
const shareRules = sharedEntity.shareRules as IShareRule;
|
||||
const shareRules = this.parseShareRules(sharedEntity.shareRules);
|
||||
|
||||
// Get the repository for the shared entity
|
||||
const repository = this.resolveRepository(sharedEntity.entity);
|
||||
// Re-validated at resolution time too: rules stored before this guard existed (or edited
|
||||
// through another path) must not be able to traverse out of the tenant.
|
||||
assertShareRulesAreSafe(repository.metadata, shareRules);
|
||||
|
||||
// Construct the find options for the shared entity.
|
||||
// Scope the lookup to the share's OWN tenant/organization so a token issued by tenant A can
|
||||
@@ -108,8 +119,11 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
throw new NotFoundException('Entity not found');
|
||||
}
|
||||
|
||||
// Return the entity
|
||||
return filterSharedEntity(entity, shareRules);
|
||||
// Return the entity, dropping any joined row outside the share's own scope
|
||||
return filterSharedEntity(entity, shareRules, {
|
||||
tenantId: sharedEntity.tenantId,
|
||||
organizationId: sharedEntity.organizationId
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NotFoundException(`Failed to get shared entity by token: ${error?.message || error}`);
|
||||
}
|
||||
@@ -134,7 +148,7 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
): FindOneOptions<any> {
|
||||
return {
|
||||
where: this.buildScopedWhere(repository, entityId, tenantId, organizationId),
|
||||
select: buildSharedEntitySelect(rules),
|
||||
select: buildSharedEntitySelect(rules, repository.metadata),
|
||||
relations: buildSharedEntityRelations(rules)
|
||||
}
|
||||
}
|
||||
@@ -161,21 +175,68 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
|
||||
): Record<string, any> {
|
||||
const where: Record<string, any> = { id: entityId };
|
||||
const hasTenantColumn = !!repository.metadata.findColumnWithPropertyName('tenantId');
|
||||
// Fail closed: if the target entity IS tenant-scoped (has a tenantId column) but we have no
|
||||
// tenantId to scope by, refuse rather than fall back to an `id`-only lookup that would bypass
|
||||
// tenant isolation on the public token route (GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
|
||||
if (hasTenantColumn) {
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Cannot resolve a tenant-scoped entity without a tenant context');
|
||||
}
|
||||
where['tenantId'] = tenantId;
|
||||
// Global (tenant-less) entity types — Tenant, Language, Currency, ... — are NOT shareable:
|
||||
// there is no tenant column to scope them by, so the lookup would be id-only and their inverse
|
||||
// relations reach into every tenant (GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
|
||||
if (!hasTenantColumn) {
|
||||
throw new ForbiddenException(`Entity "${repository.metadata.name}" is not tenant-scoped and cannot be shared`);
|
||||
}
|
||||
// Fail closed: a tenant-scoped entity without a tenant to scope by must not fall back to an
|
||||
// `id`-only lookup on the public token route.
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Cannot resolve a tenant-scoped entity without a tenant context');
|
||||
}
|
||||
where['tenantId'] = tenantId;
|
||||
if (organizationId && repository.metadata.findColumnWithPropertyName('organizationId')) {
|
||||
where['organizationId'] = organizationId;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a share. The target (entity/entityId) and the token are pinned at creation and never
|
||||
* client-editable; when the rules change they are re-validated against the target's metadata.
|
||||
*
|
||||
* @param id - The shared entity id.
|
||||
* @param input - The update payload.
|
||||
* @returns The updated shared entity.
|
||||
*/
|
||||
public async update(id: string, input: Partial<SharedEntity>): Promise<SharedEntity | UpdateResult> {
|
||||
const {
|
||||
id: _id,
|
||||
entity: _entity,
|
||||
entityId: _entityId,
|
||||
token: _token,
|
||||
tenant: _tenant,
|
||||
tenantId: _tenantId,
|
||||
organization: _organization,
|
||||
organizationId: _organizationId,
|
||||
...safeInput
|
||||
} = (input ?? {}) as any;
|
||||
if (safeInput.shareRules !== undefined) {
|
||||
const existing = await this.findOneByIdString(id);
|
||||
const targetRepository = this.resolveRepository(existing.entity);
|
||||
assertShareRulesAreSafe(targetRepository.metadata, this.parseShareRules(safeInput.shareRules));
|
||||
}
|
||||
return await super.update(id, safeInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* shareRules is stored as JSON(B) on Postgres/MySQL and as text on SQLite; normalise to an object.
|
||||
*
|
||||
* @param shareRules - The stored or submitted rules.
|
||||
*/
|
||||
private parseShareRules(shareRules: IShareRule | string): IShareRule {
|
||||
if (typeof shareRules === 'string') {
|
||||
try {
|
||||
return JSON.parse(shareRules) as IShareRule;
|
||||
} catch {
|
||||
throw new BadRequestException('shareRules must be valid JSON');
|
||||
}
|
||||
}
|
||||
return shareRules as IShareRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the repository for the given entity name.
|
||||
*
|
||||
|
||||
+10
-4
@@ -1,6 +1,7 @@
|
||||
import { ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { StatusTypesMapRequestApprovalEnum } from '@gauzy/contracts';
|
||||
import { RequestContext } from '../../../core/context';
|
||||
import { TimeOffStatusCommand } from '../time-off.status.command';
|
||||
import { TimeOffRequest } from '../../time-off-request.entity';
|
||||
import { TypeOrmTimeOffRequestRepository } from '../../repository/type-orm-time-off-request.repository';
|
||||
@@ -15,12 +16,17 @@ export class TimeOffStatusHandler implements ICommandHandler<TimeOffStatusComman
|
||||
|
||||
public async execute(command?: TimeOffStatusCommand): Promise<TimeOffRequest> {
|
||||
const { id, status } = command;
|
||||
// Both repositories are RAW (not tenant-aware): the request and its approval row must be
|
||||
// resolved inside the caller's tenant, or an admin of tenant A could approve / deny any tenant
|
||||
// B request by UUID (GHSA-gwpq-mmw7-vx85 class).
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
if (!id || !tenantId) {
|
||||
throw new NotFoundException('Request time off not found');
|
||||
}
|
||||
|
||||
const [timeOffRequest, requestApproval] = await Promise.all([
|
||||
await this.typeOrmTimeOffRequestRepository.findOneBy({ id }),
|
||||
await this.typeOrmRequestApprovalRepository.findOneBy({
|
||||
requestId: id
|
||||
})
|
||||
this.typeOrmTimeOffRequestRepository.findOneBy({ id, tenantId }),
|
||||
this.typeOrmRequestApprovalRepository.findOneBy({ requestId: id, tenantId })
|
||||
]);
|
||||
|
||||
if (!timeOffRequest) {
|
||||
|
||||
@@ -130,11 +130,13 @@ export class TimeOffRequestService extends TenantAwareCrudService<TimeOffRequest
|
||||
|
||||
async updateTimeOffByAdmin(id: string, timeOffRequest: ITimeOffCreateInput) {
|
||||
try {
|
||||
// Verify the record belongs to the current tenant before updating
|
||||
// Verify the record belongs to the current tenant before updating, and make the verified id
|
||||
// the one that is saved: the body is not DTO-validated, so a body id spread after the path id
|
||||
// used to retarget the save (TypeORM save() with an existing PK is an UPDATE of that row).
|
||||
await this.findOneByIdString(id);
|
||||
return await this.save({
|
||||
id,
|
||||
...timeOffRequest
|
||||
...timeOffRequest,
|
||||
id
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IUser } from '@gauzy/contracts';
|
||||
import { IUser, PermissionsEnum, RolesEnum } from '@gauzy/contracts';
|
||||
import { RequestContext } from '../../../core/context';
|
||||
import { UserCreateCommand } from '../user.create.command';
|
||||
import { UserService } from '../../user.service';
|
||||
|
||||
@@ -15,6 +17,13 @@ export class UserCreateHandler implements ICommandHandler<UserCreateCommand> {
|
||||
*/
|
||||
public async execute(command: UserCreateCommand): Promise<IUser> {
|
||||
const { input } = command;
|
||||
|
||||
// Creating a SUPER_ADMIN is reserved to callers who may edit super admins — the same boundary
|
||||
// the register handler and invite creation enforce. Both the flat `roleId` and the `role`
|
||||
// relation are resolved from the database (the relation wins on persist), and an id that does
|
||||
// not belong to the caller's tenant is refused rather than ignored.
|
||||
await this.userService.assertCanAssignRoles([input?.roleId, input?.role?.id]);
|
||||
|
||||
return await this.userService.create(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IUserUpdateInput } from '@gauzy/contracts';
|
||||
import { IntersectionType, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { User } from '../user.entity';
|
||||
import { CreateUserDTO } from './create-user.dto';
|
||||
|
||||
@@ -14,9 +15,26 @@ class UpdateUserBaseDTO extends PickType(User, [
|
||||
'isActive'
|
||||
] as const) {}
|
||||
|
||||
/**
|
||||
* The credential half of a profile update.
|
||||
*
|
||||
* `hash` carries the NEW PASSWORD in clear text (`UserService.updateProfile` hashes it before the
|
||||
* write) — that is the long-standing contract the profile form uses. It is declared explicitly so the
|
||||
* route can validate with `whitelist: true`: every property the DTO does not declare is stripped, which
|
||||
* is what keeps identity/verification columns (`id`, `emailVerifiedAt`, `emailToken`, `refreshToken`, …)
|
||||
* out of a body that is otherwise spread straight onto the entity.
|
||||
*/
|
||||
class UpdateUserCredentialsDTO {
|
||||
@ApiPropertyOptional({ type: () => String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly hash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User Data Transfer Object (DTO) validation.
|
||||
*/
|
||||
export class UpdateUserDTO
|
||||
extends IntersectionType(PartialType(CreateUserDTO), UpdateUserBaseDTO)
|
||||
extends IntersectionType(PartialType(CreateUserDTO), IntersectionType(UpdateUserBaseDTO, UpdateUserCredentialsDTO))
|
||||
implements IUserUpdateInput {}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { RolesEnum } from '@gauzy/contracts';
|
||||
import { assertRoleAssignmentAllowed } from './role-assignment.helper';
|
||||
|
||||
/**
|
||||
* Regression suite for the SUPER_ADMIN assignment boundary (GHSA-hjcg-633x-qq74 / GHSA-x4mv-fhwj-g3rp
|
||||
* residuals).
|
||||
*
|
||||
* Two ways the previous gates leaked:
|
||||
* - they read the role name from the CLIENT (`input.user.role.name`), so a role object carrying only
|
||||
* an id — or a spoofed name — skipped the check while the id was still persisted;
|
||||
* - they resolved only ONE identifier (`roleId ?? role.id`), while the `role` RELATION wins on
|
||||
* persist, so a harmless `roleId` next to a privileged `role: { id }` validated the harmless one;
|
||||
* - and an id that did not resolve inside the caller's tenant was treated as "not a super admin",
|
||||
* which let an attacker who also owns a second tenant pass that tenant's SUPER_ADMIN role id.
|
||||
*/
|
||||
describe('assertRoleAssignmentAllowed', () => {
|
||||
it('allows an ordinary role for any caller', () => {
|
||||
expect(() => assertRoleAssignmentAllowed(RolesEnum.EMPLOYEE, false)).not.toThrow();
|
||||
expect(() => assertRoleAssignmentAllowed(RolesEnum.ADMIN, false)).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows SUPER_ADMIN only for a caller holding SUPER_ADMIN_EDIT', () => {
|
||||
expect(() => assertRoleAssignmentAllowed(RolesEnum.SUPER_ADMIN, true)).not.toThrow();
|
||||
expect(() => assertRoleAssignmentAllowed(RolesEnum.SUPER_ADMIN, false)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it.each([[undefined], ['']])('refuses an id that did not resolve in the tenant (%p) — fail closed', (roleName) => {
|
||||
// A cross-tenant SUPER_ADMIN role id resolves to undefined here; it must NOT read as "harmless".
|
||||
expect(() => assertRoleAssignmentAllowed(roleName as any, false)).toThrow(BadRequestException);
|
||||
expect(() => assertRoleAssignmentAllowed(roleName as any, true)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('CONTROL: the pre-fix shape (only the first identifier checked) would have admitted the escalation', () => {
|
||||
// Body: { roleId: <EMPLOYEE>, role: { id: <SUPER_ADMIN> } } — the relation is what gets persisted.
|
||||
const resolved = { roleId: RolesEnum.EMPLOYEE, relation: RolesEnum.SUPER_ADMIN };
|
||||
const preFix = () => assertRoleAssignmentAllowed(resolved.roleId, false); // `roleId ?? role.id`
|
||||
const fixed = () => [resolved.roleId, resolved.relation].forEach((name) => assertRoleAssignmentAllowed(name, false));
|
||||
expect(preFix).not.toThrow();
|
||||
expect(fixed).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { RolesEnum } from '@gauzy/contracts';
|
||||
|
||||
/**
|
||||
* Decides whether a caller may assign a role, given the role's name **as resolved from the database**
|
||||
* and whether the caller holds `SUPER_ADMIN_EDIT`.
|
||||
*
|
||||
* Two rules, both fail-closed:
|
||||
*
|
||||
* 1. An unresolvable role id is refused. `UserService.resolveRoleName()` scopes its lookup to the
|
||||
* caller's tenant, so a role id borrowed from another tenant resolves to `undefined` — treating
|
||||
* that as "not a super admin" would let an attacker who owns a second tenant on the same
|
||||
* deployment pass their own tenant's SUPER_ADMIN role id through the gate.
|
||||
* 2. Granting SUPER_ADMIN requires `SUPER_ADMIN_EDIT` — the same boundary the register handler and
|
||||
* invite creation enforce (GHSA-hjcg-633x-qq74 / GHSA-x4mv-fhwj-g3rp).
|
||||
*
|
||||
* Callers must apply this to EVERY role identifier in the payload (both the flat `roleId` and the
|
||||
* `role` relation): the relation wins when the row is persisted, so validating only one of them lets
|
||||
* a body pair a harmless `roleId` with a privileged `role: { id }`.
|
||||
*
|
||||
* @param roleName - The role name resolved from the database, or undefined when it did not resolve.
|
||||
* @param canEditSuperAdmin - Whether the caller holds `PermissionsEnum.SUPER_ADMIN_EDIT`.
|
||||
*/
|
||||
export function assertRoleAssignmentAllowed(roleName: string | undefined, canEditSuperAdmin: boolean): void {
|
||||
if (!roleName) {
|
||||
throw new BadRequestException('The specified role does not exist in this tenant.');
|
||||
}
|
||||
if (roleName === RolesEnum.SUPER_ADMIN && !canEditSuperAdmin) {
|
||||
throw new ForbiddenException('Only a super admin may assign the super admin role.');
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ export class UserController extends CrudController<User> {
|
||||
@Permissions(PermissionsEnum.ORG_USERS_EDIT)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('/')
|
||||
@UseValidationPipe()
|
||||
@UseValidationPipe({ whitelist: true })
|
||||
async create(@Body() entity: CreateUserDTO): Promise<IUser> {
|
||||
return await this._commandBus.execute(new UserCreateCommand(entity));
|
||||
}
|
||||
@@ -263,12 +263,9 @@ export class UserController extends CrudController<User> {
|
||||
@UseGuards(TenantPermissionGuard, PermissionGuard)
|
||||
@Permissions(PermissionsEnum.ORG_USERS_EDIT, PermissionsEnum.PROFILE_EDIT)
|
||||
@Put('/:id')
|
||||
@UseValidationPipe({ transform: true })
|
||||
@UseValidationPipe({ transform: true, whitelist: true })
|
||||
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateUserDTO): Promise<IUser> {
|
||||
return await this._userService.updateProfile(id, {
|
||||
id,
|
||||
...entity
|
||||
});
|
||||
return await this._userService.updateProfile(id, { ...entity, id });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ import { User } from './user.entity';
|
||||
import { validateUserDeletion } from './default-protected-users';
|
||||
import { assertUiPreferencesSize, mergeUiPreferences, sanitizeUiPreferencesPatch } from './ui-preferences.util';
|
||||
import { PasswordHashService } from '../password-hash/password-hash.service';
|
||||
import { assertRoleAssignmentAllowed } from './role-assignment.helper';
|
||||
import {
|
||||
emailVerificationClaimWhere,
|
||||
emailVerificationClaimWhereMikroOrm,
|
||||
@@ -382,6 +383,11 @@ export class UserService extends TenantAwareCrudService<User> {
|
||||
* @throws ForbiddenException if the user lacks the required permissions or attempts unauthorized updates.
|
||||
*/
|
||||
async updateProfile(id: ID | number, entity: User): Promise<IUser> {
|
||||
// The path id is authoritative. Every check below authorizes THIS id, and save() persists the
|
||||
// entity's id — a body `id` (the update DTO is not whitelisted) must never retarget the write to
|
||||
// another user (e.g. overwrite the SUPER_ADMIN's password hash from a PROFILE_EDIT account).
|
||||
entity.id = id as ID;
|
||||
|
||||
// Retrieve the current user's role ID from the RequestContext
|
||||
const currentRoleId = RequestContext.currentRoleId();
|
||||
const currentUserId = RequestContext.currentUserId();
|
||||
@@ -432,6 +438,11 @@ export class UserService extends TenantAwareCrudService<User> {
|
||||
if (requestedRoleIds.some((roleId) => String(roleId) !== String(currentRoleId))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
} else {
|
||||
// Updating SOMEONE ELSE: granting SUPER_ADMIN is reserved to callers who may edit super
|
||||
// admins (the same boundary the register handler and invite creation enforce). The role is
|
||||
// resolved from the database — never from a client-supplied role name.
|
||||
await this.assertCanAssignRoles([entity.role?.id, entity.roleId]);
|
||||
}
|
||||
|
||||
// Update password hash if provided
|
||||
@@ -810,6 +821,52 @@ export class UserService extends TenantAwareCrudService<User> {
|
||||
return this._passwordHashService.hash(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a payload that assigns a role the caller may not grant.
|
||||
*
|
||||
* @param roleIds Every role identifier in the payload — both the flat `roleId` and `role.id`.
|
||||
* @throws BadRequestException When an id does not resolve inside the caller's tenant.
|
||||
* @throws ForbiddenException When SUPER_ADMIN is requested without `SUPER_ADMIN_EDIT`.
|
||||
*/
|
||||
public async assertCanAssignRoles(roleIds: Array<ID | undefined>): Promise<void> {
|
||||
// EVERY candidate is checked, not just the first: the entity carries both a `role` relation and a
|
||||
// flat `roleId` column, and the RELATION wins when the row is persisted — so a body sending a
|
||||
// harmless `roleId` next to a privileged `role: { id }` must not validate the harmless one.
|
||||
const candidates = roleIds.filter((roleId) => isNotEmpty(roleId)) as ID[];
|
||||
const canEditSuperAdmin = RequestContext.hasPermission(PermissionsEnum.SUPER_ADMIN_EDIT);
|
||||
for (const roleId of candidates) {
|
||||
assertRoleAssignmentAllowed(await this.resolveRoleName(roleId), canEditSuperAdmin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the name of a role of the caller's tenant from the database (by entity name, to avoid
|
||||
* a role -> user -> role import cycle). Returns undefined for an unknown / foreign role.
|
||||
*
|
||||
* @param roleId The role id to resolve.
|
||||
*/
|
||||
public async resolveRoleName(roleId: ID): Promise<string | undefined> {
|
||||
if (!roleId) {
|
||||
return undefined;
|
||||
}
|
||||
const tenantId = RequestContext.currentTenantId();
|
||||
switch (this.ormType) {
|
||||
case MultiORMEnum.MikroORM: {
|
||||
const role = await this.mikroOrmRepository
|
||||
.getEntityManager()
|
||||
.findOne('Role', { id: roleId, ...(tenantId ? { tenantId } : {}) } as any);
|
||||
return (role as any)?.name;
|
||||
}
|
||||
case MultiORMEnum.TypeORM:
|
||||
default: {
|
||||
const role = await this.typeOrmRepository.manager.findOne('Role', {
|
||||
where: { id: roleId, ...(tenantId ? { tenantId } : {}) } as any
|
||||
});
|
||||
return (role as any)?.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To permanently delete your account from your Gauzy app:
|
||||
*
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
PermissionGuard,
|
||||
Permissions,
|
||||
RequestContext,
|
||||
TenantPermissionGuard
|
||||
TenantPermissionGuard,
|
||||
UploadedFileStorage,
|
||||
documentUploadFileFilter
|
||||
} from '@gauzy/core';
|
||||
import { AiChatService, MAX_AUDIO_BYTES } from './ai-chat.service';
|
||||
import {
|
||||
@@ -39,6 +41,13 @@ import {
|
||||
MAX_ATTACHMENT_BYTES
|
||||
} from './attachments/ai-chat-attachment.service';
|
||||
|
||||
/**
|
||||
* Object-name extensions a static file server would render in the browser (mirrors the Documents
|
||||
* upload endpoint). The stored object carries a neutral extension instead; the client keeps the
|
||||
* real type via `mimeType`.
|
||||
*/
|
||||
const RENDERABLE_KEY_EXTENSIONS = new Set(['html', 'htm', 'xhtml', 'xml', 'svg', 'svgz', 'js', 'mjs', 'css']);
|
||||
|
||||
/**
|
||||
* Per-request storage engine of the attachment endpoint.
|
||||
*
|
||||
@@ -60,7 +69,14 @@ const attachmentsStorage = (ctx: ExecutionContext) => {
|
||||
const safeExtension = String(extension ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
return safeExtension ? `${randomUUID()}.${safeExtension}` : `${randomUUID()}`;
|
||||
if (!safeExtension) {
|
||||
return `${randomUUID()}`;
|
||||
}
|
||||
// Never let a browser-renderable extension onto the stored object name — same rule as the
|
||||
// Documents upload endpoint (the LOCAL provider serves /public/<key> with a Content-Type
|
||||
// derived from the extension; the canonical type travels in the attachment's mimeType).
|
||||
const storedExtension = RENDERABLE_KEY_EXTENSIONS.has(safeExtension) ? 'bin' : safeExtension;
|
||||
return `${randomUUID()}.${storedExtension}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -210,13 +226,21 @@ export class AiChatController {
|
||||
@UseInterceptors(
|
||||
LazyFileInterceptor('file', {
|
||||
storage: (ctx: ExecutionContext) => attachmentsStorage(ctx),
|
||||
// Documents of (almost) any type are ingested here, so no allowlist — but script-capable
|
||||
// non-document types (.svg, .xhtml, .mhtml, .hta, .js, ...) have no business being stored
|
||||
// under /public with the client's extension.
|
||||
fileFilter: documentUploadFileFilter,
|
||||
// The same constant the service's cap derives from, declared here so an oversized
|
||||
// upload is rejected by multer BEFORE the provider stores any of it.
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES }
|
||||
})
|
||||
)
|
||||
async attach(
|
||||
@UploadedFile() file: IUploadedFile,
|
||||
// Core's decorator maps the multer object through the ACTIVE provider (mapUploadedFile), which
|
||||
// is what fills `key` (LOCAL derives it from `path`; S3-family providers set it themselves).
|
||||
// Nest's plain @UploadedFile() hands the raw diskStorage object over, which has no `key` — so
|
||||
// on the default LOCAL provider every attachment answered 400 after the bytes were written.
|
||||
@UploadedFileStorage() file: IUploadedFile,
|
||||
@Body() body: { conversationId?: string }
|
||||
): Promise<IAiChatAttachmentResult> {
|
||||
return this.attachmentService.save(file, body?.conversationId);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// `@gauzy/core` pulls the whole bootstrap graph; only `RequestContext` is on the path under
|
||||
// test, so it is stubbed AT THE MODULE BOUNDARY — hoisted above the imports.
|
||||
const deleteFile = jest.fn(async () => undefined);
|
||||
jest.mock('@gauzy/core', () => ({
|
||||
RequestContext: {
|
||||
currentTenantId: jest.fn(() => 'tenant-1'),
|
||||
currentOrganizationId: jest.fn(() => 'org-1'),
|
||||
currentUserId: jest.fn(() => 'user-1'),
|
||||
currentRequest: jest.fn(() => ({ headers: {} }))
|
||||
}
|
||||
},
|
||||
// The service discards a rejected upload through the active provider.
|
||||
FileStorage: jest.fn().mockImplementation(() => ({ getProvider: () => ({ deleteFile }) }))
|
||||
}));
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
@@ -97,12 +100,36 @@ describe('AiChatAttachmentService', () => {
|
||||
* Without a scope the consumer would have to GUESS which organization the file belongs to,
|
||||
* which is how an attachment lands in the wrong workspace. Refusing is the only safe answer.
|
||||
*/
|
||||
it('refuses an attachment it cannot attribute to an organization', async () => {
|
||||
it('refuses an attachment it cannot attribute to an organization — and removes the stored bytes', async () => {
|
||||
requestContext.currentOrganizationId.mockReturnValue(null);
|
||||
requestContext.currentRequest.mockReturnValue({ headers: {} });
|
||||
deleteFile.mockClear();
|
||||
|
||||
await expect(service.save(uploaded())).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
// multer already wrote the object before the service ran: it must not be left orphaned.
|
||||
expect(deleteFile).toHaveBeenCalledWith('ai-chat/tenant-1/org-1/abc.pdf');
|
||||
});
|
||||
|
||||
it('still rejects (with the scope error) when the orphan cleanup itself fails', async () => {
|
||||
requestContext.currentOrganizationId.mockReturnValue(null);
|
||||
requestContext.currentRequest.mockReturnValue({ headers: {} });
|
||||
deleteFile.mockRejectedValueOnce(new Error('disk gone'));
|
||||
|
||||
await expect(service.save(uploaded())).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
/**
|
||||
* On the default LOCAL provider Nest's plain @UploadedFile() hands over multer's diskStorage
|
||||
* object, which has no `key` (only core's @UploadedFileStorage() maps it through the provider,
|
||||
* which derives `key` from `path`). The controller now uses that decorator; this pins the
|
||||
* contract the service relies on: a mapped file HAS a key and is accepted.
|
||||
*/
|
||||
it('accepts a provider-mapped LOCAL file (key derived from path)', async () => {
|
||||
const result = await service.save(
|
||||
uploaded({ path: '/srv/public/ai-chat/tenant-1/org-1/abc.pdf', key: 'ai-chat/tenant-1/org-1/abc.pdf' })
|
||||
);
|
||||
expect(result.key).toBe('ai-chat/tenant-1/org-1/abc.pdf');
|
||||
});
|
||||
|
||||
it('rejects a request with no uploaded file', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ID, UploadedFile } from '@gauzy/contracts';
|
||||
import { EventBus, RequestContext } from '@gauzy/core';
|
||||
import { EventBus, FileStorage, RequestContext } from '@gauzy/core';
|
||||
import { AiChatAttachmentSavedEvent } from './ai-chat-attachment.event';
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,9 @@ export class AiChatAttachmentService {
|
||||
const organizationId = this.resolveOrganizationId();
|
||||
if (!tenantId || !organizationId) {
|
||||
// Without a scope the attachment cannot be attributed to anything, and a consumer
|
||||
// would have to guess — which is how a file ends up in the wrong organization.
|
||||
// would have to guess — which is how a file ends up in the wrong organization. The bytes
|
||||
// are already in storage (multer ran first): remove them rather than leave an orphan.
|
||||
await this.discardStoredFile(file.key);
|
||||
throw new BadRequestException(
|
||||
'An organization is required to attach a file — send the `Organization-Id` header.'
|
||||
);
|
||||
@@ -100,6 +102,22 @@ export class AiChatAttachmentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a stored object that will not be recorded (rejected upload). Best effort: a failure
|
||||
* to delete must not mask the rejection the caller is about to see.
|
||||
*
|
||||
* @param key The storage key of the object.
|
||||
*/
|
||||
private async discardStoredFile(key: string): Promise<void> {
|
||||
try {
|
||||
await new FileStorage().getProvider().deleteFile(key);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Could not remove rejected attachment '${key}': ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The requesting organization: the request context first, then the `Organization-Id` header
|
||||
* the web client sends on every call (the JWT itself carries no organization).
|
||||
|
||||
@@ -11,6 +11,6 @@ export class ChangelogUpdateHandler
|
||||
public async execute(command: ChangelogUpdateCommand): Promise<IChangelog> {
|
||||
const { input } = command;
|
||||
const { id } = input;
|
||||
return this.changelogService.create({ id, ...input });
|
||||
return this.changelogService.create({ ...input, id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,17 @@ export class ChatCaptureSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
const sniff = sniffFile(buffer, fileName, payload.file.mimetype);
|
||||
if (!sniff.ok) {
|
||||
this.logger.warn(`Chat attachment '${fileName.slice(0, 40)}' rejected: ${sniff.code}`);
|
||||
// Nothing will ever reference a REJECTED object: remove it instead of leaving an orphan
|
||||
// (best effort — the rejection stands either way). Only when the bytes were actually read:
|
||||
// LocalProvider.getFile swallows fs errors and returns undefined, and a transient read
|
||||
// failure must not destroy a valid attachment.
|
||||
if (buffer?.length) {
|
||||
try {
|
||||
await provider.deleteFile(payload.file.key);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not remove rejected chat attachment '${payload.file.key}': ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ID
|
||||
} from '@gauzy/contracts';
|
||||
import { PermissionGuard, Permissions, TenantPermissionGuard, UUIDValidationPipe } from '@gauzy/core';
|
||||
import { HubstaffService } from './hubstaff.service';
|
||||
import { HubstaffService, IHubstaffAccessTokenResponse } from './hubstaff.service';
|
||||
|
||||
@ApiTags('Hubstaff Integrations')
|
||||
@UseGuards(TenantPermissionGuard, PermissionGuard)
|
||||
@@ -37,12 +37,12 @@ export class HubstaffController {
|
||||
* Refresh Hubstaff token by integration ID
|
||||
*
|
||||
* @param integrationId The ID of the integration
|
||||
* @returns The refreshed Hubstaff token
|
||||
* @returns The refreshed Hubstaff access token (the refresh token itself stays server-side)
|
||||
*/
|
||||
@Get('/refresh-token/:integrationId')
|
||||
async refreshHubstaffTokenByIntegration(
|
||||
@Param('integrationId', UUIDValidationPipe) integrationId: ID
|
||||
): Promise<string> {
|
||||
): Promise<IHubstaffAccessTokenResponse> {
|
||||
return await this._hubstaffService.refreshToken(integrationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ import {
|
||||
} from '@gauzy/core';
|
||||
import { HUBSTAFF_AUTHORIZATION_URL } from './hubstaff.config';
|
||||
|
||||
/**
|
||||
* What the refresh endpoint returns to the client: the short-lived access token only. The refresh
|
||||
* token is a long-lived credential and never leaves the server (GHSA-3rqg-gpm9-gx84 class).
|
||||
*/
|
||||
export interface IHubstaffAccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HubstaffService {
|
||||
constructor(
|
||||
@@ -109,7 +120,7 @@ export class HubstaffService {
|
||||
* @param integrationId The ID of the integration.
|
||||
* @returns The new tokens.
|
||||
*/
|
||||
async refreshToken(integrationId: ID) {
|
||||
async refreshToken(integrationId: ID): Promise<IHubstaffAccessTokenResponse> {
|
||||
const settings = await this._integrationSettingService.find({
|
||||
where: {
|
||||
integration: { id: integrationId },
|
||||
@@ -163,7 +174,10 @@ export class HubstaffService {
|
||||
}) as DeepPartial<IIntegrationSetting>;
|
||||
|
||||
await this._integrationSettingService.create(settingsDto);
|
||||
return tokens;
|
||||
// The client only needs the (short-lived) access token to keep calling the API; the refresh
|
||||
// token is a long-lived credential and stays server-side (GHSA-3rqg-gpm9-gx84 class).
|
||||
const { access_token, token_type, expires_in, scope } = tokens ?? {};
|
||||
return { access_token, token_type, expires_in, scope };
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
+10
-3
@@ -7,7 +7,8 @@ import {
|
||||
RequestContext,
|
||||
TenantPermissionGuard,
|
||||
UseValidationPipe,
|
||||
UUIDValidationPipe
|
||||
UUIDValidationPipe,
|
||||
documentUploadFileFilter
|
||||
} from '@gauzy/core';
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -84,7 +85,10 @@ export class PluginManagementController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
LazyAnyFileInterceptor({
|
||||
storage: () => FileStorageFactory.create('plugins')
|
||||
storage: () => FileStorageFactory.create('plugins'),
|
||||
// Plugin archives land under /public with the client extension: refuse script-capable
|
||||
// non-document types (GHSA-p334-cm7f-php5 class).
|
||||
fileFilter: documentUploadFileFilter
|
||||
})
|
||||
)
|
||||
@Permissions(PermissionsEnum.PLUGIN_CONFIGURE)
|
||||
@@ -188,7 +192,10 @@ export class PluginManagementController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
LazyAnyFileInterceptor({
|
||||
storage: () => FileStorageFactory.create('plugins')
|
||||
storage: () => FileStorageFactory.create('plugins'),
|
||||
// Plugin archives land under /public with the client extension: refuse script-capable
|
||||
// non-document types (GHSA-p334-cm7f-php5 class).
|
||||
fileFilter: documentUploadFileFilter
|
||||
})
|
||||
)
|
||||
@Permissions(PermissionsEnum.PLUGIN_UPDATE)
|
||||
|
||||
+6
-2
@@ -6,7 +6,8 @@ import {
|
||||
PermissionGuard,
|
||||
TenantPermissionGuard,
|
||||
UseValidationPipe,
|
||||
UUIDValidationPipe
|
||||
UUIDValidationPipe,
|
||||
documentUploadFileFilter
|
||||
} from '@gauzy/core';
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -80,7 +81,10 @@ export class PluginSourceController {
|
||||
@UseValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })
|
||||
@UseInterceptors(
|
||||
LazyAnyFileInterceptor({
|
||||
storage: () => FileStorageFactory.create('plugins')
|
||||
storage: () => FileStorageFactory.create('plugins'),
|
||||
// Plugin archives land under /public with the client extension: refuse script-capable
|
||||
// non-document types (GHSA-p334-cm7f-php5 class).
|
||||
fileFilter: documentUploadFileFilter
|
||||
})
|
||||
)
|
||||
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
|
||||
|
||||
+10
-3
@@ -6,7 +6,8 @@ import {
|
||||
PermissionGuard,
|
||||
TenantPermissionGuard,
|
||||
UseValidationPipe,
|
||||
UUIDValidationPipe
|
||||
UUIDValidationPipe,
|
||||
documentUploadFileFilter
|
||||
} from '@gauzy/core';
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -76,7 +77,10 @@ export class PluginVersionController {
|
||||
@UseValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })
|
||||
@UseInterceptors(
|
||||
LazyAnyFileInterceptor({
|
||||
storage: () => FileStorageFactory.create('plugins')
|
||||
storage: () => FileStorageFactory.create('plugins'),
|
||||
// Plugin archives land under /public with the client extension: refuse script-capable
|
||||
// non-document types (GHSA-p334-cm7f-php5 class).
|
||||
fileFilter: documentUploadFileFilter
|
||||
})
|
||||
)
|
||||
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
|
||||
@@ -177,7 +181,10 @@ export class PluginVersionController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
LazyAnyFileInterceptor({
|
||||
storage: () => FileStorageFactory.create('plugins')
|
||||
storage: () => FileStorageFactory.create('plugins'),
|
||||
// Plugin archives land under /public with the client extension: refuse script-capable
|
||||
// non-document types (GHSA-p334-cm7f-php5 class).
|
||||
fileFilter: documentUploadFileFilter
|
||||
})
|
||||
)
|
||||
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
|
||||
|
||||
+10
-4
@@ -13,7 +13,11 @@ services:
|
||||
- key: DEMO
|
||||
value: false
|
||||
- key: NODE_ENV
|
||||
value: development
|
||||
value: production
|
||||
# API and webapp live on two different *.onrender.com sites (public suffix), so the
|
||||
# production default of CORP: same-site would block every API-served image.
|
||||
- key: CORP_POLICY
|
||||
value: cross-origin
|
||||
- key: ADMIN_PASSWORD_RESET
|
||||
value: true
|
||||
- key: LOG_LEVEL
|
||||
@@ -29,11 +33,13 @@ services:
|
||||
- key: CLIENT_BASE_URL
|
||||
value: https://ever-gauzy-webapp.onrender.com
|
||||
- key: EXPRESS_SESSION_SECRET
|
||||
value: gauzy
|
||||
generateValue: true
|
||||
- key: JWT_SECRET
|
||||
value: secretKey
|
||||
generateValue: true
|
||||
- key: JWT_REFRESH_TOKEN_SECRET
|
||||
value: refreshSecretKey
|
||||
generateValue: true
|
||||
- key: JWT_VERIFICATION_TOKEN_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_REFRESH_TOKEN_EXPIRATION_TIME
|
||||
value: 86400
|
||||
- key: ALLOWED_ORIGINS
|
||||
|
||||
Reference in New Issue
Block a user