Restrict which workspace fields can be updated before activation (#23781)

## What

`validateWorkspaceUpdatePermissions` returned early with no checks at
all when the workspace was in `PENDING_CREATION`, so `updateWorkspace`
accepted any field during that window. It now allows only the fields
needed to set the workspace up (`displayName`, `subdomain`, `logo`) and
rejects everything else until the workspace is activated.

Note that `updateWorkspace`'s resolver guard is `CustomPermissionGuard`,
which always returns true and only documents that the check lives in the
resolver/service, so this service method is the actual enforcement
point.

## Why

A workspace stays in `PENDING_CREATION` from signup until onboarding
completes, and the JWT strategy issues an authenticated context for it
without resolving member permissions. During that window every field was
writable with no permission check, including security relevant ones such
as `allowImpersonation`, `isTwoFactorAuthenticationEnforced` and
`isPublicInviteLinkEnabled`.

In practice the only principal present before activation is the
workspace creator, who is granted the Admin role
(`canUpdateAllSettings`) the moment activation completes, so there is no
privilege escalation over another user today. This is defense in depth:
the early return was broader than it needed to be, and it becomes a real
gap if the "only the creator exists before activation" assumption ever
stops holding, for example a workspace left pending or a future flow
that adds members before activation.

The bypass exists because a pending workspace has no roles yet, so
permissions cannot be resolved for it. Keeping a small explicit
allowlist preserves that while removing the blanket skip.

## Scope

Only the `updateWorkspace` path. `SettingsPermissionGuard` has a similar
bypass for `PENDING_CREATION` / `ONGOING_CREATION`, but it covers 62
resolvers including billing endpoints that onboarding legitimately calls
before activation, so narrowing it needs its own analysis and is
deliberately left out.

## Tests

`workspace-update-before-activation.integration-spec.ts`, run against a
real database with the seeded workspace flipped to `PENDING_CREATION`:

- a security sensitive field (`allowImpersonation`) is rejected and the
stored value is unchanged
- mixing a setup field with a security sensitive one rejects the whole
update, and `displayName` is not persisted
- setup fields (`displayName`) still apply, so the restriction does not
break workspace setup

Both rejection tests were verified to fail when the old blanket early
return is put back, while the positive control keeps passing. The
existing `settings-permissions/workspace*` suites still pass (38 tests
total), confirming no change for activated workspaces.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Qf58T7Lm7PazNbS3UwaZPd)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23781?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-08-05 10:15:31 +02:00
committed by GitHub
parent 7c9ec6a770
commit 0b817e3bc0
6 changed files with 231 additions and 6 deletions
@@ -0,0 +1,7 @@
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export const WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION = {
displayName: true,
subdomain: true,
logo: true,
} as const satisfies Partial<Record<keyof WorkspaceEntity, true>>;
@@ -49,6 +49,7 @@ import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/se
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION } from 'src/engine/core-modules/workspace/constants/workspace-fields-updatable-before-activation.constant';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
WorkspaceException,
@@ -818,12 +819,6 @@ export class WorkspaceService {
apiKey: ApiKeyEntity | undefined;
workspaceActivationStatus: WorkspaceActivationStatus;
}) {
if (
workspaceActivationStatus === WorkspaceActivationStatus.PENDING_CREATION
) {
return;
}
const systemFields = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt']);
const fieldsBeingUpdated = Object.keys(payload).filter(
@@ -834,6 +829,28 @@ export class WorkspaceService {
return;
}
if (
workspaceActivationStatus === WorkspaceActivationStatus.PENDING_CREATION
) {
const fieldsRequiringActivation = fieldsBeingUpdated.filter(
(field) => !(field in WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION),
);
if (fieldsRequiringActivation.length > 0) {
const fieldsList = fieldsRequiringActivation.join(', ');
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`These fields cannot be updated before the workspace is activated: ${fieldsList}.`,
},
);
}
return;
}
if (!userWorkspaceId) {
throw new Error('Missing userWorkspaceId in authContext');
}
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateWorkspace while the workspace is pending creation rejects security sensitive fields 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
"name": "ForbiddenError",
}
`;
exports[`updateWorkspace while the workspace is pending creation rejects the whole update when a setup field is mixed with a security sensitive one 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
"name": "ForbiddenError",
}
`;
@@ -0,0 +1,116 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { updateWorkspace } from 'test/integration/graphql/utils/update-workspace.util';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION } from 'src/engine/core-modules/workspace/constants/workspace-fields-updatable-before-activation.constant';
import { type UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// Every allowlisted field needs a value here, so adding one to the constant
// without covering it fails to compile.
const SETUP_FIELD_VALUES = {
displayName: `Pending setup ${Date.now()}`,
subdomain: `pending-setup-${Date.now()}`,
logo: 'pending-setup-logo.png',
} satisfies Record<
keyof typeof WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION,
string
>;
const setActivationStatus = (activationStatus: WorkspaceActivationStatus) =>
testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[activationStatus, SEED_APPLE_WORKSPACE_ID],
);
describe('updateWorkspace while the workspace is pending creation', () => {
let originalDisplayName: string;
let originalSubdomain: string;
let originalLogo: string | null;
let originalAllowImpersonation: boolean;
beforeAll(async () => {
const [workspace] = await testDataSource.query(
'SELECT "displayName", "subdomain", "logo", "allowImpersonation" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
originalDisplayName = workspace.displayName;
originalSubdomain = workspace.subdomain;
originalLogo = workspace.logo;
originalAllowImpersonation = workspace.allowImpersonation;
await setActivationStatus(WorkspaceActivationStatus.PENDING_CREATION);
});
afterAll(async () => {
await setActivationStatus(WorkspaceActivationStatus.ACTIVE);
await testDataSource.query(
'UPDATE core.workspace SET "displayName" = $1, "subdomain" = $2, "logo" = $3 WHERE id = $4',
[
originalDisplayName,
originalSubdomain,
originalLogo,
SEED_APPLE_WORKSPACE_ID,
],
);
});
it('rejects security sensitive fields', async () => {
const { data, errors } = await updateWorkspace({
data: { allowImpersonation: !originalAllowImpersonation },
expectToFail: true,
});
expect(data?.updateWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
const [workspace] = await testDataSource.query(
'SELECT "allowImpersonation" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace.allowImpersonation).toBe(originalAllowImpersonation);
});
it('rejects the whole update when a setup field is mixed with a security sensitive one', async () => {
const { data, errors } = await updateWorkspace({
data: {
displayName: 'Should not be applied',
isPublicInviteLinkEnabled: true,
},
expectToFail: true,
});
expect(data?.updateWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
const [workspace] = await testDataSource.query(
'SELECT "displayName" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace.displayName).toBe(originalDisplayName);
});
it.each(Object.entries(SETUP_FIELD_VALUES))(
'still allows %s, which is needed to set the workspace up',
async (field, value) => {
const { data, errors } = await updateWorkspace({
data: { [field]: value } as UpdateWorkspaceInput,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data.updateWorkspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
const [workspace] = await testDataSource.query(
`SELECT "${field}" FROM core.workspace WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace[field]).toBe(value);
},
);
});
@@ -0,0 +1,18 @@
import gql from 'graphql-tag';
import { type UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
export const updateWorkspaceOperationFactory = ({
data,
}: {
data: UpdateWorkspaceInput;
}) => ({
query: gql`
mutation UpdateWorkspace($data: UpdateWorkspaceInput!) {
updateWorkspace(data: $data) {
id
}
}
`,
variables: { data },
});
@@ -0,0 +1,42 @@
import { updateWorkspaceOperationFactory } from 'test/integration/graphql/utils/update-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
type UpdateWorkspaceUtilArgs = {
data: UpdateWorkspaceInput;
expectToFail?: boolean;
token?: string;
};
export const updateWorkspace = async ({
data,
expectToFail = false,
token,
}: UpdateWorkspaceUtilArgs): CommonResponseBody<{
updateWorkspace: { id: string };
}> => {
const response = await makeMetadataAPIRequest(
updateWorkspaceOperationFactory({ data }),
token,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'updateWorkspace should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'updateWorkspace has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};