Compare commits

...
Author SHA1 Message Date
Félix Malfait d025337ddb feat(server): add AppRegistration entity and server-level secrets
Introduce a server-level AppRegistration entity for managing application
identity, OAuth client credentials, and server-scoped encrypted variables.

- AppRegistration entity with clientId, clientSecretHash, redirectUris, scopes
- AppRegistrationVariable entity with AES-256-GCM encryption for secrets
- Full CRUD resolver secured with SettingsPermissionGuard
- Universal identifier guard service for collision detection
- Variable resolution service for runtime secret access
- Database migration creating both tables with proper indexes/FKs
- ApplicationEntity gains appRegistrationId FK (ON DELETE SET NULL)
- Shared types: ServerVariables, updated ApplicationManifest
- Unit tests for encryption, identifier guard, and service logic

Made-with: Cursor
2026-02-26 10:56:45 +01:00
29 changed files with 1905 additions and 16 deletions
@@ -0,0 +1,111 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAppRegistration1772000000000 implements MigrationInterface {
name = 'CreateAppRegistration1772000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "core"."appRegistration" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"universalIdentifier" uuid NOT NULL,
"name" text NOT NULL,
"description" text,
"logoUrl" text,
"author" text,
"clientId" text NOT NULL,
"clientSecretHash" text,
"redirectUris" text[] NOT NULL DEFAULT '{}',
"scopes" text[] NOT NULL DEFAULT '{}',
"createdByUserId" uuid,
"websiteUrl" text,
"termsUrl" text,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_app_registration" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_created_by_user" FOREIGN KEY ("createdByUserId") REFERENCES "core"."user"("id") ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"
ON "core"."appRegistration" ("universalIdentifier")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"
ON "core"."appRegistration" ("clientId")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_CREATED_BY_USER_ID"
ON "core"."appRegistration" ("createdByUserId")
`);
await queryRunner.query(`
CREATE TABLE "core"."appRegistrationVariable" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" text NOT NULL,
"encryptedValue" text NOT NULL DEFAULT '',
"description" text NOT NULL DEFAULT '',
"isSecret" boolean NOT NULL DEFAULT true,
"isRequired" boolean NOT NULL DEFAULT false,
"appRegistrationId" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_app_registration_variable" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_variable_app_registration" FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE CASCADE,
CONSTRAINT "IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE" UNIQUE ("key", "appRegistrationId")
)
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"
ON "core"."appRegistrationVariable" ("appRegistrationId")
`);
await queryRunner.query(
`ALTER TABLE "core"."application" ADD "appRegistrationId" uuid`,
);
await queryRunner.query(`
ALTER TABLE "core"."application"
ADD CONSTRAINT "FK_application_app_registration"
FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE SET NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" DROP CONSTRAINT "FK_application_app_registration"`,
);
await queryRunner.query(
`ALTER TABLE "core"."application" DROP COLUMN "appRegistrationId"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"`,
);
await queryRunner.query(
`DROP TABLE "core"."appRegistrationVariable"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CREATED_BY_USER_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"`,
);
await queryRunner.query(`DROP TABLE "core"."appRegistration"`);
}
}
@@ -0,0 +1,58 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('AppRegistrationEncryptionService', () => {
let service: AppRegistrationEncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationEncryptionService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue('test-app-secret-32-chars-long!!!'),
},
},
],
}).compile();
service = module.get(AppRegistrationEncryptionService);
});
it('should encrypt and decrypt a value', () => {
const plaintext = 'my-secret-api-key';
const encrypted = service.encrypt(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext', () => {
const plaintext = 'same-value';
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
expect(encrypted1).not.toBe(encrypted2);
expect(service.decrypt(encrypted1)).toBe(plaintext);
expect(service.decrypt(encrypted2)).toBe(plaintext);
});
it('should handle empty strings', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('should return empty string when decrypting empty input', () => {
expect(service.decrypt('')).toBe('');
});
});
@@ -0,0 +1,116 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
describe('AppRegistrationIdentifierGuardService', () => {
let service: AppRegistrationIdentifierGuardService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let applicationRepository: jest.Mocked<Repository<ApplicationEntity>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationIdentifierGuardService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get(AppRegistrationIdentifierGuardService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
applicationRepository = module.get(
getRepositoryToken(ApplicationEntity),
);
});
it('should allow when application has no appRegistrationId (grandfather policy)', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: null,
} as ApplicationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'some-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is not claimed by any registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'unclaimed-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is claimed by the same registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
universalIdentifier: 'my-uid',
name: 'My App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'my-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should reject when identifier is claimed by a different registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
name: 'My App',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-2',
universalIdentifier: 'stolen-uid',
name: 'Other App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'stolen-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('claimed by');
});
});
@@ -0,0 +1,324 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationExceptionCode } from 'src/engine/core-modules/app-registration/app-registration.exception';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
describe('AppRegistrationService', () => {
let service: AppRegistrationService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let variableRepository: jest.Mocked<
Repository<AppRegistrationVariableEntity>
>;
let encryptionService: jest.Mocked<AppRegistrationEncryptionService>;
const mockUserId = 'user-123';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
},
},
{
provide: getRepositoryToken(AppRegistrationVariableEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: AppRegistrationEncryptionService,
useValue: {
encrypt: jest.fn((value: string) => `enc_${value}`),
decrypt: jest.fn((value: string) =>
value.replace('enc_', ''),
),
},
},
],
}).compile();
service = module.get(AppRegistrationService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
variableRepository = module.get(
getRepositoryToken(AppRegistrationVariableEntity),
);
encryptionService = module.get(AppRegistrationEncryptionService);
});
describe('create', () => {
it('should always generate a client secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create(
{ name: 'Test App' },
mockUserId,
);
expect(result.appRegistration.name).toBe('Test App');
expect(result.clientSecret).toBeDefined();
expect(result.clientSecret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.save).toHaveBeenCalled();
});
it('should accept null createdByUserId for CLI-created registrations', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create({ name: 'CLI App' }, null);
expect(result.appRegistration.createdByUserId).toBeNull();
});
it('should reject duplicate universal identifiers', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'existing',
} as AppRegistrationEntity);
await expect(
service.create(
{
name: 'Dupe App',
universalIdentifier: 'existing-uid',
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
});
});
it('should reject invalid scopes', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
await expect(
service.create(
{
name: 'Bad Scopes App',
scopes: ['api', 'invalid:scope'],
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.INVALID_SCOPE,
});
});
});
describe('delete', () => {
it('should soft delete a registration', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const result = await service.delete('reg-1');
expect(result).toBe(true);
expect(appRegistrationRepository.softDelete).toHaveBeenCalledWith(
'reg-1',
);
});
});
describe('rotateClientSecret', () => {
it('should generate a new secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const secret = await service.rotateClientSecret('reg-1');
expect(secret).toBeDefined();
expect(secret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.update).toHaveBeenCalledWith(
'reg-1',
expect.objectContaining({ clientSecretHash: expect.any(String) }),
);
});
});
describe('createVariable', () => {
it('should encrypt variable value', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.createVariable({
appRegistrationId: 'reg-1',
key: 'API_KEY',
value: 'secret-value',
});
expect(encryptionService.encrypt).toHaveBeenCalledWith(
'secret-value',
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
encryptedValue: 'enc_secret-value',
}),
);
});
});
describe('syncVariableSchemas', () => {
it('should create variables for new keys', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
NOTION_CLIENT_ID: {
description: 'Notion OAuth Client ID',
isSecret: false,
isRequired: true,
},
NOTION_CLIENT_SECRET: {
description: 'Notion OAuth Client Secret',
isSecret: true,
isRequired: true,
},
});
expect(variableRepository.save).toHaveBeenCalledTimes(2);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_ID',
encryptedValue: '',
isSecret: false,
isRequired: true,
}),
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_SECRET',
isSecret: true,
isRequired: true,
}),
);
});
it('should update metadata for existing keys without overwriting values', async () => {
variableRepository.findOne.mockResolvedValue({
id: 'var-1',
key: 'API_KEY',
encryptedValue: 'enc_existing_value',
isSecret: true,
isRequired: false,
} as AppRegistrationVariableEntity);
await service.syncVariableSchemas('reg-1', {
API_KEY: {
description: 'Updated description',
isSecret: false,
isRequired: true,
},
});
expect(variableRepository.update).toHaveBeenCalledWith('var-1', {
description: 'Updated description',
isSecret: false,
isRequired: true,
});
expect(variableRepository.save).not.toHaveBeenCalled();
});
it('should delete variables not in schema', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
KEPT_KEY: { description: 'stays' },
});
expect(variableRepository.delete).toHaveBeenCalledWith(
expect.objectContaining({
appRegistrationId: 'reg-1',
}),
);
});
});
describe('findOneByClientId', () => {
it('should return null when client not found', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.findOneByClientId('nonexistent');
expect(result).toBeNull();
});
it('should return registration by client ID', async () => {
const mockRegistration = {
id: 'reg-1',
clientId: 'chrome',
} as AppRegistrationEntity;
appRegistrationRepository.findOne.mockResolvedValue(
mockRegistration,
);
const result = await service.findOneByClientId('chrome');
expect(result).toEqual(mockRegistration);
});
});
});
@@ -0,0 +1,25 @@
import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
expect(ALL_OAUTH_SCOPES).toContain('api');
expect(ALL_OAUTH_SCOPES).toContain('profile');
expect(ALL_OAUTH_SCOPES).toHaveLength(2);
});
it('should have descriptions for all scopes', () => {
for (const scope of ALL_OAUTH_SCOPES) {
expect(OAUTH_SCOPE_DESCRIPTIONS[scope]).toBeDefined();
expect(OAUTH_SCOPE_DESCRIPTIONS[scope].length).toBeGreaterThan(0);
}
});
it('should have consistent keys and values', () => {
expect(OAUTH_SCOPES.API).toBe('api');
expect(OAUTH_SCOPES.PROFILE).toBe('profile');
});
});
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const HKDF_CONTEXT = 'app-registration-variable';
@Injectable()
export class AppRegistrationEncryptionService {
private encryptionKey: Buffer;
constructor(private readonly twentyConfigService: TwentyConfigService) {
const appSecret = this.twentyConfigService.get('APP_SECRET');
this.encryptionKey = Buffer.from(
crypto.hkdfSync('sha256', appSecret, '', HKDF_CONTEXT, 32),
);
}
encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, this.encryptionKey, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Format: base64(iv + authTag + ciphertext)
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
decrypt(encryptedBase64: string): string {
if (!encryptedBase64) {
return '';
}
const data = Buffer.from(encryptedBase64, 'base64');
const iv = data.subarray(0, IV_LENGTH);
const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(
ALGORITHM,
this.encryptionKey,
iv,
{ authTagLength: AUTH_TAG_LENGTH },
);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class AppRegistrationIdentifierGuardService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
// Validates that a universalIdentifier being created/used by an application
// is not claimed by a different AppRegistration.
// Grandfather policy: applications without appRegistrationId are not checked.
async validateUniversalIdentifierOwnership(params: {
universalIdentifier: string;
applicationId: string;
}): Promise<{ isValid: boolean; errorMessage?: string }> {
const application = await this.applicationRepository.findOne({
where: { id: params.applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return { isValid: true };
}
const ownerRegistration = await this.appRegistrationRepository.findOne({
where: { universalIdentifier: params.universalIdentifier },
});
if (!isDefined(ownerRegistration)) {
return { isValid: true };
}
if (ownerRegistration.id !== application.appRegistrationId) {
return {
isValid: false,
errorMessage:
`Universal identifier '${params.universalIdentifier}' is claimed by ` +
`app registration '${ownerRegistration.name}' (${ownerRegistration.id}), ` +
`but application '${application.name}' is linked to a different registration`,
};
}
return { isValid: true };
}
}
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
// Resolution order: workspace-level override > server-level default
@Injectable()
export class AppRegistrationVariableResolutionService {
constructor(
@InjectRepository(ApplicationVariableEntity)
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly appRegistrationVariableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly appRegistrationEncryption: AppRegistrationEncryptionService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async resolveVariable(
applicationId: string,
key: string,
): Promise<string | undefined> {
const workspaceVariable = await this.applicationVariableRepository.findOne({
where: { applicationId, key },
});
if (isDefined(workspaceVariable)) {
if (workspaceVariable.isSecret) {
return this.secretEncryptionService.decrypt(workspaceVariable.value);
}
return workspaceVariable.value;
}
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return undefined;
}
const serverVariable = await this.appRegistrationVariableRepository.findOne(
{
where: {
appRegistrationId: application.appRegistrationId,
key,
},
},
);
if (!isDefined(serverVariable)) {
return undefined;
}
return this.appRegistrationEncryption.decrypt(
serverVariable.encryptedValue,
);
}
async resolveAllVariables(
applicationId: string,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (isDefined(application?.appRegistrationId)) {
const serverVariables = await this.appRegistrationVariableRepository.find(
{
where: { appRegistrationId: application.appRegistrationId },
},
);
for (const variable of serverVariables) {
result[variable.key] = this.appRegistrationEncryption.decrypt(
variable.encryptedValue,
);
}
}
const workspaceVariables = await this.applicationVariableRepository.find({
where: { applicationId },
});
for (const variable of workspaceVariables) {
if (variable.isSecret) {
result[variable.key] = this.secretEncryptionService.decrypt(
variable.value,
);
} else {
result[variable.key] = variable.value;
}
}
return result;
}
}
@@ -0,0 +1,76 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@Entity({ name: 'appRegistrationVariable', schema: 'core' })
@ObjectType('AppRegistrationVariable')
@Unique('IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE', [
'key',
'appRegistrationId',
])
@Index('IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID', [
'appRegistrationId',
])
export class AppRegistrationVariableEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'text' })
key: string;
@Column({ nullable: false, type: 'text', default: '' })
encryptedValue: string;
@Field()
@Column({ nullable: false, type: 'text', default: '' })
description: string;
@Field()
@Column({ nullable: false, type: 'boolean', default: true })
isSecret: boolean;
@Field()
@Column({ nullable: false, type: 'boolean', default: false })
isRequired: boolean;
@Field()
get isFilled(): boolean {
return this.encryptedValue !== '';
}
@Column({ nullable: false, type: 'uuid' })
appRegistrationId: string;
@ManyToOne(
() => AppRegistrationEntity,
(appRegistration) => appRegistration.variables,
{ onDelete: 'CASCADE', nullable: false },
)
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,105 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Entity({ name: 'appRegistration', schema: 'core' })
@ObjectType('AppRegistration')
@Index('IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE', ['universalIdentifier'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE', ['clientId'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
export class AppRegistrationEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Field()
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
clientId: string;
@Column({ nullable: true, type: 'text' })
clientSecretHash: string | null;
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
redirectUris: string[];
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
scopes: string[];
@Column({ nullable: true, type: 'uuid' })
createdByUserId: string | null;
@ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@OneToMany(
() => AppRegistrationVariableEntity,
(variable) => variable.appRegistration,
{ onDelete: 'CASCADE' },
)
variables: Relation<AppRegistrationVariableEntity[]>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,53 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum AppRegistrationExceptionCode {
APP_REGISTRATION_NOT_FOUND = 'APP_REGISTRATION_NOT_FOUND',
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
CLIENT_ID_ALREADY_EXISTS = 'CLIENT_ID_ALREADY_EXISTS',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
INVALID_INPUT = 'INVALID_INPUT',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
const getExceptionUserFriendlyMessage = (
code: AppRegistrationExceptionCode,
) => {
switch (code) {
case AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND:
return msg`App registration not found.`;
case AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
return msg`This universal identifier is already claimed by another registration.`;
case AppRegistrationExceptionCode.CLIENT_ID_ALREADY_EXISTS:
return msg`This client ID already exists.`;
case AppRegistrationExceptionCode.INVALID_SCOPE:
return msg`One or more requested scopes are invalid.`;
case AppRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case AppRegistrationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case AppRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`App registration variable not found.`;
default:
assertUnreachable(code);
}
};
export class AppRegistrationException extends CustomException<AppRegistrationExceptionCode> {
constructor(
message: string,
code: AppRegistrationExceptionCode,
{
userFriendlyMessage,
}: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { AppRegistrationVariableResolutionService } from 'src/engine/core-modules/app-registration/app-registration-variable-resolution.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationResolver } from 'src/engine/core-modules/app-registration/app-registration.resolver';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([
AppRegistrationEntity,
AppRegistrationVariableEntity,
ApplicationEntity,
ApplicationVariableEntity,
]),
SecretEncryptionModule,
PermissionsModule,
],
providers: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
AppRegistrationResolver,
],
exports: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
],
})
export class AppRegistrationModule {}
@@ -0,0 +1,172 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { CreateAppRegistrationOutput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.output';
import { CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { RotateClientSecretOutput } from 'src/engine/core-modules/app-registration/dtos/rotate-client-secret.output';
import { UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration-variable.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(
AuthGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class AppRegistrationResolver {
constructor(
private readonly appRegistrationService: AppRegistrationService,
) {}
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByClientId(
@Args('clientId') clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByClientId(clientId);
}
@UseGuards(WorkspaceAuthGuard)
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByUniversalIdentifier(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationEntity])
async findManyAppRegistrations(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationService.findMany();
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationEntity)
async findOneAppRegistration(
@Args('id') id: string,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.findOneById(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationStatsOutput)
async findAppRegistrationStats(
@Args('id') id: string,
): Promise<AppRegistrationStatsOutput> {
return this.appRegistrationService.getStats(id);
}
@UseGuards(WorkspaceAuthGuard)
@Mutation(() => CreateAppRegistrationOutput)
async createAppRegistration(
@Args('input') input: CreateAppRegistrationInput,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<CreateAppRegistrationOutput> {
return this.appRegistrationService.create(input, user?.id ?? null);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationEntity)
async updateAppRegistration(
@Args('input') input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.update(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistration(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.delete(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => RotateClientSecretOutput)
async rotateAppRegistrationClientSecret(
@Args('id') id: string,
): Promise<RotateClientSecretOutput> {
const clientSecret =
await this.appRegistrationService.rotateClientSecret(id);
return { clientSecret };
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationVariableEntity])
async findAppRegistrationVariables(
@Args('appRegistrationId') appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.appRegistrationService.findVariables(appRegistrationId);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async createAppRegistrationVariable(
@Args('input') input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.createVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async updateAppRegistrationVariable(
@Args('input') input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.updateVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistrationVariable(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.deleteVariable(id);
}
}
@@ -0,0 +1,349 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import crypto from 'crypto';
import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import {
AppRegistrationException,
AppRegistrationExceptionCode,
} from 'src/engine/core-modules/app-registration/app-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
import { type AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { type CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { type CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { type UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { type UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration-variable.input';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
export class AppRegistrationService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly variableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly encryptionService: AppRegistrationEncryptionService,
) {}
async findMany(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(id: string): Promise<AppRegistrationEntity> {
const registration = await this.appRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new AppRegistrationException(
`App registration with id ${id} not found`,
AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByClientId(
clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { clientId },
});
}
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { universalIdentifier },
});
}
async create(
input: CreateAppRegistrationInput,
createdByUserId: string | null,
): Promise<{ appRegistration: AppRegistrationEntity; clientSecret: string }> {
const universalIdentifier = input.universalIdentifier ?? v4();
const existingByUid =
await this.findOneByUniversalIdentifier(universalIdentifier);
if (existingByUid) {
throw new AppRegistrationException(
`Universal identifier ${universalIdentifier} is already claimed`,
AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const clientId = v4();
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
const appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
clientId,
clientSecretHash,
redirectUris: input.redirectUris ?? [],
scopes: input.scopes ?? [],
createdByUserId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.appRegistrationRepository.save(appRegistration);
return { appRegistration: saved, clientSecret };
}
async update(
input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
await this.findOneById(input.id);
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const updateData: QueryDeepPartialEntity<AppRegistrationEntity> = {};
if (isDefined(input.name)) updateData.name = input.name;
if (isDefined(input.description)) updateData.description = input.description;
if (isDefined(input.logoUrl)) updateData.logoUrl = input.logoUrl;
if (isDefined(input.author)) updateData.author = input.author;
if (isDefined(input.redirectUris)) updateData.redirectUris = input.redirectUris;
if (isDefined(input.scopes)) updateData.scopes = input.scopes;
if (isDefined(input.websiteUrl)) updateData.websiteUrl = input.websiteUrl;
if (isDefined(input.termsUrl)) updateData.termsUrl = input.termsUrl;
await this.appRegistrationRepository.update(input.id, updateData);
return this.findOneById(input.id);
}
async delete(id: string): Promise<boolean> {
await this.findOneById(id);
await this.appRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string): Promise<string> {
await this.findOneById(id);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
await this.appRegistrationRepository.update(id, { clientSecretHash });
return clientSecret;
}
async verifyClientSecret(
registration: AppRegistrationEntity,
clientSecret: string,
): Promise<boolean> {
if (!registration.clientSecretHash) {
return false;
}
return bcrypt.compare(clientSecret, registration.clientSecretHash);
}
// Variable operations
async findVariables(
appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.variableRepository.find({
where: { appRegistrationId },
order: { key: 'ASC' },
});
}
async createVariable(
input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
await this.findOneById(input.appRegistrationId);
const encryptedValue = this.encryptionService.encrypt(input.value);
const variable = this.variableRepository.create({
appRegistrationId: input.appRegistrationId,
key: input.key,
encryptedValue,
description: input.description ?? '',
isSecret: input.isSecret ?? true,
});
return this.variableRepository.save(variable);
}
async updateVariable(
input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
const variable = await this.variableRepository.findOne({
where: { id: input.id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${input.id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
const updateData: QueryDeepPartialEntity<AppRegistrationVariableEntity> = {};
if (isDefined(input.value)) {
updateData.encryptedValue = this.encryptionService.encrypt(input.value);
}
if (isDefined(input.description)) {
updateData.description = input.description;
}
await this.variableRepository.update(input.id, updateData);
return this.variableRepository.findOneOrFail({ where: { id: input.id } });
}
async deleteVariable(id: string): Promise<boolean> {
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
await this.variableRepository.delete(id);
return true;
}
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
async syncVariableSchemas(
appRegistrationId: string,
serverVariables: ServerVariables,
): Promise<void> {
const declaredKeys = Object.keys(serverVariables);
for (const [key, schema] of Object.entries(serverVariables)) {
const existing = await this.variableRepository.findOne({
where: { appRegistrationId, key },
});
if (existing) {
await this.variableRepository.update(existing.id, {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
});
} else {
await this.variableRepository.save(
this.variableRepository.create({
appRegistrationId,
key,
encryptedValue: '',
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
}),
);
}
}
if (declaredKeys.length > 0) {
await this.variableRepository.delete({
appRegistrationId,
key: Not(In(declaredKeys)),
});
} else {
await this.variableRepository.delete({ appRegistrationId });
}
}
async getStats(
appRegistrationId: string,
): Promise<AppRegistrationStatsOutput> {
await this.findOneById(appRegistrationId);
const installs = await this.applicationRepository.find({
where: { appRegistrationId },
select: ['version'],
});
const versionCounts = new Map<string, number>();
for (const install of installs) {
const version = install.version ?? 'unknown';
versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
}
const versionDistribution = Array.from(versionCounts.entries())
.map(([version, count]) => ({ version, count }))
.sort((a, b) => b.count - a.count);
const latestVersion = versionDistribution[0]?.version ?? null;
return {
activeInstalls: installs.length,
latestVersion,
versionDistribution,
};
}
private async generateClientSecret(): Promise<{
clientSecret: string;
clientSecretHash: string;
}> {
const clientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = await bcrypt.hash(
clientSecret,
BCRYPT_SALT_ROUNDS,
);
return { clientSecret, clientSecretHash };
}
private validateScopes(scopes: string[]): void {
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
const invalidScopes = scopes.filter(
(scope) => !validScopes.includes(scope),
);
if (invalidScopes.length > 0) {
throw new AppRegistrationException(
`Invalid scopes: ${invalidScopes.join(', ')}`,
AppRegistrationExceptionCode.INVALID_SCOPE,
);
}
}
}
@@ -0,0 +1,17 @@
// Scopes are a thin consent boundary shown to the user during OAuth authorization.
// Actual permissions are enforced by the role assigned to the application at the
// workspace level (object, field, and row-level permissions).
export const OAUTH_SCOPES = {
API: 'api',
PROFILE: 'profile',
} as const;
export type OAuthScope = (typeof OAUTH_SCOPES)[keyof typeof OAUTH_SCOPES];
export const ALL_OAUTH_SCOPES: OAuthScope[] = Object.values(OAUTH_SCOPES);
export const OAUTH_SCOPE_DESCRIPTIONS: Record<OAuthScope, string> = {
[OAUTH_SCOPES.API]:
'Access workspace data according to the assigned role',
[OAUTH_SCOPES.PROFILE]: "Read the authenticated user's profile",
};
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class VersionDistributionEntry {
@Field()
version: string;
@Field(() => Int)
count: number;
}
@ObjectType()
export class AppRegistrationStatsOutput {
@Field(() => Int)
activeInstalls: number;
@Field(() => String, { nullable: true })
latestVersion: string | null;
@Field(() => [VersionDistributionEntry])
versionDistribution: VersionDistributionEntry[];
}
@@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationVariableInput {
@Field()
@IsUUID()
appRegistrationId: string;
@Field()
@IsString()
key: string;
@Field()
@IsString()
value: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isSecret?: boolean;
}
@@ -0,0 +1,52 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationInput {
@Field()
@IsString()
name: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
universalIdentifier?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@ObjectType()
export class CreateAppRegistrationOutput {
@Field(() => AppRegistrationEntity)
appRegistration: AppRegistrationEntity;
@Field()
clientSecret: string;
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class RotateClientSecretOutput {
@Field()
clientSecret: string;
}
@@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class UpdateAppRegistrationVariableInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
}
@@ -0,0 +1,57 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
@InputType()
export class UpdateAppRegistrationInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -7,6 +7,7 @@ import {
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@@ -14,6 +15,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -91,6 +93,16 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: true, type: 'uuid' })
appRegistrationId: string | null;
@ManyToOne(() => AppRegistrationEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity> | null;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'appRegistration',
] as const satisfies (keyof ApplicationEntity)[];
@@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-registration.module';
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
@@ -88,6 +89,7 @@ import { FileModule } from './file/file.module';
FileModule,
RowLevelPermissionModule,
OpenApiModule,
AppRegistrationModule,
ApplicationModule,
ApplicationSyncModule,
AppTokenModule,
@@ -4,4 +4,5 @@ export enum AuthProviderEnum {
Password = 'password',
SSO = 'sso',
Impersonation = 'impersonation',
OAuth = 'oauth',
}
@@ -1,17 +1,7 @@
import { type ApplicationVariables } from './applicationVariablesType';
import { type ServerVariables } from './serverVariablesType';
import { type SyncableEntityOptions } from './syncableEntityOptionsType';
export type ApplicationMarketplaceData = {
author?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
};
export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
postInstallLogicFunctionUniversalIdentifier?: string;
@@ -19,7 +9,15 @@ export type ApplicationManifest = SyncableEntityOptions & {
description: string;
icon?: string;
applicationVariables?: ApplicationVariables;
marketplaceData?: ApplicationMarketplaceData;
serverVariables?: ServerVariables;
author?: string;
logoUrl?: string;
category?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
packageJsonChecksum: string | null;
yarnLockChecksum: string | null;
apiClientChecksum: string | null;
@@ -7,10 +7,7 @@
* |___/
*/
export type {
ApplicationMarketplaceData,
ApplicationManifest,
} from './applicationType';
export type { ApplicationManifest } from './applicationType';
export type { ApplicationVariables } from './applicationVariablesType';
export type { AssetManifest } from './assetManifestType';
export { API_CLIENT_DIR } from './constants/ApiClientDirectory';
@@ -52,6 +49,7 @@ export type {
FieldPermissionManifest,
RoleManifest,
} from './roleManifestType';
export type { ServerVariables } from './serverVariablesType';
export type { SkillManifest } from './skillManifestType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
export type {
@@ -0,0 +1,7 @@
type ServerVariableSchema = {
description?: string;
isSecret?: boolean;
isRequired?: boolean;
};
export type ServerVariables = Record<string, ServerVariableSchema>;