chore(server): drop unused postgresCredentials feature (#20573)

## Summary

Drops the `postgresCredentials` legacy feature: a never-finished
"postgres proxy" that would have let users query their workspace data
over a standard Postgres connection. Nothing — frontend, e2e, Zapier,
docs, other server code — calls these mutations/query.

## History

- **Introduced** June 2024 (#5767, Thomas Trompette) as "first step for
creating credentials for database proxy", alongside the Postgres FDW /
remote-server work and the custom `twenty-postgres-spilo` image. Planned
follow-ups (provisioning a DB on the proxy, mapping users, exposing it
as a remote server) never landed.
- **Abandoned** January 2026 (#17001, Weiko) when the sibling "remote
integration" feature was removed as a BREAKING CHANGE — "not maintained
for more than a year and never officially launched". The spilo image was
then replaced with vanilla `postgres:16` (#19182, March 2026), retiring
the FDW infrastructure entirely.
- This PR finishes the cleanup: removes the orphaned module, the
`allPostgresCredentials` relation, `JwtTokenTypeEnum.POSTGRES_PROXY` +
payload, the reserved metadata keywords, and adds a 2.5.0 fast instance
command that drops `core.postgresCredentials` (reversible `down`).
Regenerated frontend GraphQL types + SDK metadata client.

## Test plan

- [x] `tsgo --noEmit` clean on twenty-server + twenty-front; lint +
prettier clean on touched files.
- [x] `database:migrate:generate` reports no pending schema diff; server
boots and serves the new schema.
This commit is contained in:
Charles Bochet
2026-05-14 12:04:09 +02:00
committed by GitHub
parent 61683d8bda
commit 0d5617d446
17 changed files with 304 additions and 610 deletions
@@ -43,7 +43,6 @@ export enum JwtTokenTypeEnum {
LOGIN = 'LOGIN',
FILE = 'FILE',
API_KEY = 'API_KEY',
POSTGRES_PROXY = 'POSTGRES_PROXY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
@@ -138,10 +137,6 @@ export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
impersonatedUserWorkspaceId?: string;
};
export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.POSTGRES_PROXY;
};
export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APP_OAUTH_STATE;
workspaceId: string;
@@ -170,5 +165,4 @@ export type JwtPayload =
| RefreshTokenJwtPayload
| FileTokenJwtPayload
| FileTokenJwtPayloadLegacy
| PostgresProxyTokenJwtPayload
| AppOAuthStateJwtPayload;
@@ -51,7 +51,6 @@ import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webho
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
import { PostgresCredentialsModule } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
@@ -116,7 +115,6 @@ import { FileModule } from './file/file.module';
PublicDomainModule,
CloudflareModule,
DnsManagerModule,
PostgresCredentialsModule,
WorkflowApiModule,
WorkspaceEventEmitterModule,
ActorModule,
@@ -1,20 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('PostgresCredentials')
export class PostgresCredentialsDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field()
user: string;
@Field()
password: string;
@Field(() => UUIDScalarType)
workspaceId: string;
}
@@ -1,33 +0,0 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'postgresCredentials', schema: 'core' })
@ObjectType('PostgresCredentials')
export class PostgresCredentialsEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
user: string;
@Column({ nullable: false })
passwordHash: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: true, type: 'timestamptz' })
deletedAt: Date;
}
@@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver';
import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([PostgresCredentialsEntity]),
PermissionsModule,
],
providers: [
PostgresCredentialsResolver,
PostgresCredentialsService,
PostgresCredentialsEntity,
],
})
export class PostgresCredentialsModule {}
@@ -1,44 +0,0 @@
import { UseGuards } from '@nestjs/common';
import { Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto';
import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
)
@MetadataResolver(() => PostgresCredentialsDTO)
export class PostgresCredentialsResolver {
constructor(
private readonly postgresCredentialsService: PostgresCredentialsService,
) {}
@Mutation(() => PostgresCredentialsDTO)
async enablePostgresProxy(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.postgresCredentialsService.enablePostgresProxy(workspaceId);
}
@Mutation(() => PostgresCredentialsDTO)
async disablePostgresProxy(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.postgresCredentialsService.disablePostgresProxy(workspaceId);
}
@Query(() => PostgresCredentialsDTO, { nullable: true })
async getPostgresCredentials(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.postgresCredentialsService.getPostgresCredentials(workspaceId);
}
}
@@ -1,127 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { type PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto';
import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
export class PostgresCredentialsService {
constructor(
@InjectRepository(PostgresCredentialsEntity)
private readonly postgresCredentialsRepository: Repository<PostgresCredentialsEntity>,
private readonly jwtWrapperService: JwtWrapperService,
) {}
async enablePostgresProxy(
workspaceId: string,
): Promise<PostgresCredentialsDTO> {
const user = `user_${randomBytes(4).toString('hex')}`;
const password = randomBytes(16).toString('hex');
const key = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.POSTGRES_PROXY,
workspaceId,
);
const passwordHash = encryptText(password, key);
const existingCredentials =
await this.postgresCredentialsRepository.findOne({
where: {
workspaceId,
},
});
if (existingCredentials) {
throw new BadRequestException(
'Postgres credentials already exist for this workspace',
);
}
const postgresCredentials = await this.postgresCredentialsRepository.create(
{
user,
passwordHash,
workspaceId,
},
);
await this.postgresCredentialsRepository.save(postgresCredentials);
return {
id: postgresCredentials.id,
user,
password,
workspaceId,
};
}
async disablePostgresProxy(
workspaceId: string,
): Promise<PostgresCredentialsDTO> {
const postgresCredentials =
await this.postgresCredentialsRepository.findOne({
where: {
workspaceId,
},
});
if (!postgresCredentials?.id) {
throw new NotFoundError(
'No valid Postgres credentials not found for this workspace',
);
}
await this.postgresCredentialsRepository.delete({
id: postgresCredentials.id,
});
const key = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.POSTGRES_PROXY,
workspaceId,
);
return {
id: postgresCredentials.id,
user: postgresCredentials.user,
password: decryptText(postgresCredentials.passwordHash, key),
workspaceId: postgresCredentials.workspaceId,
};
}
async getPostgresCredentials(
workspaceId: string,
): Promise<PostgresCredentialsDTO | null> {
const postgresCredentials =
await this.postgresCredentialsRepository.findOne({
where: {
workspaceId,
},
});
if (!postgresCredentials) {
return null;
}
const key = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.POSTGRES_PROXY,
workspaceId,
);
return {
id: postgresCredentials.id,
user: postgresCredentials.user,
password: decryptText(postgresCredentials.passwordHash, key),
workspaceId: postgresCredentials.workspaceId,
};
}
}
@@ -10,7 +10,6 @@ export const WORKSPACE_ENTITY_NON_CACHED_PROPERTIES = [
'emailingDomains',
'publicDomains',
'workspaceMembersCount',
'allPostgresCredentials',
'workspaceSSOIdentityProviders',
'agents',
'webhooks',
@@ -29,7 +29,6 @@ import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/em
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -180,12 +179,6 @@ export class WorkspaceEntity {
@Column({ type: 'timestamptz', nullable: true })
suspendedAt: Date | null;
@OneToMany(
() => PostgresCredentialsEntity,
(postgresCredentials) => postgresCredentials.workspace,
)
allPostgresCredentials: Relation<PostgresCredentialsEntity[]>;
@OneToMany(
() => WorkspaceSSOIdentityProviderEntity,
(workspaceSSOIdentityProviders) => workspaceSSOIdentityProviders.workspace,