Compare commits

...
Author SHA1 Message Date
Claude 8168565d26 Fix admin panel Forbidden error for users with server-level admin flags
The AdminPanelResolver had a class-level SettingsPermissionGuard(SECURITY)
that checked workspace-role permissions on every request, blocking server-level
admins (canAccessFullAdminPanel/canImpersonate) who lacked the workspace SECURITY
permission flag. Replace with AdminPanelSecurityGuard that bypasses the
workspace permission check when server-level admin flags are set.

https://claude.ai/code/session_01HrMrsJALsayr5zn5cqPtVP
2026-04-15 20:33:37 +00:00
3 changed files with 187 additions and 7 deletions
@@ -5,7 +5,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import GraphQLJSON from 'graphql-type-json';
import { In, type Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -60,8 +59,8 @@ import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { AdminPanelSecurityGuard } from 'src/engine/guards/admin-panel-security.guard';
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -80,11 +79,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
PreventNestToAutoLogGraphqlErrorsFilter,
ConfigVariableGraphqlApiExceptionFilter,
)
@UseGuards(
WorkspaceAuthGuard,
UserAuthGuard,
SettingsPermissionGuard(PermissionFlagType.SECURITY),
)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelSecurityGuard)
export class AdminPanelResolver {
constructor(
private readonly adminUserLookupService: AdminPanelUserLookupService,
@@ -0,0 +1,113 @@
import { type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { AdminPanelSecurityGuard } from 'src/engine/guards/admin-panel-security.guard';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { type PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
describe('AdminPanelSecurityGuard', () => {
let guard: AdminPanelSecurityGuard;
let mockPermissionsService: jest.Mocked<PermissionsService>;
let mockExecutionContext: ExecutionContext;
let mockGqlContext: any;
beforeEach(() => {
mockPermissionsService = {
userHasWorkspaceSettingPermission: jest.fn(),
} as any;
mockGqlContext = {
req: {
workspace: {
id: 'workspace-id',
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
userWorkspaceId: 'user-workspace-id',
user: {
canAccessFullAdminPanel: false,
canImpersonate: false,
},
apiKey: null,
application: null,
},
};
mockExecutionContext = {} as ExecutionContext;
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue({ getContext: () => mockGqlContext } as any);
guard = new AdminPanelSecurityGuard(mockPermissionsService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should allow access when user has canAccessFullAdminPanel', async () => {
mockGqlContext.req.user.canAccessFullAdminPanel = true;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(
mockPermissionsService.userHasWorkspaceSettingPermission,
).not.toHaveBeenCalled();
});
it('should allow access when user has canImpersonate', async () => {
mockGqlContext.req.user.canImpersonate = true;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(
mockPermissionsService.userHasWorkspaceSettingPermission,
).not.toHaveBeenCalled();
});
it('should bypass permission check when workspace is being created', async () => {
mockGqlContext.req.workspace.activationStatus =
WorkspaceActivationStatus.PENDING_CREATION;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(
mockPermissionsService.userHasWorkspaceSettingPermission,
).not.toHaveBeenCalled();
});
it('should allow access when user has SECURITY workspace permission', async () => {
mockPermissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(
mockPermissionsService.userHasWorkspaceSettingPermission,
).toHaveBeenCalledWith({
userWorkspaceId: 'user-workspace-id',
setting: PermissionFlagType.SECURITY,
workspaceId: 'workspace-id',
apiKeyId: undefined,
applicationId: undefined,
});
});
it('should throw PermissionsException when user lacks all permissions', async () => {
mockPermissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
false,
);
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
PermissionsException,
);
});
});
@@ -0,0 +1,72 @@
import {
Injectable,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { msg } from '@lingui/core/macro';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
// Guard for the admin panel resolver that allows server-level admins
// (canAccessFullAdminPanel or canImpersonate) to bypass the workspace-level
// SECURITY permission check. Non-admin users fall back to the standard
// workspace role permission check.
@Injectable()
export class AdminPanelSecurityGuard implements CanActivate {
constructor(private readonly permissionsService: PermissionsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
if (
request.user.canAccessFullAdminPanel === true ||
request.user.canImpersonate === true
) {
return true;
}
const workspaceId = request.workspace.id;
const userWorkspaceId = request.userWorkspaceId;
const workspaceActivationStatus = request.workspace.activationStatus;
if (
[
WorkspaceActivationStatus.PENDING_CREATION,
WorkspaceActivationStatus.ONGOING_CREATION,
].includes(workspaceActivationStatus)
) {
return true;
}
const hasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
setting: PermissionFlagType.SECURITY,
workspaceId,
apiKeyId: request.apiKey?.id,
applicationId: request.application?.id,
});
if (hasPermission === true) {
return true;
}
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to access this feature. Please contact your workspace administrator for access.`,
},
);
}
}