Environment variables in admin panel (read only) - backend (#9943)
Backend for https://github.com/twentyhq/core-team-issues/issues/293 POC - https://github.com/twentyhq/twenty/pull/9903 --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -394,6 +394,69 @@ export type EmailPasswordResetLink = {
|
|||||||
success: Scalars['Boolean'];
|
success: Scalars['Boolean'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EnvironmentVariable = {
|
||||||
|
__typename?: 'EnvironmentVariable';
|
||||||
|
description: Scalars['String'];
|
||||||
|
name: Scalars['String'];
|
||||||
|
sensitive: Scalars['Boolean'];
|
||||||
|
value: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum EnvironmentVariablesGroup {
|
||||||
|
Analytics = 'Analytics',
|
||||||
|
Authentication = 'Authentication',
|
||||||
|
Billing = 'Billing',
|
||||||
|
Cache = 'Cache',
|
||||||
|
Database = 'Database',
|
||||||
|
Email = 'Email',
|
||||||
|
Frontend = 'Frontend',
|
||||||
|
LLM = 'LLM',
|
||||||
|
Logging = 'Logging',
|
||||||
|
QueueConfig = 'QueueConfig',
|
||||||
|
Security = 'Security',
|
||||||
|
ServerConfig = 'ServerConfig',
|
||||||
|
Serverless = 'Serverless',
|
||||||
|
Storage = 'Storage',
|
||||||
|
Support = 'Support',
|
||||||
|
Workspace = 'Workspace'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnvironmentVariablesGroupData = {
|
||||||
|
__typename?: 'EnvironmentVariablesGroupData';
|
||||||
|
groupName: EnvironmentVariablesGroup;
|
||||||
|
subgroups: Array<EnvironmentVariablesSubgroupData>;
|
||||||
|
variables: Array<EnvironmentVariable>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnvironmentVariablesOutput = {
|
||||||
|
__typename?: 'EnvironmentVariablesOutput';
|
||||||
|
groups: Array<EnvironmentVariablesGroupData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum EnvironmentVariablesSubGroup {
|
||||||
|
CloudflareConfig = 'CloudflareConfig',
|
||||||
|
EmailSettings = 'EmailSettings',
|
||||||
|
FrontSupportConfig = 'FrontSupportConfig',
|
||||||
|
GoogleAuth = 'GoogleAuth',
|
||||||
|
LambdaConfig = 'LambdaConfig',
|
||||||
|
MicrosoftAuth = 'MicrosoftAuth',
|
||||||
|
PasswordAuth = 'PasswordAuth',
|
||||||
|
RateLimiting = 'RateLimiting',
|
||||||
|
S3Config = 'S3Config',
|
||||||
|
SSL = 'SSL',
|
||||||
|
SentryConfig = 'SentryConfig',
|
||||||
|
SmtpConfig = 'SmtpConfig',
|
||||||
|
StripeConfig = 'StripeConfig',
|
||||||
|
TinybirdConfig = 'TinybirdConfig',
|
||||||
|
Tokens = 'Tokens'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnvironmentVariablesSubgroupData = {
|
||||||
|
__typename?: 'EnvironmentVariablesSubgroupData';
|
||||||
|
subgroupName: EnvironmentVariablesSubGroup;
|
||||||
|
variables: Array<EnvironmentVariable>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecuteServerlessFunctionInput = {
|
export type ExecuteServerlessFunctionInput = {
|
||||||
/** Id of the serverless function to execute */
|
/** Id of the serverless function to execute */
|
||||||
id: Scalars['UUID'];
|
id: Scalars['UUID'];
|
||||||
@ -1177,6 +1240,7 @@ export type Query = {
|
|||||||
findWorkspaceFromInviteHash: Workspace;
|
findWorkspaceFromInviteHash: Workspace;
|
||||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||||
getAvailablePackages: Scalars['JSON'];
|
getAvailablePackages: Scalars['JSON'];
|
||||||
|
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
|
||||||
getHostnameDetails?: Maybe<CustomHostnameDetails>;
|
getHostnameDetails?: Maybe<CustomHostnameDetails>;
|
||||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||||
getProductPrices: BillingProductPricesOutput;
|
getProductPrices: BillingProductPricesOutput;
|
||||||
@ -2151,6 +2215,11 @@ export type UserLookupAdminPanelMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
|
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
|
||||||
|
|
||||||
|
export type GetEnvironmentVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type GetEnvironmentVariablesGroupedQuery = { __typename?: 'Query', getEnvironmentVariablesGrouped: { __typename?: 'EnvironmentVariablesOutput', groups: Array<{ __typename?: 'EnvironmentVariablesGroupData', groupName: EnvironmentVariablesGroup, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }>, subgroups: Array<{ __typename?: 'EnvironmentVariablesSubgroupData', subgroupName: EnvironmentVariablesSubGroup, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }> }> }> } };
|
||||||
|
|
||||||
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||||
input: UpdateLabPublicFeatureFlagInput;
|
input: UpdateLabPublicFeatureFlagInput;
|
||||||
}>;
|
}>;
|
||||||
@ -3762,6 +3831,57 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
|
|||||||
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
|
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
|
||||||
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
|
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
|
||||||
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
|
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
|
||||||
|
export const GetEnvironmentVariablesGroupedDocument = gql`
|
||||||
|
query GetEnvironmentVariablesGrouped {
|
||||||
|
getEnvironmentVariablesGrouped {
|
||||||
|
groups {
|
||||||
|
groupName
|
||||||
|
variables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
value
|
||||||
|
sensitive
|
||||||
|
}
|
||||||
|
subgroups {
|
||||||
|
subgroupName
|
||||||
|
variables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
value
|
||||||
|
sensitive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useGetEnvironmentVariablesGroupedQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useGetEnvironmentVariablesGroupedQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useGetEnvironmentVariablesGroupedQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useGetEnvironmentVariablesGroupedQuery({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useGetEnvironmentVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>(GetEnvironmentVariablesGroupedDocument, options);
|
||||||
|
}
|
||||||
|
export function useGetEnvironmentVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>(GetEnvironmentVariablesGroupedDocument, options);
|
||||||
|
}
|
||||||
|
export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedQuery>;
|
||||||
|
export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedLazyQuery>;
|
||||||
|
export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>;
|
||||||
export const UpdateLabPublicFeatureFlagDocument = gql`
|
export const UpdateLabPublicFeatureFlagDocument = gql`
|
||||||
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
||||||
updateLabPublicFeatureFlag(input: $input)
|
updateLabPublicFeatureFlag(input: $input)
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_ENVIRONMENT_VARIABLES_GROUPED = gql`
|
||||||
|
query GetEnvironmentVariablesGrouped {
|
||||||
|
getEnvironmentVariablesGrouped {
|
||||||
|
groups {
|
||||||
|
groupName
|
||||||
|
variables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
value
|
||||||
|
sensitive
|
||||||
|
}
|
||||||
|
subgroups {
|
||||||
|
subgroupName
|
||||||
|
variables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
value
|
||||||
|
sensitive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -7,6 +7,8 @@ import {
|
|||||||
AuthExceptionCode,
|
AuthExceptionCode,
|
||||||
} from 'src/engine/core-modules/auth/auth.exception';
|
} from 'src/engine/core-modules/auth/auth.exception';
|
||||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
@ -17,6 +19,7 @@ const WorkspaceFindOneMock = jest.fn();
|
|||||||
const FeatureFlagUpdateMock = jest.fn();
|
const FeatureFlagUpdateMock = jest.fn();
|
||||||
const FeatureFlagSaveMock = jest.fn();
|
const FeatureFlagSaveMock = jest.fn();
|
||||||
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
|
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
|
||||||
|
const EnvironmentServiceGetAllMock = jest.fn();
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
||||||
@ -29,6 +32,13 @@ jest.mock(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'src/engine/core-modules/environment/constants/environment-variables-hidden-groups',
|
||||||
|
() => ({
|
||||||
|
ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: new Set(['HIDDEN_GROUP']),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
describe('AdminPanelService', () => {
|
describe('AdminPanelService', () => {
|
||||||
let service: AdminPanelService;
|
let service: AdminPanelService;
|
||||||
|
|
||||||
@ -61,6 +71,12 @@ describe('AdminPanelService', () => {
|
|||||||
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
|
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {
|
||||||
|
getAll: EnvironmentServiceGetAllMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -214,4 +230,144 @@ describe('AdminPanelService', () => {
|
|||||||
|
|
||||||
expect(UserFindOneMock).toHaveBeenCalled();
|
expect(UserFindOneMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getEnvironmentVariablesGrouped', () => {
|
||||||
|
it('should correctly group environment variables', () => {
|
||||||
|
EnvironmentServiceGetAllMock.mockReturnValue({
|
||||||
|
VAR_1: {
|
||||||
|
value: 'value1',
|
||||||
|
metadata: {
|
||||||
|
group: 'GROUP_1',
|
||||||
|
description: 'Description 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VAR_2: {
|
||||||
|
value: 'value2',
|
||||||
|
metadata: {
|
||||||
|
group: 'GROUP_1',
|
||||||
|
subGroup: 'SUBGROUP_1',
|
||||||
|
description: 'Description 2',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VAR_3: {
|
||||||
|
value: 'value3',
|
||||||
|
metadata: {
|
||||||
|
group: 'GROUP_2',
|
||||||
|
description: 'Description 3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.getEnvironmentVariablesGrouped();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
groups: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
groupName: 'GROUP_1',
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
name: 'VAR_1',
|
||||||
|
value: 'value1',
|
||||||
|
description: 'Description 1',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subgroups: [
|
||||||
|
{
|
||||||
|
subgroupName: 'SUBGROUP_1',
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
name: 'VAR_2',
|
||||||
|
value: 'value2',
|
||||||
|
description: 'Description 2',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
groupName: 'GROUP_2',
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
name: 'VAR_3',
|
||||||
|
value: 'value3',
|
||||||
|
description: 'Description 3',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subgroups: [],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort groups by position and variables alphabetically', () => {
|
||||||
|
EnvironmentServiceGetAllMock.mockReturnValue({
|
||||||
|
Z_VAR: {
|
||||||
|
value: 'valueZ',
|
||||||
|
metadata: {
|
||||||
|
group: 'GROUP_1',
|
||||||
|
description: 'Description Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
A_VAR: {
|
||||||
|
value: 'valueA',
|
||||||
|
metadata: {
|
||||||
|
group: 'GROUP_1',
|
||||||
|
description: 'Description A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.getEnvironmentVariablesGrouped();
|
||||||
|
|
||||||
|
const group = result.groups.find(
|
||||||
|
(g) => g.groupName === ('GROUP_1' as EnvironmentVariablesGroup),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(group?.variables[0].name).toBe('A_VAR');
|
||||||
|
expect(group?.variables[1].name).toBe('Z_VAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty environment variables', () => {
|
||||||
|
EnvironmentServiceGetAllMock.mockReturnValue({});
|
||||||
|
|
||||||
|
const result = service.getEnvironmentVariablesGrouped();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude hidden groups from the output', () => {
|
||||||
|
EnvironmentServiceGetAllMock.mockReturnValue({
|
||||||
|
VAR_1: {
|
||||||
|
value: 'value1',
|
||||||
|
metadata: {
|
||||||
|
group: 'HIDDEN_GROUP',
|
||||||
|
description: 'Description 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VAR_2: {
|
||||||
|
value: 'value2',
|
||||||
|
metadata: {
|
||||||
|
group: 'VISIBLE_GROUP',
|
||||||
|
description: 'Description 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.getEnvironmentVariablesGrouped();
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(1);
|
||||||
|
expect(result.groups[0].groupName).toBe('VISIBLE_GROUP');
|
||||||
|
expect(result.groups).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
groupName: 'HIDDEN_GROUP',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||||
|
import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output';
|
||||||
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
|
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
|
||||||
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
|
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
|
||||||
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
|
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
|
||||||
@ -46,4 +47,10 @@ export class AdminPanelResolver {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
|
||||||
|
@Query(() => EnvironmentVariablesOutput)
|
||||||
|
async getEnvironmentVariablesGrouped(): Promise<EnvironmentVariablesOutput> {
|
||||||
|
return this.adminService.getEnvironmentVariablesGrouped();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,20 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { EnvironmentVariable } from 'src/engine/core-modules/admin-panel/dtos/environment-variable.dto';
|
||||||
|
import { EnvironmentVariablesGroupData } from 'src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto';
|
||||||
|
import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output';
|
||||||
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
|
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
|
||||||
import {
|
import {
|
||||||
AuthException,
|
AuthException,
|
||||||
AuthExceptionCode,
|
AuthExceptionCode,
|
||||||
} from 'src/engine/core-modules/auth/auth.exception';
|
} from 'src/engine/core-modules/auth/auth.exception';
|
||||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||||
|
import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position';
|
||||||
|
import { ENVIRONMENT_VARIABLES_HIDDEN_GROUPS } from 'src/engine/core-modules/environment/constants/environment-variables-hidden-groups';
|
||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum';
|
||||||
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
import {
|
import {
|
||||||
@ -25,6 +33,7 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.
|
|||||||
export class AdminPanelService {
|
export class AdminPanelService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly loginTokenService: LoginTokenService,
|
private readonly loginTokenService: LoginTokenService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
@InjectRepository(User, 'core')
|
@InjectRepository(User, 'core')
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
@ -157,4 +166,74 @@ export class AdminPanelService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnvironmentVariablesGrouped(): EnvironmentVariablesOutput {
|
||||||
|
const rawEnvVars = this.environmentService.getAll();
|
||||||
|
const groupedData = new Map<
|
||||||
|
EnvironmentVariablesGroup,
|
||||||
|
{
|
||||||
|
variables: EnvironmentVariable[];
|
||||||
|
subgroups: Map<EnvironmentVariablesSubGroup, EnvironmentVariable[]>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const [varName, { value, metadata }] of Object.entries(rawEnvVars)) {
|
||||||
|
const { group, subGroup, description } = metadata;
|
||||||
|
|
||||||
|
if (ENVIRONMENT_VARIABLES_HIDDEN_GROUPS.has(group)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envVar: EnvironmentVariable = {
|
||||||
|
name: varName,
|
||||||
|
description,
|
||||||
|
value: String(value),
|
||||||
|
sensitive: metadata.sensitive ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentGroup = groupedData.get(group);
|
||||||
|
|
||||||
|
if (!currentGroup) {
|
||||||
|
currentGroup = {
|
||||||
|
variables: [],
|
||||||
|
subgroups: new Map(),
|
||||||
|
};
|
||||||
|
groupedData.set(group, currentGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subGroup) {
|
||||||
|
let subgroupVars = currentGroup.subgroups.get(subGroup);
|
||||||
|
|
||||||
|
if (!subgroupVars) {
|
||||||
|
subgroupVars = [];
|
||||||
|
currentGroup.subgroups.set(subGroup, subgroupVars);
|
||||||
|
}
|
||||||
|
subgroupVars.push(envVar);
|
||||||
|
} else {
|
||||||
|
currentGroup.variables.push(envVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: EnvironmentVariablesGroupData[] = Array.from(
|
||||||
|
groupedData.entries(),
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const positionA = ENVIRONMENT_VARIABLES_GROUP_POSITION[a[0]];
|
||||||
|
const positionB = ENVIRONMENT_VARIABLES_GROUP_POSITION[b[0]];
|
||||||
|
|
||||||
|
return positionA - positionB;
|
||||||
|
})
|
||||||
|
.map(([groupName, data]) => ({
|
||||||
|
groupName,
|
||||||
|
variables: data.variables.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
subgroups: Array.from(data.subgroups.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([subgroupName, variables]) => ({
|
||||||
|
subgroupName,
|
||||||
|
variables,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { groups };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class EnvironmentVariable {
|
||||||
|
@Field()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
sensitive: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
|
||||||
|
import { EnvironmentVariable } from './environment-variable.dto';
|
||||||
|
import { EnvironmentVariablesSubgroupData } from './environment-variables-subgroup.dto';
|
||||||
|
|
||||||
|
registerEnumType(EnvironmentVariablesGroup, {
|
||||||
|
name: 'EnvironmentVariablesGroup',
|
||||||
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class EnvironmentVariablesGroupData {
|
||||||
|
@Field(() => [EnvironmentVariable])
|
||||||
|
variables: EnvironmentVariable[];
|
||||||
|
|
||||||
|
@Field(() => [EnvironmentVariablesSubgroupData])
|
||||||
|
subgroups: EnvironmentVariablesSubgroupData[];
|
||||||
|
|
||||||
|
@Field(() => EnvironmentVariablesGroup)
|
||||||
|
groupName: EnvironmentVariablesGroup;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum';
|
||||||
|
|
||||||
|
import { EnvironmentVariable } from './environment-variable.dto';
|
||||||
|
|
||||||
|
registerEnumType(EnvironmentVariablesSubGroup, {
|
||||||
|
name: 'EnvironmentVariablesSubGroup',
|
||||||
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class EnvironmentVariablesSubgroupData {
|
||||||
|
@Field(() => [EnvironmentVariable])
|
||||||
|
variables: EnvironmentVariable[];
|
||||||
|
|
||||||
|
@Field(() => EnvironmentVariablesSubGroup)
|
||||||
|
subgroupName: EnvironmentVariablesSubGroup;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { EnvironmentVariablesGroupData } from './environment-variables-group.dto';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class EnvironmentVariablesOutput {
|
||||||
|
@Field(() => [EnvironmentVariablesGroupData])
|
||||||
|
groups: EnvironmentVariablesGroupData[];
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position';
|
||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
|
||||||
|
describe('ENVIRONMENT_VARIABLES_GROUP_POSITION', () => {
|
||||||
|
it('should include all EnvironmentVariablesGroup enum values', () => {
|
||||||
|
const enumValues = Object.values(EnvironmentVariablesGroup);
|
||||||
|
const positionKeys = Object.keys(ENVIRONMENT_VARIABLES_GROUP_POSITION);
|
||||||
|
|
||||||
|
enumValues.forEach((enumValue) => {
|
||||||
|
expect(positionKeys).toContain(enumValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
positionKeys.forEach((key) => {
|
||||||
|
expect(enumValues).toContain(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enumValues.length).toBe(positionKeys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique position values', () => {
|
||||||
|
const positions = Object.values(ENVIRONMENT_VARIABLES_GROUP_POSITION);
|
||||||
|
const uniquePositions = new Set(positions);
|
||||||
|
|
||||||
|
expect(positions.length).toBe(uniquePositions.size);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
|
||||||
|
export const ENVIRONMENT_VARIABLES_GROUP_POSITION: Record<
|
||||||
|
EnvironmentVariablesGroup,
|
||||||
|
number
|
||||||
|
> = {
|
||||||
|
[EnvironmentVariablesGroup.ServerConfig]: 100,
|
||||||
|
[EnvironmentVariablesGroup.Database]: 200,
|
||||||
|
[EnvironmentVariablesGroup.Security]: 300,
|
||||||
|
[EnvironmentVariablesGroup.Authentication]: 400,
|
||||||
|
[EnvironmentVariablesGroup.Cache]: 500,
|
||||||
|
[EnvironmentVariablesGroup.QueueConfig]: 600,
|
||||||
|
[EnvironmentVariablesGroup.Storage]: 700,
|
||||||
|
[EnvironmentVariablesGroup.Email]: 800,
|
||||||
|
[EnvironmentVariablesGroup.Frontend]: 900,
|
||||||
|
[EnvironmentVariablesGroup.Workspace]: 1000,
|
||||||
|
[EnvironmentVariablesGroup.Analytics]: 1100,
|
||||||
|
[EnvironmentVariablesGroup.Logging]: 1200,
|
||||||
|
[EnvironmentVariablesGroup.Billing]: 1300,
|
||||||
|
[EnvironmentVariablesGroup.Support]: 1400,
|
||||||
|
[EnvironmentVariablesGroup.LLM]: 1500,
|
||||||
|
[EnvironmentVariablesGroup.Serverless]: 1600,
|
||||||
|
};
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
|
||||||
|
export const ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: Set<EnvironmentVariablesGroup> =
|
||||||
|
new Set([EnvironmentVariablesGroup.LLM]);
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum';
|
||||||
|
|
||||||
|
type LastNCharsConfig = {
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies.LAST_N_CHARS;
|
||||||
|
chars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HidePasswordConfig = {
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaskingConfigType = {
|
||||||
|
APP_SECRET: LastNCharsConfig;
|
||||||
|
PG_DATABASE_URL: HidePasswordConfig;
|
||||||
|
REDIS_URL: HidePasswordConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENVIRONMENT_VARIABLES_MASKING_CONFIG: MaskingConfigType = {
|
||||||
|
APP_SECRET: {
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
chars: 5,
|
||||||
|
},
|
||||||
|
PG_DATABASE_URL: {
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD,
|
||||||
|
},
|
||||||
|
REDIS_URL: {
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const ENVIRONMENT_VARIABLES_METADATA_DECORATOR_KEY =
|
||||||
|
'environment-variables-metadata' as const;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const ENVIRONMENT_VARIABLES_METADATA_DECORATOR_NAMES_KEY =
|
||||||
|
'environment-variable-names' as const;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { registerDecorator, ValidationOptions } from 'class-validator';
|
||||||
|
|
||||||
|
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
|
||||||
|
import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum';
|
||||||
|
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||||
|
|
||||||
|
export interface EnvironmentVariablesMetadataOptions {
|
||||||
|
group: EnvironmentVariablesGroup;
|
||||||
|
subGroup?: EnvironmentVariablesSubGroup;
|
||||||
|
description: string;
|
||||||
|
sensitive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnvironmentVariablesMetadataMap = {
|
||||||
|
[key: string]: EnvironmentVariablesMetadataOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EnvironmentVariablesMetadata(
|
||||||
|
options: EnvironmentVariablesMetadataOptions,
|
||||||
|
validationOptions?: ValidationOptions,
|
||||||
|
): PropertyDecorator {
|
||||||
|
return (target: object, propertyKey: string | symbol) => {
|
||||||
|
const existingMetadata: EnvironmentVariablesMetadataMap =
|
||||||
|
TypedReflect.getMetadata('environment-variables', target.constructor) ??
|
||||||
|
{};
|
||||||
|
|
||||||
|
TypedReflect.defineMetadata(
|
||||||
|
'environment-variables',
|
||||||
|
{
|
||||||
|
...existingMetadata,
|
||||||
|
[propertyKey.toString()]: options,
|
||||||
|
},
|
||||||
|
target.constructor,
|
||||||
|
);
|
||||||
|
|
||||||
|
registerDecorator({
|
||||||
|
name: propertyKey.toString(),
|
||||||
|
target: target.constructor,
|
||||||
|
propertyName: propertyKey.toString(),
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [options],
|
||||||
|
validator: {
|
||||||
|
validate() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
defaultMessage() {
|
||||||
|
return `${propertyKey.toString()} has invalid metadata`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
export enum EnvironmentVariablesGroup {
|
||||||
|
Authentication = 'authentication',
|
||||||
|
Email = 'email',
|
||||||
|
Database = 'database',
|
||||||
|
Storage = 'storage',
|
||||||
|
ServerConfig = 'server-config',
|
||||||
|
QueueConfig = 'queue-config',
|
||||||
|
Logging = 'logging',
|
||||||
|
Cache = 'cache',
|
||||||
|
Analytics = 'analytics',
|
||||||
|
Billing = 'billing',
|
||||||
|
Frontend = 'frontend',
|
||||||
|
Security = 'security',
|
||||||
|
Serverless = 'serverless',
|
||||||
|
Support = 'support',
|
||||||
|
LLM = 'llm',
|
||||||
|
Workspace = 'workspace',
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export enum EnvironmentVariablesMaskingStrategies {
|
||||||
|
LAST_N_CHARS = 'LAST_N_CHARS',
|
||||||
|
HIDE_PASSWORD = 'HIDE_PASSWORD',
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
export enum EnvironmentVariablesSubGroup {
|
||||||
|
PasswordAuth = 'password-auth',
|
||||||
|
GoogleAuth = 'google-auth',
|
||||||
|
MicrosoftAuth = 'microsoft-auth',
|
||||||
|
SmtpConfig = 'smtp-config',
|
||||||
|
EmailSettings = 'email-settings',
|
||||||
|
S3Config = 's3-config',
|
||||||
|
Tokens = 'tokens',
|
||||||
|
SSL = 'ssl',
|
||||||
|
RateLimiting = 'rate-limiting',
|
||||||
|
LambdaConfig = 'lambda-config',
|
||||||
|
TinybirdConfig = 'tinybird-config',
|
||||||
|
StripeConfig = 'stripe-config',
|
||||||
|
SentryConfig = 'sentry-config',
|
||||||
|
FrontSupportConfig = 'front-support-config',
|
||||||
|
CloudflareConfig = 'cloudflare-config',
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,12 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
|
|
||||||
describe('EnvironmentService', () => {
|
describe('EnvironmentService', () => {
|
||||||
let service: EnvironmentService;
|
let service: EnvironmentService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@ -12,15 +14,98 @@ describe('EnvironmentService', () => {
|
|||||||
EnvironmentService,
|
EnvironmentService,
|
||||||
{
|
{
|
||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: {},
|
useValue: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<EnvironmentService>(EnvironmentService);
|
service = module.get<EnvironmentService>(EnvironmentService);
|
||||||
|
configService = module.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
Reflect.defineMetadata('environment-variables', {}, EnvironmentVariables);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAll()', () => {
|
||||||
|
it('should return empty object when no environment variables are defined', () => {
|
||||||
|
const result = service.getAll();
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return environment variables with their metadata', () => {
|
||||||
|
const mockMetadata = {
|
||||||
|
TEST_VAR: {
|
||||||
|
title: 'Test Var',
|
||||||
|
description: 'Test Description',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
'environment-variables',
|
||||||
|
mockMetadata,
|
||||||
|
EnvironmentVariables,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue('test-value');
|
||||||
|
|
||||||
|
const result = service.getAll();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
TEST_VAR: {
|
||||||
|
value: 'test-value',
|
||||||
|
metadata: mockMetadata.TEST_VAR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask sensitive data according to masking config', () => {
|
||||||
|
const mockMetadata = {
|
||||||
|
APP_SECRET: {
|
||||||
|
title: 'App Secret',
|
||||||
|
description: 'Application secret key',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
'environment-variables',
|
||||||
|
mockMetadata,
|
||||||
|
EnvironmentVariables,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue('super-secret-value');
|
||||||
|
|
||||||
|
const result = service.getAll();
|
||||||
|
|
||||||
|
expect(result.APP_SECRET.value).not.toBe('super-secret-value');
|
||||||
|
expect(result.APP_SECRET.value).toMatch(/^\*+[a-zA-Z0-9]{5}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default value when environment variable is not set', () => {
|
||||||
|
const mockMetadata = {
|
||||||
|
DEBUG_PORT: {
|
||||||
|
title: 'Debug Port',
|
||||||
|
description: 'Debug port number',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
'environment-variables',
|
||||||
|
mockMetadata,
|
||||||
|
EnvironmentVariables,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = service.getAll();
|
||||||
|
|
||||||
|
expect(result.DEBUG_PORT.value).toBe(9000);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { ENVIRONMENT_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/environment/constants/environment-variables-masking-config';
|
||||||
|
import { EnvironmentVariablesMetadataOptions } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator';
|
||||||
|
import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum';
|
||||||
import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables';
|
import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables';
|
||||||
|
import { environmentVariableMaskSensitiveData } from 'src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util';
|
||||||
|
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnvironmentService {
|
export class EnvironmentService {
|
||||||
@ -14,4 +19,60 @@ export class EnvironmentService {
|
|||||||
new EnvironmentVariables()[key],
|
new EnvironmentVariables()[key],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAll(): Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: EnvironmentVariables[keyof EnvironmentVariables];
|
||||||
|
metadata: EnvironmentVariablesMetadataOptions;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const result: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: EnvironmentVariables[keyof EnvironmentVariables];
|
||||||
|
metadata: EnvironmentVariablesMetadataOptions;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
const envVars = new EnvironmentVariables();
|
||||||
|
const metadata =
|
||||||
|
TypedReflect.getMetadata('environment-variables', EnvironmentVariables) ??
|
||||||
|
{};
|
||||||
|
|
||||||
|
Object.entries(metadata).forEach(([key, envMetadata]) => {
|
||||||
|
let value =
|
||||||
|
this.configService.get(key) ??
|
||||||
|
envVars[key as keyof EnvironmentVariables] ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
key in ENVIRONMENT_VARIABLES_MASKING_CONFIG
|
||||||
|
) {
|
||||||
|
const varMaskingConfig =
|
||||||
|
ENVIRONMENT_VARIABLES_MASKING_CONFIG[
|
||||||
|
key as keyof typeof ENVIRONMENT_VARIABLES_MASKING_CONFIG
|
||||||
|
];
|
||||||
|
const options =
|
||||||
|
varMaskingConfig.strategy ===
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS
|
||||||
|
? { chars: varMaskingConfig.chars }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
value = environmentVariableMaskSensitiveData(
|
||||||
|
value,
|
||||||
|
varMaskingConfig.strategy,
|
||||||
|
{ ...options, variableName: key },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = {
|
||||||
|
value,
|
||||||
|
metadata: envMetadata,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum';
|
||||||
|
import { environmentVariableMaskSensitiveData } from 'src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util';
|
||||||
|
|
||||||
|
describe('environmentVariableMaskSensitiveData', () => {
|
||||||
|
describe('LAST_N_CHARS strategy', () => {
|
||||||
|
it('should mask all but the last 5 characters by default', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'mysecretvalue123',
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('********ue123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask all but the specified number of characters', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'mysecretvalue123',
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
{ chars: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('********123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all asterisks if value is shorter than mask length', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'123',
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
{ chars: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('********');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'',
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HIDE_PASSWORD strategy', () => {
|
||||||
|
it('should mask password in URL', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'postgresql://user:password123@localhost:5432/db',
|
||||||
|
EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('postgresql://********:********@localhost:5432/db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URL without password', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
'postgresql://localhost:5432/db',
|
||||||
|
EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('postgresql://localhost:5432/db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid URLs', () => {
|
||||||
|
expect(() =>
|
||||||
|
environmentVariableMaskSensitiveData(
|
||||||
|
'not-a-url',
|
||||||
|
EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD,
|
||||||
|
{ variableName: 'TEST_URL' },
|
||||||
|
),
|
||||||
|
).toThrow(
|
||||||
|
'Invalid URL format for TEST_URL in HIDE_PASSWORD masking strategy',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null value', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
null as any,
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined value', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
undefined as any,
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-string value', () => {
|
||||||
|
const result = environmentVariableMaskSensitiveData(
|
||||||
|
123 as any,
|
||||||
|
EnvironmentVariablesMaskingStrategies.LAST_N_CHARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum';
|
||||||
|
|
||||||
|
export const environmentVariableMaskSensitiveData = (
|
||||||
|
value: string,
|
||||||
|
strategy: EnvironmentVariablesMaskingStrategies,
|
||||||
|
options?: { chars?: number; variableName?: string },
|
||||||
|
): string => {
|
||||||
|
if (!value || typeof value !== 'string') return value;
|
||||||
|
switch (strategy) {
|
||||||
|
case EnvironmentVariablesMaskingStrategies.LAST_N_CHARS: {
|
||||||
|
const n = Math.max(1, options?.chars ?? 5);
|
||||||
|
|
||||||
|
return value.length > n ? `********${value.slice(-n)}` : '********';
|
||||||
|
}
|
||||||
|
|
||||||
|
case EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD: {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
|
||||||
|
if (url.password) {
|
||||||
|
url.password = '********';
|
||||||
|
}
|
||||||
|
if (url.username) {
|
||||||
|
url.username = '********';
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid URL format for ${options?.variableName || 'environment variable'} in HIDE_PASSWORD masking strategy`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -2,6 +2,8 @@ import 'reflect-metadata';
|
|||||||
|
|
||||||
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
|
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
|
||||||
|
|
||||||
|
import { EnvironmentVariablesMetadataMap } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator';
|
||||||
|
|
||||||
export interface ReflectMetadataTypeMap {
|
export interface ReflectMetadataTypeMap {
|
||||||
['workspace:is-nullable-metadata-args']: true;
|
['workspace:is-nullable-metadata-args']: true;
|
||||||
['workspace:gate-metadata-args']: Gate;
|
['workspace:gate-metadata-args']: Gate;
|
||||||
@ -10,6 +12,7 @@ export interface ReflectMetadataTypeMap {
|
|||||||
['workspace:is-primary-field-metadata-args']: true;
|
['workspace:is-primary-field-metadata-args']: true;
|
||||||
['workspace:is-deprecated-field-metadata-args']: true;
|
['workspace:is-deprecated-field-metadata-args']: true;
|
||||||
['workspace:is-unique-metadata-args']: true;
|
['workspace:is-unique-metadata-args']: true;
|
||||||
|
['environment-variables']: EnvironmentVariablesMetadataMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TypedReflect {
|
export class TypedReflect {
|
||||||
|
|||||||
Reference in New Issue
Block a user