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

This commit is contained in:
Carlos Monastyrski
2025-09-19 20:50:51 -03:00
parent 1c5dd0c35f
commit b2b33b4f8b
806 changed files with 20335 additions and 7209 deletions
@@ -1,11 +1,115 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
import { TableName, TAppConnections } from "@app/db/schemas";
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
import { transformUsageToProjects } from "@app/services/app-connection/app-connection-fns";
export type TAppConnectionDALFactory = ReturnType<typeof appConnectionDALFactory>;
type AppConnectionFindFilter = Parameters<typeof buildFindFilter<TAppConnections>>[0];
export const appConnectionDALFactory = (db: TDbClient) => {
const appConnectionOrm = ormify(db, TableName.AppConnection);
return { ...appConnectionOrm };
const findWithProjectDetails = async (filter: AppConnectionFindFilter, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.AppConnection)
.leftJoin(TableName.Project, `${TableName.AppConnection}.projectId`, `${TableName.Project}.id`)
.select(selectAllTableCols(TableName.AppConnection))
.select(
// project
db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("type").withSchema(TableName.Project).as("projectType"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug")
);
if (filter) {
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.AppConnection, filter)));
}
const connections = await query;
return connections.map(({ projectName, projectSlug, projectType, projectId, ...connection }) => ({
...connection,
projectId,
project: projectId
? {
name: projectName,
type: projectType,
slug: projectSlug,
id: projectId
}
: null
}));
};
const findAppConnectionUsageById = async (connectionId: string, tx?: Knex) => {
const secretSyncs = await (tx || db.replicaNode())(TableName.SecretSync)
.where(`${TableName.SecretSync}.connectionId`, connectionId)
.join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`)
.select(
db.ref("name").withSchema(TableName.SecretSync),
db.ref("id").withSchema(TableName.SecretSync),
db.ref("projectId").withSchema(TableName.SecretSync),
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("slug").as("projectSlug").withSchema(TableName.Project),
db.ref("type").as("projectType").withSchema(TableName.Project)
);
const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2)
.where(`${TableName.SecretRotationV2}.connectionId`, connectionId)
.join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`)
.select(
db.ref("name").withSchema(TableName.SecretRotationV2),
db.ref("id").withSchema(TableName.SecretRotationV2),
db.ref("id").as("projectId").withSchema(TableName.Project),
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("slug").as("projectSlug").withSchema(TableName.Project),
db.ref("type").as("projectType").withSchema(TableName.Project)
);
const externalCas = await (tx || db.replicaNode())(TableName.ExternalCertificateAuthority)
.where(`${TableName.ExternalCertificateAuthority}.appConnectionId`, connectionId)
.orWhere(`${TableName.ExternalCertificateAuthority}.dnsAppConnectionId`, connectionId)
.join(
TableName.CertificateAuthority,
`${TableName.ExternalCertificateAuthority}.caId`,
`${TableName.CertificateAuthority}.id`
)
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
.select(
db.ref("name").withSchema(TableName.CertificateAuthority),
db.ref("id").withSchema(TableName.ExternalCertificateAuthority),
db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority),
db.ref("dnsAppConnectionId").withSchema(TableName.ExternalCertificateAuthority),
db.ref("id").as("projectId").withSchema(TableName.Project),
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("slug").as("projectSlug").withSchema(TableName.Project),
db.ref("type").as("projectType").withSchema(TableName.Project)
);
const dataSources = await (tx || db.replicaNode())(TableName.SecretScanningDataSource)
.where(`${TableName.SecretScanningDataSource}.connectionId`, connectionId)
.join(TableName.Project, `${TableName.SecretScanningDataSource}.projectId`, `${TableName.Project}.id`)
.select(
db.ref("name").withSchema(TableName.SecretScanningDataSource),
db.ref("id").withSchema(TableName.SecretScanningDataSource),
db.ref("id").as("projectId").withSchema(TableName.Project),
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("slug").as("projectSlug").withSchema(TableName.Project),
db.ref("type").as("projectType").withSchema(TableName.Project)
);
return transformUsageToProjects({
secretSyncs,
secretRotations,
dataSources,
externalCas
});
};
return { ...appConnectionOrm, findAppConnectionUsageById, findWithProjectDetails };
};
@@ -1,3 +1,4 @@
import { ProjectType } from "@app/db/schemas";
import { TAppConnections } from "@app/db/schemas/app-connections";
import {
getOCIConnectionListItem,
@@ -8,6 +9,8 @@ import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors";
import { APP_CONNECTION_NAME_MAP, APP_CONNECTION_PLAN_MAP } from "@app/services/app-connection/app-connection-maps";
@@ -16,6 +19,7 @@ import {
validateSqlConnectionCredentials
} from "@app/services/app-connection/shared/sql";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
import {
getOnePassConnectionListItem,
@@ -133,7 +137,22 @@ import {
} from "./windmill";
import { getZabbixConnectionListItem, validateZabbixConnectionCredentials, ZabbixConnectionMethod } from "./zabbix";
export const listAppConnectionOptions = () => {
const SECRET_SYNC_APP_CONNECTION_MAP = Object.fromEntries(
Object.entries(SECRET_SYNC_CONNECTION_MAP).map(([key, value]) => [value, key])
);
const SECRET_ROTATION_APP_CONNECTION_MAP = Object.fromEntries(
Object.entries(SECRET_ROTATION_CONNECTION_MAP).map(([key, value]) => [value, key])
);
const SECRET_SCANNING_APP_CONNECTION_MAP = Object.fromEntries(
Object.entries(SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP).map(([key, value]) => [value, key])
);
// scott: ideally this would be derived from a utilized map like the above
const PKI_APP_CONNECTIONS = [AppConnection.AWS, AppConnection.Cloudflare, AppConnection.AzureADCS];
export const listAppConnectionOptions = (projectType?: ProjectType) => {
return [
getAwsConnectionListItem(),
getGitHubConnectionListItem(),
@@ -173,22 +192,51 @@ export const listAppConnectionOptions = () => {
getDigitalOceanConnectionListItem(),
getNetlifyConnectionListItem(),
getOktaConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
]
.filter((option) => {
switch (projectType) {
case ProjectType.SecretManager:
return (
Boolean(SECRET_SYNC_APP_CONNECTION_MAP[option.app]) ||
Boolean(SECRET_ROTATION_APP_CONNECTION_MAP[option.app])
);
case ProjectType.SecretScanning:
return Boolean(SECRET_SCANNING_APP_CONNECTION_MAP[option.app]);
case ProjectType.CertificateManager:
return PKI_APP_CONNECTIONS.includes(option.app);
case ProjectType.KMS:
return false;
case ProjectType.SSH:
return false;
default:
return true;
}
})
.sort((a, b) => a.name.localeCompare(b.name));
};
export const encryptAppConnectionCredentials = async ({
orgId,
credentials,
kmsService
kmsService,
projectId
}: {
orgId: string;
credentials: TAppConnection["credentials"];
kmsService: TAppConnectionServiceFactoryDep["kmsService"];
projectId: string | null | undefined;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const { encryptor } = await kmsService.createCipherPairWithDataKey(
projectId
? {
type: KmsDataKey.SecretManager,
projectId
}
: {
type: KmsDataKey.Organization,
orgId
}
);
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(credentials))
@@ -200,16 +248,22 @@ export const encryptAppConnectionCredentials = async ({
export const decryptAppConnectionCredentials = async ({
orgId,
encryptedCredentials,
kmsService
kmsService,
projectId
}: {
orgId: string;
encryptedCredentials: Buffer;
kmsService: TAppConnectionServiceFactoryDep["kmsService"];
projectId: string | null | undefined;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const { decryptor } = await kmsService.createCipherPairWithDataKey(
projectId
? { type: KmsDataKey.SecretManager, projectId }
: {
type: KmsDataKey.Organization,
orgId
}
);
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedCredentials
@@ -343,6 +397,7 @@ export const decryptAppConnection = async (
credentials: await decryptAppConnectionCredentials({
encryptedCredentials: appConnection.encryptedCredentials,
orgId: appConnection.orgId,
projectId: appConnection.projectId,
kmsService
}),
credentialsHash: crypto.nativeCrypto.createHash("sha256").update(appConnection.encryptedCredentials).digest("hex")
@@ -413,3 +468,73 @@ export const enterpriseAppCheck = async (
});
}
};
type Resource = {
name: string;
id: string;
projectId: string;
projectName: string;
projectSlug: string;
projectType: string;
};
type UsageData = {
secretSyncs: Resource[];
secretRotations: Resource[];
dataSources: Resource[];
externalCas: Resource[];
};
type ResourceSummary = {
name: string;
id: string;
};
type ProjectWithResources = {
id: string;
name: string;
slug: string;
type: ProjectType;
resources: {
secretSyncs: ResourceSummary[];
secretRotations: ResourceSummary[];
dataSources: ResourceSummary[];
externalCas: (ResourceSummary & { appConnectionId?: string; dnsAppConnectionId?: string })[];
};
};
export const transformUsageToProjects = (data: UsageData): ProjectWithResources[] => {
const projectMap = new Map<string, ProjectWithResources>();
Object.entries(data).forEach(([resourceType, resources]) => {
resources.forEach((resource) => {
const { projectId, projectName, projectSlug, projectType, name, id, ...rest } = resource;
const projectKey = projectId;
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
id: projectId,
name: projectName,
slug: projectSlug,
type: projectType as ProjectType,
resources: {
secretSyncs: [],
secretRotations: [],
dataSources: [],
externalCas: []
}
});
}
const project = projectMap.get(projectKey)!;
project.resources[resourceType as keyof ProjectWithResources["resources"]].push({
name,
id,
...rest
});
});
});
return Array.from(projectMap.values());
};
@@ -13,7 +13,15 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({
app: true,
method: true
}).extend({
credentialsHash: z.string().optional()
credentialsHash: z.string().optional(),
project: z
.object({
name: z.string(),
id: z.string(),
type: z.string(),
slug: z.string()
})
.nullish()
});
export const GenericCreateAppConnectionFieldsSchema = (
@@ -28,6 +36,7 @@ export const GenericCreateAppConnectionFieldsSchema = (
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(AppConnections.CREATE(app).description),
projectId: z.string().optional().describe(AppConnections.CREATE(app).projectId),
isPlatformManagedCredentials: supportsPlatformManagedCredentials
? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials)
: z
@@ -1,5 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, TAppConnections } from "@app/db/schemas";
import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci";
import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service";
import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb";
@@ -14,6 +15,10 @@ import {
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionAppConnectionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { crypto } from "@app/lib/crypto/cryptography";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
@@ -27,9 +32,8 @@ import {
TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM,
validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns";
import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service";
import { githubRadarConnectionService } from "@app/services/app-connection/github-radar/github-radar-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { ValidateOnePassConnectionCredentialsSchema } from "./1password";
import { onePassConnectionService } from "./1password/1password-connection-service";
@@ -41,10 +45,13 @@ import {
TAppConnectionConfig,
TAppConnectionRaw,
TCreateAppConnectionDTO,
TGetAppConnectionByNameDTO,
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentialsSchema
TValidateAppConnectionCredentialsSchema,
TValidateAppConnectionUsageByIdDTO
} from "./app-connection-types";
import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0";
import { auth0ConnectionService } from "./auth0/auth0-connection-service";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureADCSConnectionCredentialsSchema } from "./azure-adcs/azure-adcs-connection-schemas";
@@ -73,6 +80,7 @@ import { gcpConnectionService } from "./gcp/gcp-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar";
import { githubRadarConnectionService } from "./github-radar/github-radar-connection-service";
import { ValidateGitLabConnectionCredentialsSchema } from "./gitlab";
import { gitlabConnectionService } from "./gitlab/gitlab-connection-service";
import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault";
@@ -108,13 +116,14 @@ import { zabbixConnectionService } from "./zabbix/zabbix-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
gatewayDAL: Pick<TGatewayDALFactory, "find">;
gatewayV2DAL: Pick<TGatewayV2DALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findProjectById">;
};
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
@@ -168,29 +177,64 @@ export const appConnectionServiceFactory = ({
gatewayService,
gatewayV2Service,
gatewayDAL,
gatewayV2DAL
gatewayV2DAL,
projectDAL
}: TAppConnectionServiceFactoryDep) => {
const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
const listAppConnections = async (actor: OrgServiceActor, app?: AppConnection, projectId?: string) => {
let appConnections: TAppConnections[];
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
if (projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
const appConnections = await appConnectionDAL.find(
app
? { orgId: actor.orgId, app }
: {
orgId: actor.orgId
}
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Read,
ProjectPermissionSub.AppConnections
);
appConnections = (
await appConnectionDAL.findWithProjectDetails({
projectId,
...(app ? { app } : {})
})
).filter((appConnection) =>
permission.can(
ProjectPermissionAppConnectionActions.Read,
subject(ProjectPermissionSub.AppConnections, { connectionId: appConnection.id })
)
);
} else {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
appConnections = (
await appConnectionDAL.findWithProjectDetails({
orgId: actor.orgId,
...(app ? { app } : {})
})
).filter((appConnection) =>
permission.can(
OrgPermissionAppConnectionActions.Read,
subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id })
)
);
}
return Promise.all(
appConnections
@@ -204,18 +248,34 @@ export const appConnectionServiceFactory = ({
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
if (appConnection.projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId: appConnection.projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Read,
subject(ProjectPermissionSub.AppConnections, { connectionId })
);
} else {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
subject(OrgPermissionSubjects.AppConnections, { connectionId })
);
}
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
@@ -223,24 +283,49 @@ export const appConnectionServiceFactory = ({
return decryptAppConnection(appConnection, kmsService);
};
const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => {
const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId });
const findAppConnectionByName = async (
app: AppConnection,
{ connectionName, projectId }: TGetAppConnectionByNameDTO,
actor: OrgServiceActor
) => {
const appConnection = await appConnectionDAL.findOne({
name: connectionName,
...(projectId ? { projectId } : { orgId: actor.orgId, projectId: null })
});
if (!appConnection)
throw new NotFoundError({ message: `Could not find App Connection with name ${connectionName}` });
throw new NotFoundError({
message: `Could not find App Connection with name ${connectionName} in ${projectId ? "project" : "organization"} scope.`
});
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
if (appConnection.projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId: appConnection.projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Read,
subject(ProjectPermissionSub.AppConnections, { connectionId: appConnection.id })
);
} else {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id })
);
}
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` });
@@ -249,10 +334,10 @@ export const appConnectionServiceFactory = ({
};
const createAppConnection = async (
{ method, app, credentials, gatewayId, ...params }: TCreateAppConnectionDTO,
{ method, app, credentials, gatewayId, projectId, ...params }: TCreateAppConnectionDTO,
actor: OrgServiceActor
) => {
const { permission } = await permissionService.getOrgPermission(
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
@@ -260,13 +345,33 @@ export const appConnectionServiceFactory = ({
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Create,
OrgPermissionSubjects.AppConnections
);
if (projectId) {
const project = await projectDAL.findProjectById(projectId);
if (!project) throw new BadRequestError({ message: `Could not find project with ID ${projectId}` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Create,
ProjectPermissionSub.AppConnections
);
} else {
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionAppConnectionActions.Create,
OrgPermissionSubjects.AppConnections
);
}
if (gatewayId) {
ForbiddenError.from(permission).throwUnlessCan(
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
@@ -304,7 +409,8 @@ export const appConnectionServiceFactory = ({
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: connectionCredentials,
orgId: actor.orgId,
kmsService
kmsService,
projectId
});
return appConnectionDAL.create({
@@ -313,6 +419,7 @@ export const appConnectionServiceFactory = ({
method,
app,
gatewayId,
projectId,
...params
});
};
@@ -365,7 +472,7 @@ export const appConnectionServiceFactory = ({
"Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections."
);
const { permission } = await permissionService.getOrgPermission(
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
@@ -373,13 +480,29 @@ export const appConnectionServiceFactory = ({
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Edit,
OrgPermissionSubjects.AppConnections
);
if (appConnection.projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId: appConnection.projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
if (gatewayId !== appConnection.gatewayId) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Edit,
subject(ProjectPermissionSub.AppConnections, { connectionId })
);
} else {
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionAppConnectionActions.Edit,
subject(OrgPermissionSubjects.AppConnections, { connectionId })
);
}
if (gatewayId !== undefined && gatewayId !== appConnection.gatewayId) {
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
@@ -441,7 +564,8 @@ export const appConnectionServiceFactory = ({
? await encryptAppConnectionCredentials({
credentials: connectionCredentials,
orgId: actor.orgId,
kmsService
kmsService,
projectId: appConnection.projectId
})
: undefined;
@@ -491,18 +615,34 @@ export const appConnectionServiceFactory = ({
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
if (appConnection.projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId: appConnection.projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Delete,
OrgPermissionSubjects.AppConnections
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Delete,
subject(ProjectPermissionSub.AppConnections, { connectionId })
);
} else {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Delete,
subject(OrgPermissionSubjects.AppConnections, { connectionId })
);
}
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
@@ -544,18 +684,34 @@ export const appConnectionServiceFactory = ({
"Failed to connect app due to plan restriction. Upgrade plan to access enterprise app connections."
);
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
appConnection.orgId,
actor.authMethod,
actor.orgId
);
if (appConnection.projectId) {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId: appConnection.projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionAppConnectionActions.Connect,
subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id })
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Connect,
subject(ProjectPermissionSub.AppConnections, { connectionId })
);
} else {
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
appConnection.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionAppConnectionActions.Connect,
subject(OrgPermissionSubjects.AppConnections, { connectionId })
);
}
if (appConnection.app !== app)
throw new BadRequestError({
@@ -569,7 +725,23 @@ export const appConnectionServiceFactory = ({
return connection as T;
};
const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => {
const validateAppConnectionUsageById = async (
app: AppConnection,
{ connectionId, projectId }: TValidateAppConnectionUsageByIdDTO,
actor: OrgServiceActor
) => {
const appConnection = await connectAppConnectionById(app, connectionId, actor);
if (appConnection.projectId && appConnection.projectId !== projectId) {
throw new BadRequestError({
message: `You cannot connect project App Connection with ID "${appConnection.id}" from project with ID "${appConnection.projectId}" to project with ID "${projectId}"`
});
}
return appConnection;
};
const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor, projectId?: string) => {
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@@ -578,28 +750,89 @@ export const appConnectionServiceFactory = ({
actor.orgId
);
const appConnections = await appConnectionDAL.find({ app, orgId: actor.orgId });
let availableProjectConnections: TAppConnections[] = [];
const availableConnections = appConnections.filter((connection) =>
if (projectId) {
const project = await projectDAL.findProjectById(projectId);
if (!project) throw new BadRequestError({ message: `Could not find project with ID ${projectId}` });
const { permission: projectPermission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(projectPermission).throwUnlessCan(
ProjectPermissionAppConnectionActions.Connect,
ProjectPermissionSub.AppConnections
);
const projectAppConnections = await appConnectionDAL.find({ app, projectId });
availableProjectConnections = projectAppConnections.filter((connection) =>
projectPermission.can(
ProjectPermissionAppConnectionActions.Connect,
subject(ProjectPermissionSub.AppConnections, { connectionId: connection.id })
)
);
}
const orgAppConnections = await appConnectionDAL.find({ app, orgId: actor.orgId, projectId: null });
const availableOrgConnections = orgAppConnections.filter((connection) =>
orgPermission.can(
OrgPermissionAppConnectionActions.Connect,
subject(OrgPermissionSubjects.AppConnections, { connectionId: connection.id })
)
);
return availableConnections as Omit<TAppConnection, "credentials">[];
return [...availableOrgConnections, ...availableProjectConnections].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
) as Omit<TAppConnection, "credentials">[];
};
const findAppConnectionUsageById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
const projectUsage = await appConnectionDAL.findAppConnectionUsageById(connectionId);
return projectUsage;
};
return {
listAppConnectionOptions,
listAppConnectionsByOrg,
listAppConnections,
findAppConnectionById,
findAppConnectionByName,
createAppConnection,
updateAppConnection,
deleteAppConnection,
connectAppConnectionById,
validateAppConnectionUsageById,
listAvailableAppConnectionsForUser,
findAppConnectionUsageById,
github: githubConnectionService(connectAppConnectionById, gatewayService, gatewayV2Service),
githubRadar: githubRadarConnectionService(connectAppConnectionById),
gcp: gcpConnectionService(connectAppConnectionById),
@@ -316,13 +316,23 @@ export type TSqlConnectionInput =
export type TCreateAppConnectionDTO = Pick<
TAppConnectionInput,
"credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId"
"credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId" | "projectId"
>;
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app" | "projectId">> & {
connectionId: string;
};
export type TGetAppConnectionByNameDTO = {
connectionName: string;
projectId?: string;
};
export type TValidateAppConnectionUsageByIdDTO = {
connectionId: string;
projectId: string;
};
export type TAppConnectionConfig =
| TAwsConnectionConfig
| TGitHubConnectionConfig
@@ -51,7 +51,7 @@ const authorizeAuth0Connection = async ({
};
export const getAuth0ConnectionAccessToken = async (
{ id, orgId, credentials }: TAuth0Connection,
{ id, orgId, credentials, projectId }: TAuth0Connection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
@@ -72,7 +72,8 @@ export const getAuth0ConnectionAccessToken = async (
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
kmsService,
projectId
});
await appConnectionDAL.updateById(id, { encryptedCredentials });
@@ -352,7 +352,8 @@ export const getAzureADCSConnectionCredentials = async (
const credentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as {
username: string;
password: string;
@@ -57,6 +57,7 @@ export const getAzureConnectionAccessToken = async (
const credentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
projectId: appConnection.projectId,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureClientSecretsConnectionCredentials;
@@ -93,6 +94,7 @@ export const getAzureConnectionAccessToken = async (
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId: appConnection.orgId,
projectId: appConnection.projectId,
kmsService
});
@@ -102,6 +104,7 @@ export const getAzureConnectionAccessToken = async (
case AzureClientSecretsConnectionMethod.ClientSecret:
const accessTokenCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
projectId: appConnection.projectId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureClientSecretsConnectionClientSecretCredentials;
@@ -129,6 +132,7 @@ export const getAzureConnectionAccessToken = async (
const encryptedClientCredentials = await encryptAppConnectionCredentials({
credentials: updatedClientCredentials,
orgId: appConnection.orgId,
projectId: appConnection.projectId,
kmsService
});
@@ -70,7 +70,8 @@ export const getAzureDevopsConnection = async (
const oauthCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as TAzureDevOpsConnectionCredentials;
if (!("refreshToken" in oauthCredentials)) {
@@ -100,7 +101,8 @@ export const getAzureDevopsConnection = async (
const encryptedOAuthCredentials = await encryptAppConnectionCredentials({
credentials: updatedOAuthCredentials,
orgId: appConnection.orgId,
kmsService
kmsService,
projectId: appConnection.projectId
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials });
@@ -111,7 +113,8 @@ export const getAzureDevopsConnection = async (
const accessTokenCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as { accessToken: string };
if (!("accessToken" in accessTokenCredentials)) {
@@ -124,7 +127,8 @@ export const getAzureDevopsConnection = async (
const clientSecretCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as TAzureDevOpsConnectionClientSecretCredentials;
const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials;
@@ -153,7 +157,8 @@ export const getAzureDevopsConnection = async (
const encryptedClientCredentials = await encryptAppConnectionCredentials({
credentials: updatedClientCredentials,
orgId: appConnection.orgId,
kmsService
kmsService,
projectId: appConnection.projectId
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
@@ -58,7 +58,8 @@ export const getAzureConnectionAccessToken = async (
const oauthCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as TAzureKeyVaultConnectionCredentials;
const { data } = await request.post<ExchangeCodeAzureResponse>(
@@ -82,7 +83,8 @@ export const getAzureConnectionAccessToken = async (
const encryptedOAuthCredentials = await encryptAppConnectionCredentials({
credentials: updatedOAuthCredentials,
orgId: appConnection.orgId,
kmsService
kmsService,
projectId: appConnection.projectId
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials });
@@ -95,7 +97,8 @@ export const getAzureConnectionAccessToken = async (
const clientSecretCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
encryptedCredentials: appConnection.encryptedCredentials,
projectId: appConnection.projectId
})) as TAzureKeyVaultConnectionClientSecretCredentials;
const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials;
@@ -124,7 +127,8 @@ export const getAzureConnectionAccessToken = async (
const encryptedClientCredentials = await encryptAppConnectionCredentials({
credentials: updatedClientCredentials,
orgId: appConnection.orgId,
kmsService
kmsService,
projectId: appConnection.projectId
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
@@ -40,7 +40,7 @@ const authorizeCamundaConnection = async ({
};
export const getCamundaConnectionAccessToken = async (
{ id, orgId, credentials }: TCamundaConnection,
{ id, orgId, credentials, projectId }: TCamundaConnection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
@@ -61,7 +61,8 @@ export const getCamundaConnectionAccessToken = async (
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
kmsService,
projectId
});
await appConnectionDAL.updateById(id, { encryptedCredentials });
@@ -47,7 +47,7 @@ const authorizeDatabricksConnection = async ({
};
export const getDatabricksConnectionAccessToken = async (
{ id, orgId, credentials }: TDatabricksConnection,
{ id, orgId, credentials, projectId }: TDatabricksConnection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
@@ -68,7 +68,8 @@ export const getDatabricksConnectionAccessToken = async (
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
kmsService,
projectId
});
await appConnectionDAL.updateById(id, { encryptedCredentials });
@@ -64,6 +64,7 @@ export const refreshGitLabToken = async (
refreshToken: string,
appId: string,
orgId: string,
projectId: string | undefined | null,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">,
instanceUrl?: string
@@ -105,7 +106,8 @@ export const refreshGitLabToken = async (
expiresAt
},
orgId,
kmsService
kmsService,
projectId
});
await appConnectionDAL.updateById(appId, { encryptedCredentials });
@@ -238,6 +240,7 @@ export const getGitLabConnectionClient = async (
appConnection.credentials.refreshToken,
appConnection.id,
appConnection.orgId,
appConnection.projectId,
appConnectionDAL,
kmsService,
appConnection.credentials.instanceUrl
@@ -273,6 +276,7 @@ export const listGitLabProjects = async ({
appConnection.credentials.refreshToken,
appConnection.id,
appConnection.orgId,
appConnection.projectId,
appConnectionDAL,
kmsService,
appConnection.credentials.instanceUrl
@@ -341,6 +345,7 @@ export const listGitLabGroups = async ({
appConnection.credentials.refreshToken,
appConnection.id,
appConnection.orgId,
appConnection.projectId,
appConnectionDAL,
kmsService,
appConnection.credentials.instanceUrl
@@ -36,6 +36,7 @@ export const refreshHerokuToken = async (
refreshToken: string,
appId: string,
orgId: string,
projectId: string | null | undefined,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<string> => {
@@ -64,7 +65,8 @@ export const refreshHerokuToken = async (
expiresAt: new Date(Date.now() + data.expires_in * 1000 - 60000)
},
orgId,
kmsService
kmsService,
projectId
});
await appConnectionDAL.updateById(appId, { encryptedCredentials });
@@ -186,6 +188,7 @@ export const listHerokuApps = async ({
appConnection.credentials.refreshToken,
appConnection.id,
appConnection.orgId,
appConnection.projectId,
appConnectionDAL,
kmsService
);
@@ -14,6 +14,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { getDefaultOrgMembershipRole } from "../org/org-role-fns";
import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal";
@@ -47,6 +49,7 @@ type TAuthLoginServiceFactoryDep = {
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
orgMembershipDAL: TOrgMembershipDALFactory;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
@@ -57,7 +60,8 @@ export const authLoginServiceFactory = ({
orgDAL,
orgMembershipDAL,
totpService,
auditLogService
auditLogService,
notificationService
}: TAuthLoginServiceFactoryDep) => {
/*
* Private
@@ -71,6 +75,16 @@ export const authLoginServiceFactory = ({
if (!isDeviceSeen) {
const newDeviceList = devices.concat([{ ip, userAgent }]);
await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx);
await notificationService.createUserNotifications([
{
userId: user.id,
type: NotificationType.LOGIN_FROM_NEW_DEVICE,
title: "Login From New Device",
body: `A new device with IP **${ip}** and User Agent **${userAgent}** has logged into your account.`
}
]);
if (user.email) {
await smtpService.sendMail({
template: SmtpTemplates.NewDeviceJoin,
@@ -563,6 +577,18 @@ export const authLoginServiceFactory = ({
.filter(Boolean) as string[];
if (adminEmails.length > 0) {
await notificationService.createUserNotifications(
orgAdmins
.filter((admin) => admin.user.id !== user.id)
.map((admin) => ({
userId: admin.user.id,
orgId: organizationId,
type: NotificationType.ADMIN_SSO_BYPASS,
title: "Security Alert: Admin SSO Bypass",
body: `The org admin **${user.email}** has bypassed enforced SSO login.`
}))
);
await smtpService.sendMail({
recipients: adminEmails,
subjectLine: "Security Alert: Admin SSO Bypass",
@@ -42,10 +42,10 @@ import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/
type TAcmeCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa"
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
@@ -152,7 +152,11 @@ export const AcmeCertificateAuthorityFns = ({
}
// validates permission to connect
await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor);
await appConnectionService.validateAppConnectionUsageById(
appConnection.app as AppConnection,
{ connectionId: dnsAppConnectionId, projectId },
actor
);
const caEntity = await certificateAuthorityDAL.transaction(async (tx) => {
try {
@@ -242,10 +246,16 @@ export const AcmeCertificateAuthorityFns = ({
});
}
const ca = await certificateAuthorityDAL.findById(id);
if (!ca) {
throw new NotFoundError({ message: `Could not find Certificate Authority with ID "${id}"` });
}
// validates permission to connect
await appConnectionService.connectAppConnectionById(
await appConnectionService.validateAppConnectionUsageById(
appConnection.app as AppConnection,
dnsAppConnectionId,
{ connectionId: dnsAppConnectionId, projectId: ca.projectId },
actor
);
@@ -41,10 +41,10 @@ import {
type TAzureAdCsCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa"
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
@@ -621,9 +621,9 @@ export const AzureAdCsCertificateAuthorityFns = ({
});
}
await appConnectionService.connectAppConnectionById(
await appConnectionService.validateAppConnectionUsageById(
appConnection.app as AppConnection,
azureAdcsConnectionId,
{ connectionId: azureAdcsConnectionId, projectId },
actor
);
@@ -705,9 +705,15 @@ export const AzureAdCsCertificateAuthorityFns = ({
});
}
await appConnectionService.connectAppConnectionById(
const ca = await certificateAuthorityDAL.findById(id);
if (!ca) {
throw new NotFoundError({ message: `Could not find Certificate Authority with ID "${id}"` });
}
await appConnectionService.validateAppConnectionUsageById(
appConnection.app as AppConnection,
azureAdcsConnectionId,
{ connectionId: azureAdcsConnectionId, projectId: ca.projectId },
actor
);
@@ -35,7 +35,7 @@ import {
type TCertificateAuthorityQueueFactoryDep = {
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory;
@@ -43,7 +43,7 @@ import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-cert
type TCertificateAuthorityServiceFactoryDep = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
| "transaction"
@@ -55,7 +55,7 @@ export const importDataIntoInfisicalFn = async ({
actorId,
actorOrgId,
actorAuthMethod,
workspaceName: project.name,
projectName: project.name,
createDefaultEnvs: false,
tx
})
@@ -5,6 +5,8 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectServiceFactory } from "../project/project-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -42,6 +44,7 @@ export type TExternalMigrationQueueFactoryDep = {
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TExternalMigrationQueueFactory = ReturnType<typeof externalMigrationQueueFactory>;
@@ -62,9 +65,12 @@ export const externalMigrationQueueFactory = ({
folderDAL,
folderCommitService,
folderVersionDAL,
resourceMetadataDAL
resourceMetadataDAL,
notificationService
}: TExternalMigrationQueueFactoryDep) => {
const startImport = async (dto: {
orgId: string;
actorId: string;
actorEmail: string;
importType: ExternalPlatforms;
data: {
@@ -87,9 +93,19 @@ export const externalMigrationQueueFactory = ({
};
queueService.start(QueueName.ImportSecretsFromExternalSource, async (job) => {
const { data, actorEmail, importType } = job.data;
const { data, actorEmail, importType, actorId, orgId } = job.data;
try {
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_STARTED,
title: "Import Started",
body: `An import from **${importType}** to Infisical has been started.`
}
]);
await smtpService.sendMail({
recipients: [actorEmail],
subjectLine: "Infisical import started",
@@ -137,6 +153,16 @@ export const externalMigrationQueueFactory = ({
);
}
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_SUCCESSFUL,
title: "Import Successful",
body: `An import from **${importType}** to Infisical has successfully completed.`
}
]);
await smtpService.sendMail({
recipients: [actorEmail],
subjectLine: "Infisical import successful",
@@ -146,6 +172,17 @@ export const externalMigrationQueueFactory = ({
template: SmtpTemplates.ExternalImportSuccessful
});
} catch (err) {
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_FAILED,
title: "Import Failed",
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
body: `An import from **${importType}** to Infisical has failed: ${(err as any)?.message || "Unknown error"}.`
}
]);
await smtpService.sendMail({
recipients: [job.data.actorEmail],
subjectLine: "Infisical import failed",
@@ -73,6 +73,8 @@ export const externalMigrationServiceFactory = ({
const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson);
await externalMigrationQueue.startImport({
actorId: user.id,
orgId: actorOrgId,
actorEmail: user.email!,
importType: ExternalPlatforms.EnvKey,
data: {
@@ -131,6 +133,8 @@ export const externalMigrationServiceFactory = ({
const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson);
await externalMigrationQueue.startImport({
actorId: user.id,
orgId: actorOrgId,
actorEmail: user.email!,
importType: ExternalPlatforms.Vault,
data: {
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import { IdentityAuthMethod } from "@app/db/schemas";
import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template";
@@ -15,10 +16,18 @@ import {
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import {
BadRequestError,
NotFoundError,
PermissionBoundaryError,
RateLimitError,
UnauthorizedError
} from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -32,6 +41,8 @@ import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal";
import {
AllowedFieldsSchema,
TAttachLdapAuthDTO,
TCheckLdapAuthLockoutDTO,
TClearLdapAuthLockoutsDTO,
TGetLdapAuthDTO,
TLoginLdapAuthDTO,
TRevokeLdapAuthDTO,
@@ -50,10 +61,19 @@ type TIdentityLdapAuthServiceFactoryDep = {
kmsService: TKmsServiceFactory;
identityDAL: TIdentityDALFactory;
identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory;
keyStore: Pick<
TKeyStoreFactory,
"setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock"
>;
};
export type TIdentityLdapAuthServiceFactory = ReturnType<typeof identityLdapAuthServiceFactory>;
type LockoutObject = {
lockedOut: boolean;
failedAttempts: number;
};
export const identityLdapAuthServiceFactory = ({
identityAccessTokenDAL,
identityDAL,
@@ -62,7 +82,8 @@ export const identityLdapAuthServiceFactory = ({
licenseService,
permissionService,
kmsService,
identityAuthTemplateDAL
identityAuthTemplateDAL,
keyStore
}: TIdentityLdapAuthServiceFactoryDep) => {
const getLdapConfig = async (identityId: string) => {
const identity = await identityDAL.findOne({ id: identityId });
@@ -126,13 +147,17 @@ export const identityLdapAuthServiceFactory = ({
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) {
throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId });
if (!identityLdapAuth) {
throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` });
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
@@ -204,7 +229,11 @@ export const identityLdapAuthServiceFactory = ({
actor,
actorOrgId,
isActorSuperAdmin,
allowedFields
allowedFields,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TAttachLdapAuthDTO) => {
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
@@ -337,7 +366,11 @@ export const identityLdapAuthServiceFactory = ({
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined,
templateId
templateId,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
},
tx
);
@@ -363,7 +396,11 @@ export const identityLdapAuthServiceFactory = ({
actorId,
actorAuthMethod,
actor,
actorOrgId
actorOrgId,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TUpdateLdapAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
@@ -511,7 +548,11 @@ export const identityLdapAuthServiceFactory = ({
accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
: undefined,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId };
@@ -611,12 +652,123 @@ export const identityLdapAuthServiceFactory = ({
return revokedIdentityLdapAuth;
};
const withLdapLockout = async <T>(
{ identityId, username }: TCheckLdapAuthLockoutDTO,
authFn: () => Promise<T>
): Promise<T> => {
const usernameSlug = slugify(username.trim().toLowerCase());
const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${usernameSlug}`;
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try {
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 3000, {
retryCount: 3,
retryDelay: 1500,
retryJitter: 100
});
} catch (e) {
logger.info(
`identity login failed to acquire lock [identityId=${identityId}] [authMethod=${IdentityAuthMethod.LDAP_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
try {
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
if (lockoutRaw) {
const lockout = JSON.parse(lockoutRaw) as LockoutObject;
if (lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
}
const result = await authFn();
await keyStore.deleteItem(LOCKOUT_KEY);
return result;
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
if ((error as any).status === 401) {
const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId });
if (!identityLdapAuth) {
throw new UnauthorizedError({ message: "Invalid credentials" });
}
if (identityLdapAuth.lockoutEnabled) {
let lockout: LockoutObject = {
lockedOut: false,
failedAttempts: 0
};
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityLdapAuth.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityLdapAuth.lockoutDurationSeconds : identityLdapAuth.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
}
throw new UnauthorizedError({ message: "Invalid credentials" });
}
throw error;
} finally {
await lock.release();
}
};
const clearLdapAuthLockouts = async ({
identityId,
actorId,
actor,
actorOrgId,
actorAuthMethod
}: TClearLdapAuthLockoutsDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) {
throw new BadRequestError({
message: "The identity does not have ldap auth"
});
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
const deleted = await keyStore.deleteItems({
pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*`
});
return { deleted, identityId, orgId: identityMembershipOrg.orgId };
};
return {
attachLdapAuth,
getLdapConfig,
updateLdapAuth,
login,
revokeIdentityLdapAuth,
getLdapAuth
getLdapAuth,
withLdapLockout,
clearLdapAuthLockouts
};
};
@@ -27,6 +27,10 @@ export type TAttachLdapAuthDTO = {
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[];
isActorSuperAdmin?: boolean;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateLdapAuthDTO = {
@@ -43,6 +47,10 @@ export type TUpdateLdapAuthDTO = {
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
} & Omit<TProjectPermission, "projectId">;
export type TGetLdapAuthDTO = {
@@ -56,3 +64,12 @@ export type TLoginLdapAuthDTO = {
export type TRevokeLdapAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TClearLdapAuthLockoutsDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCheckLdapAuthLockoutDTO = {
identityId: string;
username: string;
};
@@ -84,69 +84,70 @@ export const identityUaServiceFactory = ({
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
if (identityUa.lockoutEnabled) {
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: "Failed to acquire lock: rate limit exceeded" });
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
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;
}
}
try {
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
if (!validClientSecretInfo) {
if (identityUa.lockoutEnabled) {
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
try {
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, {
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
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) {
// Re-fetch the latest lockout data while holding the lock
const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY);
if (lockoutRawNew) {
lockout = JSON.parse(lockoutRawNew) as LockoutObject;
} else {
lockout = {
lockedOut: false,
failedAttempts: 0
};
}
if (lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
@@ -157,110 +158,121 @@ export const identityUaServiceFactory = ({
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"
});
} catch (e) {
if (lock === undefined) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
throw e;
} finally {
if (lock) {
await lock.release();
}
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
// If credentials are valid, clear any existing lockout record
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 client secret usage limit reached"
message: "Access denied due to expired client secret"
});
}
}
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;
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 client secret usage limit reached"
});
}
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
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,
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)
}
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
);
return {
accessToken,
identityUa,
validClientSecretInfo,
identityAccessToken,
identityMembershipOrg,
...accessTokenTTLParams
};
} finally {
if (lock) await lock.release();
}
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
};
};
const attachUniversalAuth = async ({
@@ -33,7 +33,7 @@ type TIdentityServiceFactoryDep = {
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
keyStore: Pick<TKeyStoreFactory, "getKeysByPattern">;
keyStore: Pick<TKeyStoreFactory, "getKeysByPattern" | "getItem">;
};
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
@@ -261,12 +261,18 @@ export const identityServiceFactory = ({
const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`);
const activeLockoutAuthMethods = new Set<string>();
activeLockouts.forEach((key) => {
for await (const key of activeLockouts) {
const parts = key.split(":");
if (parts.length > 3) {
activeLockoutAuthMethods.add(parts[3]);
const lockoutRaw = await keyStore.getItem(key);
if (lockoutRaw) {
const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean };
if (lockout.lockedOut) {
activeLockoutAuthMethods.add(parts[3]);
}
}
}
});
}
return {
...identity,
@@ -1,6 +1,21 @@
export enum NotificationType {
ACCESS_APPROVAL_REQUEST = "access-approval-request",
ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated"
ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated",
ACCESS_POLICY_BYPASSED = "access-policy-bypassed",
SECRET_CHANGE_REQUEST = "secret-change-request",
SECRET_CHANGE_POLICY_BYPASSED = "secret-change-policy-bypassed",
SECRET_ROTATION_FAILED = "secret-rotation-failed",
SECRET_SCANNING_SECRETS_DETECTED = "secret-scanning-secrets-detected",
SECRET_SCANNING_SCAN_FAILED = "secret-scanning-scan-failed",
LOGIN_FROM_NEW_DEVICE = "login-from-new-device",
ADMIN_SSO_BYPASS = "admin-sso-bypass",
IMPORT_STARTED = "import-started",
IMPORT_SUCCESSFUL = "import-successful",
IMPORT_FAILED = "import-failed",
DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN = "direct-project-access-issued-to-admin",
PROJECT_ACCESS_REQUEST = "project-access-request",
PROJECT_INVITATION = "project-invitation",
SECRET_SYNC_FAILED = "secret-sync-failed"
}
export interface TCreateUserNotificationDTO {
@@ -5,6 +5,8 @@ import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
@@ -20,6 +22,7 @@ type TOrgAdminServiceFactoryDep = {
>;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create" | "delete">;
smtpService: Pick<TSmtpService, "sendMail">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TOrgAdminServiceFactory = ReturnType<typeof orgAdminServiceFactory>;
@@ -29,7 +32,8 @@ export const orgAdminServiceFactory = ({
projectDAL,
projectMembershipDAL,
projectUserMembershipRoleDAL,
smtpService
smtpService,
notificationService
}: TOrgAdminServiceFactoryDep) => {
const listOrgProjects = async ({
actor,
@@ -130,23 +134,34 @@ export const orgAdminServiceFactory = ({
});
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
const filteredProjectMembers = projectMembers
.filter(
(member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId
)
.map((el) => el.user.email!)
.filter(Boolean);
const projectAdmins = projectMembers.filter(
(member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId
);
const mappedProjectAdmins = projectAdmins.map((el) => el.user.email!).filter(Boolean);
const actorEmail = projectMembers.find((el) => el.userId === actorId)?.user?.username;
if (filteredProjectMembers.length) {
await smtpService.sendMail({
template: SmtpTemplates.OrgAdminProjectDirectAccess,
recipients: filteredProjectMembers,
subjectLine: "Organization Admin Project Direct Access Issued",
substitutions: {
projectName: project.name,
email: projectMembers.find((el) => el.userId === actorId)?.user?.username
}
});
if (actorEmail) {
await notificationService.createUserNotifications(
projectAdmins.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN,
title: "Direct Project Access Issued",
body: `The organization admin **${actorEmail}** has self-issued direct access to the project **${project.name}**.`
}))
);
if (mappedProjectAdmins.length) {
await smtpService.sendMail({
template: SmtpTemplates.OrgAdminProjectDirectAccess,
recipients: mappedProjectAdmins,
subjectLine: "Organization Admin Project Direct Access Issued",
substitutions: {
projectName: project.name,
email: actorEmail
}
});
}
}
return { isExistingMember: false, membership: updatedMembership };
};
@@ -18,6 +18,8 @@ import { ms } from "@app/lib/ms";
import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal";
import { ActorType } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
@@ -56,6 +58,7 @@ type TProjectMembershipServiceFactoryDep = {
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
groupProjectDAL: TGroupProjectDALFactory;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TProjectMembershipServiceFactory = ReturnType<typeof projectMembershipServiceFactory>;
@@ -74,7 +77,8 @@ export const projectMembershipServiceFactory = ({
projectDAL,
projectKeyDAL,
secretReminderRecipientsDAL,
licenseService
licenseService,
notificationService
}: TProjectMembershipServiceFactoryDep) => {
const getProjectMemberships = async ({
actorId,
@@ -236,6 +240,16 @@ export const projectMembershipServiceFactory = ({
});
if (sendEmails) {
await notificationService.createUserNotifications(
orgMembers.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.PROJECT_INVITATION,
title: "Project Invitation",
body: `You've been invited to join the project **${project.name}**.`
}))
);
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
@@ -399,6 +399,7 @@ export const projectDALFactory = (db: TDbClient) => {
name?: string;
sortBy?: SearchProjectSortBy;
sortDir?: SortDirection;
projectIds?: string[];
}) => {
const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto;
@@ -454,6 +455,11 @@ export const projectDALFactory = (db: TDbClient) => {
if (dto.name) {
void query.whereILike(`${TableName.Project}.name`, `%${dto.name}%`);
}
if (dto.projectIds?.length) {
void query.whereIn(`${TableName.Project}.id`, dto.projectIds);
}
const docs = await query;
return { docs, totalCount: Number(docs?.[0]?.count ?? 0) };
+41 -11
View File
@@ -57,6 +57,8 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns";
import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal";
import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal";
import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal";
@@ -183,6 +185,7 @@ type TProjectServiceFactoryDep = {
>;
projectTemplateService: TProjectTemplateServiceFactory;
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
@@ -227,7 +230,8 @@ export const projectServiceFactory = ({
projectTemplateService,
groupProjectDAL,
smtpService,
reminderService
reminderService,
notificationService
}: TProjectServiceFactoryDep) => {
/*
* Create workspace. Make user the admin
@@ -237,8 +241,8 @@ export const projectServiceFactory = ({
actorId,
actorOrgId,
actorAuthMethod,
workspaceName,
workspaceDescription,
projectName: workspaceName,
projectDescription: workspaceDescription,
slug: projectSlug,
kmsKeyId,
tx: trx,
@@ -254,7 +258,13 @@ export const projectServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
if (
permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace) &&
permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Project)
) {
throw new ForbiddenRequestError({ message: "You don't have permission to create a project" });
}
const results = await (trx || projectDAL).transaction(async (tx) => {
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]);
@@ -591,7 +601,8 @@ export const projectServiceFactory = ({
secretSharing: update.secretSharing,
defaultProduct: update.defaultProduct,
showSnapshotsLegacy: update.showSnapshotsLegacy,
secretDetectionIgnoreValues: update.secretDetectionIgnoreValues
secretDetectionIgnoreValues: update.secretDetectionIgnoreValues,
pitVersionLimit: update.pitVersionLimit
});
return updatedProject;
@@ -684,19 +695,21 @@ export const projectServiceFactory = ({
actorOrgId,
actorAuthMethod,
auditLogsRetentionDays,
workspaceSlug
filter
}: TUpdateAuditLogsRetentionDTO) => {
const project = await projectDAL.findProjectBySlug(workspaceSlug, actorOrgId);
const project = await projectDAL.findProjectByFilter(filter);
const projectId = project.id;
if (!project) {
throw new NotFoundError({
message: `Project with slug '${workspaceSlug}' not found`
message: `Project not found`
});
}
const { hasRole } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: project.id,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.Any
@@ -1806,7 +1819,8 @@ export const projectServiceFactory = ({
limit,
type,
orderBy,
orderDirection
orderDirection,
projectIds
}: TSearchProjectsDTO) => {
// check user belong to org
await permissionService.getOrgPermission(
@@ -1822,6 +1836,7 @@ export const projectServiceFactory = ({
offset,
name,
type,
projectIds,
orgId: permission.orgId,
actor: permission.type,
actorId: permission.id,
@@ -1913,6 +1928,21 @@ export const projectServiceFactory = ({
projectTypeUrl = "cert-management";
}
const callbackPath = `/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`;
await notificationService.createUserNotifications(
projectMembers
.filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin))
.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.PROJECT_ACCESS_REQUEST,
title: "Project Access Request",
body: `**${userDetails.firstName} ${userDetails.lastName}** (${userDetails.email}) has requested access to the project **${project.name}**.`,
link: callbackPath
}))
);
await smtpService.sendMail({
template: SmtpTemplates.ProjectAccessRequest,
recipients: filteredProjectMembers,
@@ -1923,7 +1953,7 @@ export const projectServiceFactory = ({
projectName: project?.name,
orgName: org?.name,
note: comment,
callback_url: `${appCfg.SITE_URL}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`
callback_url: `${appCfg.SITE_URL}${callbackPath}`
}
});
};
@@ -42,12 +42,13 @@ export type TCreateProjectDTO = {
actorAuthMethod: ActorAuthMethod;
actorId: string;
actorOrgId?: string;
workspaceName: string;
workspaceDescription?: string;
projectName: string;
projectDescription?: string;
slug?: string;
kmsKeyId?: string;
createDefaultEnvs?: boolean;
template?: string;
pitVersionLimit?: number;
tx?: Knex;
type?: ProjectType;
};
@@ -78,7 +79,7 @@ export type TUpdateProjectVersionLimitDTO = {
export type TUpdateAuditLogsRetentionDTO = {
auditLogsRetentionDays: number;
workspaceSlug: string;
filter: Filter;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateProjectNameDTO = {
@@ -90,6 +91,7 @@ export type TUpdateProjectDTO = {
update: {
name?: string;
description?: string;
pitVersionLimit?: number;
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
defaultProduct?: ProjectType;
@@ -221,6 +223,7 @@ export type TSearchProjectsDTO = {
limit?: number;
offset?: number;
orderBy?: SearchProjectSortBy;
projectIds?: string[];
orderDirection?: SortDirection;
};
@@ -53,6 +53,7 @@ const getValidAccessToken = async (
connection.credentials.refreshToken,
connection.id,
connection.orgId,
connection.projectId,
appConnectionDAL,
kmsService,
connection.credentials.instanceUrl
@@ -32,6 +32,7 @@ const getValidAuthToken = async (
connection.credentials.refreshToken,
connection.id,
connection.orgId,
connection.projectId,
appConnectionDAL,
kmsService
);
@@ -31,6 +31,7 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"),
db.ref("projectId").withSchema(TableName.AppConnection).as("connectionProjectId"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
db
@@ -67,6 +68,7 @@ const expandSecretSync = (
connectionVersion,
connectionIsPlatformManagedCredentials,
connectionGatewayId,
connectionProjectId,
...el
} = secretSync;
@@ -86,7 +88,8 @@ const expandSecretSync = (
updatedAt: connectionUpdatedAt,
version: connectionVersion,
isPlatformManagedCredentials: connectionIsPlatformManagedCredentials,
gatewayId: connectionGatewayId
gatewayId: connectionGatewayId,
projectId: connectionProjectId
},
folder: folder
? {
@@ -61,6 +61,8 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
@@ -100,6 +102,7 @@ type TSecretSyncQueueFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
type SecretSyncActionJob = Job<
@@ -142,7 +145,8 @@ export const secretSyncQueueFactory = ({
folderCommitService,
licenseService,
gatewayService,
gatewayV2Service
gatewayV2Service,
notificationService
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
@@ -484,13 +488,14 @@ export const secretSyncQueueFactory = ({
try {
const {
connection: { orgId, encryptedCredentials }
connection: { orgId, encryptedCredentials, projectId }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
kmsService,
projectId
});
const secretSyncWithCredentials = {
@@ -624,13 +629,14 @@ export const secretSyncQueueFactory = ({
try {
const {
connection: { orgId, encryptedCredentials }
connection: { orgId, encryptedCredentials, projectId }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
kmsService,
projectId
});
await $importSecrets(
@@ -744,13 +750,14 @@ export const secretSyncQueueFactory = ({
try {
const {
connection: { orgId, encryptedCredentials }
connection: { orgId, encryptedCredentials, projectId }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
kmsService,
projectId
});
const secretMap = await $getInfisicalSecrets(secretSync);
@@ -895,6 +902,19 @@ export const secretSyncQueueFactory = ({
break;
}
const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`;
await notificationService.createUserNotifications(
projectAdmins.map((admin) => ({
userId: admin.userId,
orgId: project.orgId,
type: NotificationType.SECRET_SYNC_FAILED,
title: `Secret Sync Failed to ${actionLabel} Secrets`,
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
link: syncPath
}))
);
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
@@ -907,7 +927,7 @@ export const secretSyncQueueFactory = ({
secretPath: folder?.path,
environment: environment?.name,
projectName: project.name,
syncUrl: `${appCfg.SITE_URL}/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`
syncUrl: `${appCfg.SITE_URL}${syncPath}`
}
});
};
@@ -41,7 +41,7 @@ import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = {
secretSyncDAL: TSecretSyncDALFactory;
secretImportDAL: TSecretImportDALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
@@ -267,7 +267,11 @@ export const secretSyncServiceFactory = ({
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
// validates permission to connect and app is valid for sync destination
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
await appConnectionService.validateAppConnectionUsageById(
destinationApp,
{ connectionId: params.connectionId, projectId },
actor
);
try {
const secretSync = await secretSyncDAL.create({
@@ -362,7 +366,11 @@ export const secretSyncServiceFactory = ({
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
// validates permission to connect and app is valid for sync destination
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
await appConnectionService.validateAppConnectionUsageById(
destinationApp,
{ connectionId: params.connectionId, projectId: secretSync.projectId },
actor
);
}
if (
@@ -446,9 +446,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
}
})
.where((bd) => {
void bd
.whereNull(`${TableName.SecretV2}.userId`)
.orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null });
void bd.whereNull(`${TableName.SecretV2}.userId`);
// scott: removing this as we don't need to count overrides
// and there is currently a bug when you move secrets that doesn't move the override so this can skew count
// .orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null });
})
.countDistinct(`${TableName.SecretV2}.key`);
@@ -491,15 +491,16 @@ export const secretV2BridgeServiceFactory = ({
secret = sharedSecretToModify;
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: inputSecret.secretName,
secretTags: secret.tags.map((el) => el.slug)
})
);
if (secret.type !== SecretType.Personal)
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: inputSecret.secretName,
secretTags: secret.tags.map((el) => el.slug)
})
);
// validate tags
// fetch all tags and if not same count throw error meaning one was invalid tags
@@ -510,17 +511,18 @@ export const secretV2BridgeServiceFactory = ({
const tagsToCheck = inputSecret.tagIds ? newTags : secret.tags;
// now check with new ids
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: inputSecret.secretName,
...(tagsToCheck.length && {
secretTags: tagsToCheck.map((el) => el.slug)
if (secret.type !== SecretType.Personal)
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: inputSecret.secretName,
...(tagsToCheck.length && {
secretTags: tagsToCheck.map((el) => el.slug)
})
})
})
);
);
if (inputSecret.newSecretName) {
const doesNewNameSecretExist = await secretDAL.findOne({
@@ -727,15 +729,17 @@ export const secretV2BridgeServiceFactory = ({
})
});
if (!secretToDelete) throw new NotFoundError({ message: "Secret not found" });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretToDelete.key,
secretTags: secretToDelete.tags?.map((el) => el.slug)
})
);
if (secretToDelete.type !== SecretType.Personal)
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretToDelete.key,
secretTags: secretToDelete.tags?.map((el) => el.slug)
})
);
try {
const deletedSecret = await secretDAL.transaction(async (tx) => {
@@ -1679,7 +1683,7 @@ export const secretV2BridgeServiceFactory = ({
await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []);
// get all tags
const sanitizedTagIds = inputSecrets.flatMap(({ tagIds = [] }) => tagIds);
const sanitizedTagIds = [...new Set(inputSecrets.flatMap(({ tagIds = [] }) => tagIds))];
const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds) : [];
if (tags.length !== sanitizedTagIds.length)
throw new NotFoundError({ message: `Tag not found. Found ${tags.map((el) => el.slug).join(",")}` });
@@ -1927,7 +1931,7 @@ export const secretV2BridgeServiceFactory = ({
});
// get all tags
const sanitizedTagIds = secretsToUpdate.flatMap(({ tagIds = [] }) => tagIds);
const sanitizedTagIds = [...new Set(secretsToUpdate.flatMap(({ tagIds = [] }) => tagIds))];
const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds, tx) : [];
if (tags.length !== sanitizedTagIds.length) throw new NotFoundError({ message: "Tag not found" });
const tagsGroupByID = groupBy(tags, (i) => i.id);
@@ -2354,7 +2358,8 @@ export const secretV2BridgeServiceFactory = ({
actorAuthMethod,
limit = 20,
offset = 0,
secretId
secretId,
secretVersions: secretVersionsFilter
}: TGetSecretVersionsDTO) => {
const secret = await secretDAL.findById(secretId);
@@ -2391,6 +2396,7 @@ export const secretV2BridgeServiceFactory = ({
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({
secretId,
projectId: folder.projectId,
secretVersions: secretVersionsFilter,
findOpt: {
offset,
limit,
@@ -2960,7 +2966,7 @@ export const secretV2BridgeServiceFactory = ({
secretKey: secretName
});
return { tree: stackTrace, value: expandedValue };
return { tree: stackTrace, value: expandedValue, secret };
};
const getAccessibleSecrets = async ({
@@ -159,6 +159,7 @@ export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
limit?: number;
offset?: number;
secretId: string;
secretVersions?: string[];
};
export type TSecretReference = { environment: string; secretPath: string; secretKey: string };
+18 -7
View File
@@ -2568,7 +2568,8 @@ export const secretServiceFactory = ({
actorAuthMethod,
limit = 20,
offset = 0,
secretId
secretId,
secretVersions: filterSecretVersions
}: TGetSecretVersionsDTO) => {
const secretVersionV2 = await secretV2BridgeService
.getSecretVersions({
@@ -2578,7 +2579,8 @@ export const secretServiceFactory = ({
actorAuthMethod,
limit,
offset,
secretId
secretId,
secretVersions: filterSecretVersions
})
.catch((err) => {
if ((err as Error).message === "BadRequest: Failed to find secret") {
@@ -2970,14 +2972,23 @@ export const secretServiceFactory = ({
actor,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
projectId: inputProjectId
}: TMoveSecretsDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
let project;
if (projectSlug) {
project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
} else if (inputProjectId) {
project = await projectDAL.findById(inputProjectId);
}
if (!project) {
throw new NotFoundError({
message: `Project with slug '${projectSlug}' not found`
});
}
const projectId = project.id;
if (project.version === ProjectVersion.V3) {
return secretV2BridgeService.moveSecrets({
sourceEnvironment,
@@ -3170,7 +3181,7 @@ export const secretServiceFactory = ({
});
}
const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
project.id,
projectId,
destinationFolder.environment.slug,
destinationFolder.path
);
@@ -3257,7 +3268,7 @@ export const secretServiceFactory = ({
}
if (locallyUpdatedSecrets.length) {
await fnSecretBulkUpdate({
projectId: project.id,
projectId,
folderId: destinationFolder.id,
secretVersionDAL,
secretDAL,
@@ -3300,7 +3311,7 @@ export const secretServiceFactory = ({
const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete }));
const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
project.id,
projectId,
sourceFolder.environment.slug,
sourceFolder.path
);
+3 -1
View File
@@ -331,6 +331,7 @@ export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
limit?: number;
offset?: number;
secretId: string;
secretVersions?: string[];
};
export type TSecretReference = { environment: string; secretPath: string };
@@ -534,7 +535,8 @@ export type TSyncSecretsDTO<T extends boolean = false> = {
});
export type TMoveSecretsDTO = {
projectSlug: string;
projectId?: string;
projectSlug?: string;
sourceEnvironment: string;
sourceSecretPath: string;
destinationEnvironment: string;
@@ -32,7 +32,8 @@ export enum PostHogEventTypes {
IssueSshHostHostCert = "Issue SSH Host Host Certificate",
SignCert = "Sign PKI Certificate",
IssueCert = "Issue PKI Certificate",
InvalidateCache = "Invalidate Cache"
InvalidateCache = "Invalidate Cache",
NotificationUpdated = "Notification Updated"
}
export type TSecretModifiedEvent = {
@@ -46,7 +47,7 @@ export type TSecretModifiedEvent = {
properties: {
numberOfSecrets: number;
environment: string;
workspaceId: string;
projectId: string;
secretPath: string;
channel?: string;
userAgent?: string;
@@ -232,6 +233,14 @@ export type TInvalidateCacheEvent = {
};
};
export type TNotificationUpdatedEvent = {
event: PostHogEventTypes.NotificationUpdated;
properties: {
notificationId: string;
isRead?: boolean;
};
};
export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
@@ -251,4 +260,5 @@ export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TSignCertificateEvent
| TIssueCertificateEvent
| TInvalidateCacheEvent
| TNotificationUpdatedEvent
);
+8 -1
View File
@@ -294,11 +294,18 @@ export const userServiceFactory = ({
// Delete all user aliases since the email is changing
await userAliasDAL.delete({ userId }, tx);
// Ensure EMAIL auth method is included if not already present
const currentAuthMethods = user.authMethods || [];
const updatedAuthMethods = currentAuthMethods.includes(AuthMethod.EMAIL)
? currentAuthMethods
: [...currentAuthMethods, AuthMethod.EMAIL];
const updatedUser = await userDAL.updateById(
userId,
{
email: newEmail.toLowerCase(),
username: newEmail.toLowerCase()
username: newEmail.toLowerCase(),
authMethods: updatedAuthMethods
},
tx
);