Merge remote-tracking branch 'origin/main' into ENG-3546

This commit is contained in:
Carlos Monastyrski
2025-09-04 09:43:55 -03:00
116 changed files with 10134 additions and 5764 deletions
@@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
hcvault: hcVaultConnectionService(connectAppConnectionById),
hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService),
windmill: windmillConnectionService(connectAppConnectionById),
teamcity: teamcityConnectionService(connectAppConnectionById),
oci: ociConnectionService(connectAppConnectionById, licenseService),
@@ -91,7 +91,7 @@ export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0
};
} catch (e: unknown) {
throw new BadRequestError({
message: (e as Error).message ?? `Unable to validate connection: verify credentials`
message: (e as Error).message ?? "Unable to validate connection: verify credentials"
});
}
};
@@ -70,7 +70,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -204,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -186,7 +186,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun
};
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
};
@@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa
};
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
};
@@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
}
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
@@ -447,7 +447,7 @@ export const validateGitHubConnectionCredentials = async (
}
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
@@ -1,18 +1,18 @@
import { AxiosError } from "axios";
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import {
THCVaultConnection,
THCVaultConnectionConfig,
THCVaultMountResponse,
TValidateHCVaultConnectionCredentials
} from "./hc-vault-connection-types";
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
@@ -37,7 +37,78 @@ type TokenRespData = {
};
};
export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => {
export const requestWithHCVaultGateway = async <T>(
appConnection: { gatewayId?: string | null },
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
requestConfig: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
const { gatewayId } = appConnection;
// If gateway isn't set up, don't proxy request
if (!gatewayId) {
return request.request(requestConfig);
}
const url = new URL(requestConfig.url as string);
await blockLocalAndPrivateIpAddresses(url.toString());
const [targetHost] = await verifyHostInputValidity(url.hostname, true);
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
return withGatewayProxy(
async (proxyPort) => {
const httpsAgent = new https.Agent({
servername: targetHost
});
url.protocol = "https:";
url.host = `localhost:${proxyPort}`;
const finalRequestConfig: AxiosRequestConfig = {
...requestConfig,
url: url.toString(),
httpsAgent,
headers: {
...requestConfig.headers,
Host: targetHost
}
};
try {
return await request.request(finalRequestConfig);
} catch (error) {
if (error instanceof AxiosError) {
logger.error(
{ message: error.message, data: (error.response as undefined | { data: unknown })?.data },
"Error during HashiCorp Vault gateway request:"
);
}
throw error;
}
},
{
protocol: GatewayProxyProtocol.Tcp,
targetHost,
targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
};
export const getHCVaultAccessToken = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
// Return access token directly if not using AppRole method
if (connection.method !== HCVaultConnectionMethod.AppRole) {
return connection.credentials.accessToken;
@@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
// Generate temporary token for AppRole method
try {
const { instanceUrl, roleId, secretId } = connection.credentials;
const tokenResp = await request.post<TokenRespData>(
`${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
{ role_id: roleId, secret_id: secretId },
{
headers: {
"Content-Type": "application/json",
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
}
}
);
const tokenResp = await requestWithHCVaultGateway<TokenRespData>(connection, gatewayService, {
url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
method: "POST",
headers: {
"Content-Type": "application/json",
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
},
data: { role_id: roleId, secret_id: secretId }
});
if (tokenResp.status !== 200) {
throw new BadRequestError({
@@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
}
};
export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => {
const instanceUrl = await getHCVaultInstanceUrl(config);
export const validateHCVaultConnectionCredentials = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
try {
const accessToken = await getHCVaultAccessToken(config);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
// Verify token
await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, {
await requestWithHCVaultGateway(connection, gatewayService, {
url: `${instanceUrl}/v1/auth/token/lookup-self`,
method: "GET",
headers: { "X-Vault-Token": accessToken }
});
return config.credentials;
return connection.credentials;
} catch (error: unknown) {
logger.error(error, "Unable to verify HC Vault connection");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
};
export const listHCVaultMounts = async (appConnection: THCVaultConnection) => {
const instanceUrl = await getHCVaultInstanceUrl(appConnection);
const accessToken = await getHCVaultAccessToken(appConnection);
export const listHCVaultMounts = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const { data } = await request.get<THCVaultMountResponse>(`${instanceUrl}/v1/sys/mounts`, {
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/mounts`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {})
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
}
});
@@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection(
export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [
BaseHCVaultConnectionSchema.extend({
method: z.literal(HCVaultConnectionMethod.AccessToken),
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({})
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({
namespace: true,
instanceUrl: true
})
}),
BaseHCVaultConnectionSchema.extend({
method: z.literal(HCVaultConnectionMethod.AppRole),
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({})
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({
namespace: true,
instanceUrl: true,
roleId: true
})
})
]);
@@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("
]);
export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault)
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })
);
export const UpdateHCVaultConnectionSchema = z
@@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z
.optional()
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault));
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }));
export const HCVaultConnectionListItemSchema = z.object({
name: z.literal("HCVault"),
@@ -1,3 +1,4 @@
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
@@ -11,12 +12,15 @@ type TGetAppConnectionFunc = (
actor: OrgServiceActor
) => Promise<THCVaultConnection>;
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
export const hcVaultConnectionService = (
getAppConnection: TGetAppConnectionFunc,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
try {
const mounts = await listHCVaultMounts(appConnection);
const mounts = await listHCVaultMounts(appConnection, gatewayService);
return mounts;
} catch (error) {
logger.error(error, "Failed to establish connection with Hashicorp Vault");
@@ -453,23 +453,24 @@ export const authLoginServiceFactory = ({
const selectedOrg = await orgDAL.findById(organizationId);
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}`
});
}
// Check if authEnforced is true and the current auth method is not an enforced method
if (
selectedOrg.authEnforced &&
!isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC
decodedToken.authMethod !== AuthMethod.OIDC &&
!(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin)
) {
throw new BadRequestError({
message: "Login with the auth method required by your organization."
});
}
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}`
});
}
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
@@ -408,19 +408,123 @@ export const transformToInfisicalFormatNamespaceToProjects = (
};
};
export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => {
const projects: Array<{ name: string; id: string }> = [];
const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = [];
const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = [];
const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = [];
// track created entities to avoid duplicates
const projectMap = new Map<string, string>(); // team name -> projectId
const environmentMap = new Map<string, string>(); // team-name:envName -> environmentId
const folderMap = new Map<string, string>(); // team-name:envName:folderPath -> folderId
for (const data of vaultData) {
const { path, secretData } = data;
const pathParts = path.split("/").filter(Boolean);
if (pathParts.length < 2) {
// eslint-disable-next-line no-continue
continue;
}
// first level: environment (dev, prod, staging, etc.)
const environmentName = pathParts[0];
// second level: team name (team1, team2, etc.)
const teamName = pathParts[1];
// remaining parts: folder structure
const folderParts = pathParts.slice(2);
// create project (team) if if doesn't exist
if (!projectMap.has(teamName)) {
const projectId = uuidv4();
projectMap.set(teamName, projectId);
projects.push({
name: teamName,
id: projectId
});
}
const projectId = projectMap.get(teamName)!;
// create environment (dev, prod, etc.) for team
const envKey = `${teamName}:${environmentName}`;
if (!environmentMap.has(envKey)) {
const environmentId = uuidv4();
environmentMap.set(envKey, environmentId);
environments.push({
name: environmentName,
id: environmentId,
projectId
});
}
const environmentId = environmentMap.get(envKey)!;
// create folder structure for path segments
let currentFolderId: string | undefined;
let currentPath = "";
for (const folderName of folderParts) {
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
const folderKey = `${teamName}:${environmentName}:${currentPath}`;
if (!folderMap.has(folderKey)) {
const folderId = uuidv4();
folderMap.set(folderKey, folderId);
folders.push({
id: folderId,
name: folderName,
environmentId,
parentFolderId: currentFolderId || environmentId
});
currentFolderId = folderId;
} else {
currentFolderId = folderMap.get(folderKey)!;
}
}
for (const [key, value] of Object.entries(secretData)) {
secrets.push({
id: uuidv4(),
name: key,
environmentId,
value: String(value),
folderId: currentFolderId
});
}
}
return {
projects,
environments,
folders,
secrets
};
};
// refer to internal doc for more details on which ID's belong to which orgs.
// when its a custom migration, then it doesn't matter which mapping type is used (as of now).
export const vaultMigrationTransformMappings: Record<
string,
(vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData
> = {
"68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1
} as const;
export const importVaultDataFn = async (
{
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType,
gatewayId
gatewayId,
orgId
}: {
vaultAccessToken: string;
vaultNamespace?: string;
vaultUrl: string;
mappingType: VaultMappingType;
gatewayId?: string;
orgId: string;
},
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => {
@@ -432,6 +536,25 @@ export const importVaultDataFn = async (
});
}
let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData;
if (mappingType === VaultMappingType.Custom) {
transformFn = vaultMigrationTransformMappings[orgId];
if (!transformFn) {
throw new BadRequestError({
message: "Please contact our sales team to enable custom vault migrations."
});
}
} else {
transformFn = transformToInfisicalFormatNamespaceToProjects;
}
logger.info(
{ orgId, mappingType },
`[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform`
);
const vaultApi = vaultFactory(gatewayService);
const vaultData = await vaultApi.collectVaultData({
@@ -441,7 +564,5 @@ export const importVaultDataFn = async (
gatewayId
});
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
return infisicalData;
return transformFn(vaultData, mappingType);
};
@@ -5,9 +5,20 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { TUserDALFactory } from "../user/user-dal";
import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns";
import {
decryptEnvKeyDataFn,
importVaultDataFn,
parseEnvKeyDataFn,
vaultMigrationTransformMappings
} from "./external-migration-fns";
import { TExternalMigrationQueueFactory } from "./external-migration-queue";
import { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types";
import {
ExternalMigrationProviders,
ExternalPlatforms,
THasCustomVaultMigrationDTO,
TImportEnvKeyDataDTO,
TImportVaultDataDTO
} from "./external-migration-types";
type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
@@ -101,7 +112,8 @@ export const externalMigrationServiceFactory = ({
vaultNamespace,
vaultUrl,
mappingType,
gatewayId
gatewayId,
orgId: actorOrgId
},
{
gatewayService
@@ -127,8 +139,37 @@ export const externalMigrationServiceFactory = ({
});
};
const hasCustomVaultMigration = async ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
provider
}: THasCustomVaultMigrationDTO) => {
const { membership } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
if (membership.role !== OrgMembershipRole.Admin) {
throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" });
}
if (provider !== ExternalMigrationProviders.Vault) {
throw new BadRequestError({
message: "Invalid provider. Vault is the only supported provider for custom migrations."
});
}
return actorOrgId in vaultMigrationTransformMappings;
};
return {
importEnvKeyData,
importVaultData
importVaultData,
hasCustomVaultMigration
};
};
@@ -4,7 +4,8 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export enum VaultMappingType {
Namespace = "namespace",
KeyVault = "key-vault"
KeyVault = "key-vault",
Custom = "custom"
}
export type InfisicalImportData = {
@@ -26,6 +27,10 @@ export type TImportEnvKeyDataDTO = {
encryptedJson: { nonce: string; data: string };
} & Omit<TOrgPermission, "orgId">;
export type THasCustomVaultMigrationDTO = {
provider: ExternalMigrationProviders;
} & Omit<TOrgPermission, "orgId">;
export type TImportVaultDataDTO = {
vaultAccessToken: string;
vaultNamespace?: string;
@@ -111,3 +116,8 @@ export enum ExternalPlatforms {
EnvKey = "EnvKey",
Vault = "Vault"
}
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
@@ -8,11 +8,18 @@ import {
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import {
BadRequestError,
NotFoundError,
PermissionBoundaryError,
RateLimitError,
UnauthorizedError
} from "@app/lib/errors";
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
import { logger } from "@app/lib/logger";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -40,15 +47,18 @@ type TIdentityUaServiceFactoryDep = {
identityOrgMembershipDAL: TIdentityOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems">;
keyStore: Pick<
TKeyStoreFactory,
"setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock"
>;
};
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
// type LockoutObject = {
// lockedOut: boolean;
// failedAttempts: number;
// };
type LockoutObject = {
lockedOut: boolean;
failedAttempts: number;
};
export const identityUaServiceFactory = ({
identityUaDAL,
@@ -62,15 +72,8 @@ export const identityUaServiceFactory = ({
const login = async (clientId: string, clientSecret: string, ip: string) => {
const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) {
throw new NotFoundError({
message: "No identity with specified client ID was found"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
throw new NotFoundError({
message: "No identity with the org membership was found"
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
@@ -78,119 +81,184 @@ export const identityUaServiceFactory = ({
ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
});
const clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecrtInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null;
for await (const info of clientSecrtInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
if (isMatch) {
validClientSecretInfo = info;
break;
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try {
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, {
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
} catch (e) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Rate limit exceeded" });
}
if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" });
try {
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
if (currentDate > expirationTime) {
if (lockout && lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
const clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) {
validClientSecretInfo = info;
break;
}
}
if (!validClientSecretInfo) {
if (identityUa.lockoutEnabled) {
if (!lockout) {
lockout = {
lockedOut: false,
failedAttempts: 0
};
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
}
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
await keyStore.deleteItem(LOCKOUT_KEY);
}
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
message: "Access denied due to client secret usage limit reached"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
const accessTokenTTLParams =
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await identityOrgMembershipDAL.updateById(
identityMembershipOrg.id,
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
);
return newToken;
});
throw new UnauthorizedError({
message: "Access denied due to client secret usage limit reached"
});
}
const accessTokenTTLParams =
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await identityOrgMembershipDAL.updateById(
identityMembershipOrg.id,
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
clientSecretId: validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return newToken;
});
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityUa.identityId,
clientSecretId: validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return {
accessToken,
identityUa,
validClientSecretInfo,
identityAccessToken,
identityMembershipOrg,
...accessTokenTTLParams
};
return {
accessToken,
identityUa,
validClientSecretInfo,
identityAccessToken,
identityMembershipOrg,
...accessTokenTTLParams
};
} finally {
await lock.release();
}
};
const attachUniversalAuth = async ({
@@ -153,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
}
};
const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => {
try {
const members = await db
.replicaNode()(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`)
.andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`);
})
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
db.ref("orgId").withSchema(TableName.OrgMembership),
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("isActive").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId")
)
.where({ isGhost: false });
return members.map((member) => ({
id: member.id,
orgId: member.orgId,
role: member.role,
status: member.status,
isActive: member.isActive,
inviteEmail: member.inviteEmail,
user: {
id: member.userId,
email: member.email,
username: member.username,
firstName: member.firstName,
lastName: member.lastName
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find org memberships with users by org id" });
}
};
return {
...orgMembershipOrm,
findOrgMembershipById,
findRecentInvitedMemberships,
updateLastInvitedAtByIds
updateLastInvitedAtByIds,
findOrgMembershipsWithUsersByOrgId
};
};
+14 -1
View File
@@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => {
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
const formattedDocs = sqlNestRelationships({
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
orgMembershipId,
orgMembershipRole,
orgMembershipRoleName,
orgMembershipRoleId
orgMembershipRoleId,
orgMembershipStatus
}) => ({
user: {
id: userId,
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
firstName,
lastName
},
status: orgMembershipStatus,
membershipId: orgMembershipId,
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
roleId: orgMembershipRoleId
@@ -488,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => {
}
};
const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => {
try {
const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*");
return memberships;
} catch (error) {
throw new DatabaseError({ error, name: "Create org memberships" });
}
};
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
try {
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
findMembership,
findMembershipWithScimFilter,
createMembership,
bulkCreateMemberships,
updateMembershipById,
deleteMembershipById,
deleteMembershipsById,
+29 -21
View File
@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
/*
* Create organization
* */
const createOrganization = async ({
userId,
userEmail,
orgName
}: {
userId: string;
orgName: string;
userEmail?: string | null;
}) => {
const createOrganization = async (
{
userId,
userEmail,
orgName
}: {
userId?: string;
orgName: string;
userEmail?: string | null;
},
trx?: Knex
) => {
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
const key = crypto.randomBytes(32).toString("base64");
const {
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
const organization = await orgDAL.transaction(async (tx) => {
const createOrg = async (tx: Knex) => {
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
const org = await orgDAL.create(
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
tx
);
await orgDAL.createMembership(
{
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
if (userId) {
await orgDAL.createMembership(
{
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
}
await orgBotDAL.create(
{
name: org.name,
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
tx
);
return org;
});
};
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
return organization;
@@ -1,9 +1,13 @@
import { isAxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault";
import {
getHCVaultAccessToken,
getHCVaultInstanceUrl,
requestWithHCVaultGateway,
THCVaultConnection
} from "@app/services/app-connection/hc-vault";
import {
THCVaultListVariables,
THCVaultListVariablesResponse,
@@ -14,19 +18,20 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => {
await blockLocalAndPrivateIpAddresses(instanceUrl);
const listHCVaultVariables = async (
{ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
try {
const { data } = await request.get<THCVaultListVariablesResponse>(
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
{
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
}
const { data } = await requestWithHCVaultGateway<THCVaultListVariablesResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
}
);
});
return data.data.data;
} catch (error: unknown) {
@@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken
};
// Hashicorp Vault updates all variables in one batch. This is to respect their versioning
const updateHCVaultVariables = async ({
path,
instanceUrl,
namespace,
accessToken,
mount,
data
}: TPostHCVaultVariable) => {
await blockLocalAndPrivateIpAddresses(instanceUrl);
return request.post(
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
{
data
const updateHCVaultVariables = async (
{ path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
return requestWithHCVaultGateway(connection, gatewayService, {
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
method: "POST",
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
"Content-Type": "application/json"
},
{
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
"Content-Type": "application/json"
}
}
);
data: { data }
});
};
export const HCVaultSyncFns = {
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
syncSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
environment,
@@ -74,16 +75,20 @@ export const HCVaultSyncFns = {
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({
instanceUrl,
accessToken,
namespace,
mount,
path
});
const variables = await listHCVaultVariables(
{
instanceUrl,
accessToken,
namespace,
mount,
path
},
connection,
gatewayService
);
let tainted = false;
for (const entry of Object.entries(secretMap)) {
@@ -110,24 +115,36 @@ export const HCVaultSyncFns = {
if (!tainted) return;
try {
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
await updateHCVaultVariables(
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
connection,
gatewayService
);
} catch (error) {
throw new SecretSyncError({
error
});
}
},
removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
removeSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
destinationConfig: { mount, path }
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path });
const variables = await listHCVaultVariables(
{ instanceUrl, namespace, accessToken, mount, path },
connection,
gatewayService
);
for await (const [key] of Object.entries(variables)) {
if (key in secretMap) {
@@ -136,30 +153,41 @@ export const HCVaultSyncFns = {
}
try {
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
await updateHCVaultVariables(
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
connection,
gatewayService
);
} catch (error) {
throw new SecretSyncError({
error
});
}
},
getSecrets: async (secretSync: THCVaultSyncWithCredentials) => {
getSecrets: async (
secretSync: THCVaultSyncWithCredentials,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
destinationConfig: { mount, path }
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({
instanceUrl,
namespace,
accessToken,
mount,
path
});
const variables = await listHCVaultVariables(
{
instanceUrl,
namespace,
accessToken,
mount,
path
},
connection,
gatewayService
);
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }]));
}
@@ -244,7 +244,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill:
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault:
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap);
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity:
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault:
@@ -283,7 +283,7 @@ export const SecretSyncFns = {
},
getSecrets: async (
secretSync: TSecretSyncWithCredentials,
{ kmsService, appConnectionDAL }: TSyncSecretDeps
{ kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps
): Promise<TSecretMap> => {
let secretMap: TSecretMap;
switch (secretSync.destination) {
@@ -341,7 +341,7 @@ export const SecretSyncFns = {
secretMap = await WindmillSyncFns.getSecrets(secretSync);
break;
case SecretSync.HCVault:
secretMap = await HCVaultSyncFns.getSecrets(secretSync);
secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService);
break;
case SecretSync.TeamCity:
secretMap = await TeamCitySyncFns.getSecrets(secretSync);
@@ -451,7 +451,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill:
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault:
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity:
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault:
@@ -0,0 +1,69 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface OrganizationAssignmentTemplateProps extends Omit<BaseEmailWrapperProps, "preview" | "title"> {
inviterFirstName?: string;
inviterUsername?: string;
organizationName: string;
callback_url: string;
}
export const OrganizationAssignmentTemplate = ({
organizationName,
inviterFirstName,
inviterUsername,
callback_url,
siteUrl
}: OrganizationAssignmentTemplateProps) => {
return (
<BaseEmailWrapper
title="New Organization"
preview="You've been added to a new organization on Infisical."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
You've been added to the organization
<br />
<strong>{organizationName}</strong> on <strong>Infisical</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border text-center border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
{inviterFirstName && inviterUsername ? (
<>
<strong>{inviterFirstName}</strong> (
<BaseLink href={`mailto:${inviterUsername}`}>{inviterUsername}</BaseLink>) has added you as an
organization admin to <strong>{organizationName}</strong>.
</>
) : (
<>
An instance admin has added you as an organization admin to <strong>{organizationName}</strong>.
</>
)}
</Text>
</Section>
<Section className="text-center">
<BaseButton href={callback_url}>View Dashboard</BaseButton>
</Section>
<Section className="mt-[24px] bg-gray-50 pt-[2px] pb-[16px] border border-solid border-gray-200 px-[24px] rounded-md text-gray-800">
<Text className="mb-[0px]">
<strong>About Infisical:</strong> Infisical is an all-in-one platform to securely manage application secrets,
certificates, SSH keys, and configurations across your team and infrastructure.
</Text>
</Section>
</BaseEmailWrapper>
);
};
export default OrganizationAssignmentTemplate;
OrganizationAssignmentTemplate.PreviewProps = {
organizationName: "Example Organization",
inviterFirstName: "Jane",
inviterUsername: "[email protected]",
siteUrl: "https://infisical.com",
callback_url: "https://app.infisical.com"
} as OrganizationAssignmentTemplateProps;
@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
export * from "./NewDeviceLoginTemplate";
export * from "./OrgAdminBreakglassAccessTemplate";
export * from "./OrgAdminProjectGrantAccessTemplate";
export * from "./OrganizationAssignmentTemplate";
export * from "./OrganizationInvitationTemplate";
export * from "./PasswordResetTemplate";
export * from "./PasswordSetupTemplate";
@@ -18,6 +18,7 @@ import {
NewDeviceLoginTemplate,
OrgAdminBreakglassAccessTemplate,
OrgAdminProjectGrantAccessTemplate,
OrganizationAssignmentTemplate,
OrganizationInvitationTemplate,
PasswordResetTemplate,
PasswordSetupTemplate,
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
NewDeviceJoin = "newDevice",
OrgInvite = "organizationInvitation",
OrgAssignment = "organizationAssignment",
ResetPassword = "passwordReset",
SetupPassword = "passwordSetup",
SecretLeakIncident = "secretLeakIncident",
@@ -94,6 +96,7 @@ export enum SmtpHost {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
@@ -1,6 +1,13 @@
import { CronJob } from "cron";
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
import {
IdentityAuthMethod,
OrgMembershipRole,
OrgMembershipStatus,
TSuperAdmin,
TSuperAdminUpdate,
TUsers
} from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import {
@@ -13,7 +20,12 @@ import {
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { isDisposableEmail } from "@app/lib/validator";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAuthLoginFactory } from "../auth/auth-login-service";
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
@@ -43,7 +55,9 @@ import {
TAdminGetUsersDTO,
TAdminIntegrationConfig,
TAdminSignUpDTO,
TGetOrganizationsDTO
TCreateOrganizationDTO,
TGetOrganizationsDTO,
TResendOrgInviteDTO
} from "./super-admin-types";
type TSuperAdminServiceFactoryDep = {
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
kmsRootConfigDAL: TKmsRootConfigDALFactory;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "updateSubscriptionOrgMemberCount">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
invalidateCacheQueue: TInvalidateCacheQueueFactory;
smtpService: Pick<TSmtpService, "sendMail">;
tokenService: TAuthTokenServiceFactory;
};
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
identityTokenAuthDAL,
identityOrgMembershipDAL,
microsoftTeamsService,
invalidateCacheQueue
invalidateCacheQueue,
smtpService,
tokenService
}: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => {
// TODO(akhilmhdh): bad pattern time less change this later to me itself
@@ -732,6 +750,159 @@ export const superAdminServiceFactory = ({
return organizations;
};
const createOrganization = async (
{ name, inviteAdminEmails: emails }: TCreateOrganizationDTO,
actor: OrgServiceActor
) => {
const appCfg = getConfig();
const inviteAdminEmails = [...new Set(emails)];
if (!appCfg.isDevelopmentMode && appCfg.isCloud)
throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" });
const serverAdmin = await userDAL.findById(actor.id);
const plan = licenseService.onPremFeatures;
const isEmailInvalid = await isDisposableEmail(inviteAdminEmails);
if (isEmailInvalid) {
throw new BadRequestError({
message: "Disposable emails are not allowed",
name: "InviteUser"
});
}
const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => {
const org = await orgService.createOrganization(
{
orgName: name,
userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this
},
tx
);
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username" | "isAccepted">[] = [];
for await (const inviteeEmail of inviteAdminEmails) {
const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
let inviteeUser =
usersByUsername?.length > 1
? usersByUsername.find((el) => el.username === inviteeEmail)
: usersByUsername?.[0];
// if the user doesn't exist we create the user with the email
if (!inviteeUser) {
// TODO(carlos): will be removed once the function receives usernames instead of emails
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
if (usersByEmail?.length === 1) {
[inviteeUser] = usersByEmail;
} else {
inviteeUser = await userDAL.create(
{
isAccepted: false,
email: inviteeEmail,
username: inviteeEmail,
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
}
}
const inviteeUserId = inviteeUser?.id;
const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx);
// when user is missing the encrytion keys
// this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp
// So what we do is we generate a random secure password and then encrypt it with a random pub-private key
// Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password
if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) {
await userDAL.createUserEncryption(
{
userId: inviteeUserId,
encryptionVersion: 2
},
tx
);
}
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
name: "InviteUser",
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
await orgDAL.createMembership(
{
userId: inviteeUser.id,
inviteEmail: inviteeEmail,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
);
users.push(inviteeUser);
}
return { organization: org, users };
});
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
await Promise.allSettled(
usersToEmail.map(async (user) => {
if (!user.email) return;
if (user.isAccepted) {
return smtpService.sendMail({
template: SmtpTemplates.OrgAssignment,
subjectLine: "You've been added to an Infisical organization",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}`
}
});
}
// new user, send regular invite
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: user.id,
orgId: organization.id
});
return smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
})
);
return organization;
};
const deleteOrganization = async (organizationId: string) => {
const organization = await orgDAL.deleteById(organizationId);
return organization;
@@ -763,6 +934,86 @@ export const superAdminServiceFactory = ({
return organizationMembership;
};
const joinOrganization = async (orgId: string, actor: OrgServiceActor) => {
const serverAdmin = await userDAL.findById(actor.id);
if (!serverAdmin) {
throw new NotFoundError({ message: "Could not find server admin user" });
}
const org = await orgDAL.findById(orgId);
if (!org) {
throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` });
}
const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId });
if (existingOrgMembership) {
throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` });
}
const orgMembership = await orgDAL.createMembership({
userId: serverAdmin.id,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
});
return orgMembership;
};
const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => {
const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId });
if (!orgMembership) {
throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" });
}
if (orgMembership.status === OrgMembershipStatus.Accepted) {
throw new BadRequestError({
message: "This user has already accepted their invitation."
});
}
if (!orgMembership.userId) {
throw new NotFoundError({ message: "Cannot find user associated with Org Membership." });
}
if (!orgMembership.inviteEmail) {
throw new BadRequestError({ message: "No invite email associated with user." });
}
const org = await orgDAL.findOrgById(orgMembership.orgId);
const appCfg = getConfig();
const serverAdmin = await userDAL.findById(actor.id);
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: orgMembership.userId,
orgId: orgMembership.orgId
});
await smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [orgMembership.inviteEmail],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: org?.name,
email: orgMembership.inviteEmail,
organizationId: orgMembership.orgId,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
return orgMembership;
};
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
const identities = await identityDAL.getIdentitiesByFilter({
limit,
@@ -901,6 +1152,9 @@ export const superAdminServiceFactory = ({
initializeEnvConfigSync,
getEnvOverrides,
getEnvOverridesOrganized,
deleteUsers
deleteUsers,
createOrganization,
joinOrganization,
resendOrgInvite
};
};
@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
searchTerm: string;
};
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
export enum LoginMethod {
EMAIL = "email",
GOOGLE = "google",