Merge remote-tracking branch 'origin/main' into misc/terraform-project-group-prereq

This commit is contained in:
Sheen Capadngan
2024-09-23 15:09:56 +08:00
166 changed files with 7249 additions and 1987 deletions
@@ -77,6 +77,39 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/entra-id/users",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"),
applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"),
clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration")
}),
response: {
200: z
.object({
name: z.string().min(1).describe("The name of the user"),
id: z.string().min(1).describe("The ID of the user"),
email: z.string().min(1).describe("The email of the user")
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({
tenantId: req.body.tenantId,
applicationId: req.body.applicationId,
clientSecret: req.body.clientSecret
});
return data;
}
});
server.route({
method: "PATCH",
url: "/:name",
@@ -237,7 +270,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const dynamicSecretCfgs = await server.services.dynamicSecret.list({
const dynamicSecretCfgs = await server.services.dynamicSecret.listDynamicSecretsByEnv({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
@@ -101,6 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
message: "Slug must be a valid"
}),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description),
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
}),
response: {
+23 -11
View File
@@ -87,6 +87,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
});
/*
* Daniel: This endpoint is no longer is use.
* We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows.
*
* Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality.
*/
server.route({
method: "GET",
url: "/:workspaceId/audit-logs",
@@ -101,7 +107,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.workspaceId)
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId)
}),
querystring: z.object({
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
@@ -122,10 +128,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
})
.merge(
z.object({
project: z.object({
name: z.string(),
slug: z.string()
}),
project: z
.object({
name: z.string(),
slug: z.string()
})
.optional(),
event: z.object({
type: z.string(),
metadata: z.any()
@@ -146,12 +154,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
projectId: req.params.workspaceId,
...req.query,
endDate: req.query.endDate,
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActor: req.query.actor,
actor: req.permission.type
actor: req.permission.type,
filter: {
...req.query,
projectId: req.params.workspaceId,
endDate: req.query.endDate,
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActorId: req.query.actor,
eventType: req.query.eventType ? [req.query.eventType] : undefined
}
});
return { auditLogs };
}
@@ -3,9 +3,12 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { AuditLogsSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type";
import { EventType } from "./audit-log-types";
export type TAuditLogDALFactory = ReturnType<typeof auditLogDALFactory>;
@@ -25,37 +28,81 @@ export const auditLogDALFactory = (db: TDbClient) => {
const auditLogOrm = ormify(db, TableName.AuditLog);
const find = async (
{ orgId, projectId, userAgentType, startDate, endDate, limit = 20, offset = 0, actor, eventType }: TFindQuery,
{
orgId,
projectId,
userAgentType,
startDate,
endDate,
limit = 20,
offset = 0,
actorId,
actorType,
eventType,
eventMetadata
}: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string;
actorType?: ActorType;
eventType?: EventType[];
eventMetadata?: Record<string, string>;
},
tx?: Knex
) => {
if (!orgId && !projectId) {
throw new Error("Either orgId or projectId must be provided");
}
try {
// Find statements
const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog)
.where(
stripUndefinedInWhere({
projectId,
[`${TableName.AuditLog}.orgId`]: orgId,
eventType,
userAgentType
})
)
.leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`)
// eslint-disable-next-line func-names
.where(function () {
if (orgId) {
void this.where(`${TableName.Project}.orgId`, orgId).orWhere(`${TableName.AuditLog}.orgId`, orgId);
} else if (projectId) {
void this.where(`${TableName.AuditLog}.projectId`, projectId);
}
});
if (userAgentType) {
void sqlQuery.where("userAgentType", userAgentType);
}
// Select statements
void sqlQuery
.select(selectAllTableCols(TableName.AuditLog))
.select(
db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug")
)
.limit(limit)
.offset(offset)
.orderBy(`${TableName.AuditLog}.createdAt`, "desc");
if (actor) {
void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actor]);
// Special case: Filter by actor ID
if (actorId) {
void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]);
}
// Special case: Filter by key/value pairs in eventMetadata field
if (eventMetadata && Object.keys(eventMetadata).length) {
Object.entries(eventMetadata).forEach(([key, value]) => {
void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]);
});
}
// Filter by actor type
if (actorType) {
void sqlQuery.where("actor", actorType);
}
// Filter by event types
if (eventType?.length) {
void sqlQuery.whereIn("eventType", eventType);
}
// Filter by date range
if (startDate) {
void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate);
}
@@ -64,13 +111,21 @@ export const auditLogDALFactory = (db: TDbClient) => {
}
const docs = await sqlQuery;
return docs.map((doc) => ({
...AuditLogsSchema.parse(doc),
project: {
name: doc.projectName,
slug: doc.projectSlug
}
}));
return docs.map((doc) => {
// Our type system refuses to acknowledge that the project name and slug are present in the doc, due to the disjointed query structure above.
// This is a quick and dirty way to get around the types.
const projectDoc = doc as unknown as { projectName: string; projectSlug: string };
return {
...AuditLogsSchema.parse(doc),
...(projectDoc?.projectSlug && {
project: {
name: projectDoc.projectName,
slug: projectDoc.projectSlug
}
})
};
});
} catch (error) {
throw new DatabaseError({ error });
}
@@ -23,30 +23,19 @@ export const auditLogServiceFactory = ({
auditLogQueue,
permissionService
}: TAuditLogServiceFactoryDep) => {
const listAuditLogs = async ({
userAgentType,
eventType,
offset,
limit,
endDate,
startDate,
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
auditLogActor
}: TListProjectAuditLogDTO) => {
if (projectId) {
const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => {
// Filter logs for specific project
if (filter.projectId) {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
filter.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
} else {
// Organization-wide logs
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
@@ -57,22 +46,23 @@ export const auditLogServiceFactory = ({
/**
* NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved
* to the organization level
* to the organization level ✅
*/
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
}
// If project ID is not provided, then we need to return all the audit logs for the organization itself.
const auditLogs = await auditLogDAL.find({
startDate,
endDate,
limit,
offset,
eventType,
userAgentType,
actor: auditLogActor,
...(projectId ? { projectId } : { orgId: actorOrgId })
startDate: filter.startDate,
endDate: filter.endDate,
limit: filter.limit,
offset: filter.offset,
eventType: filter.eventType,
userAgentType: filter.userAgentType,
actorId: filter.auditLogActorId,
actorType: filter.actorType,
eventMetadata: filter.eventMetadata,
...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId })
});
return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({
@@ -5,19 +5,23 @@ import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
export type TListProjectAuditLogDTO = {
auditLogActor?: string;
projectId?: string;
eventType?: string;
startDate?: string;
endDate?: string;
userAgentType?: string;
limit?: number;
offset?: number;
filter: {
userAgentType?: UserAgentType;
eventType?: EventType[];
offset?: number;
limit: number;
endDate?: string;
startDate?: string;
projectId?: string;
auditLogActorId?: string;
actorType?: ActorType;
eventMetadata?: Record<string, string>;
};
} & Omit<TProjectPermission, "projectId">;
export type TCreateAuditLogDTO = {
event: Event;
actor: UserActor | IdentityActor | ServiceActor | ScimClientActor;
actor: UserActor | IdentityActor | ServiceActor | ScimClientActor | PlatformActor;
orgId?: string;
projectId?: string;
} & BaseAuthData;
@@ -177,7 +181,8 @@ export enum EventType {
UPDATE_SLACK_INTEGRATION = "update-slack-integration",
DELETE_SLACK_INTEGRATION = "delete-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config"
UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config",
INTEGRATION_SYNCED = "integration-synced"
}
interface UserActorMetadata {
@@ -198,6 +203,8 @@ interface IdentityActorMetadata {
interface ScimClientActorMetadata {}
interface PlatformActorMetadata {}
export interface UserActor {
type: ActorType.USER;
metadata: UserActorMetadata;
@@ -208,6 +215,11 @@ export interface ServiceActor {
metadata: ServiceActorMetadata;
}
export interface PlatformActor {
type: ActorType.PLATFORM;
metadata: PlatformActorMetadata;
}
export interface IdentityActor {
type: ActorType.IDENTITY;
metadata: IdentityActorMetadata;
@@ -218,7 +230,7 @@ export interface ScimClientActor {
metadata: ScimClientActorMetadata;
}
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor;
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor;
interface GetSecretsEvent {
type: EventType.GET_SECRETS;
@@ -1518,6 +1530,16 @@ interface GetProjectSlackConfig {
id: string;
};
}
interface IntegrationSyncedEvent {
type: EventType.INTEGRATION_SYNCED;
metadata: {
integrationId: string;
lastSyncJobId: string;
lastUsed: Date;
syncMessage: string;
isSynced: boolean;
};
}
export type Event =
| GetSecretsEvent
@@ -1657,4 +1679,5 @@ export type Event =
| DeleteSlackIntegration
| GetSlackIntegration
| UpdateProjectSlackConfig
| GetProjectSlackConfig;
| GetProjectSlackConfig
| IntegrationSyncedEvent;
@@ -1,10 +1,70 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory>;
export const dynamicSecretDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.DynamicSecret);
return orm;
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
const listDynamicSecretsByFolderIds = async (
{
folderIds,
search,
limit,
offset = 0,
orderBy = SecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
folderIds: string[];
search?: string;
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.whereIn("folderId", folderIds)
.where((bd) => {
if (search) {
void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`);
}
})
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`)
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(
selectAllTableCols(TableName.DynamicSecret),
db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`)
)
.orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection);
if (limit) {
const rankOffset = offset + 1;
return await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
}
const dynamicSecrets = await query;
return dynamicSecrets;
} catch (error) {
throw new DatabaseError({ error, name: "List dynamic secret multi env" });
}
};
return { ...orm, listDynamicSecretsByFolderIds };
};
@@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
@@ -17,9 +18,12 @@ import {
TCreateDynamicSecretDTO,
TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO,
TGetDynamicSecretsCountDTO,
TListDynamicSecretsDTO,
TListDynamicSecretsMultiEnvDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
import { AzureEntraIDProvider } from "./providers/azure-entra-id";
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
type TDynamicSecretServiceFactoryDep = {
@@ -31,7 +35,7 @@ type TDynamicSecretServiceFactoryDep = {
"pruneDynamicSecret" | "unsetLeaseRevocation"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
@@ -300,19 +304,55 @@ export const dynamicSecretServiceFactory = ({
return { ...dynamicSecretCfg, inputs: providerInputs };
};
const list = async ({
// get unique dynamic secret count across multiple envs
const getCountMultiEnv = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
projectId,
path,
environmentSlug
}: TListDynamicSecretsDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
environmentSlugs,
search
}: TListDynamicSecretsMultiEnvDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
const projectId = project.id;
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find(
{ $in: { folderId: folders.map((folder) => folder.id) }, $search: search ? { name: `%${search}%` } : undefined },
{ countDistinct: "name" }
);
return Number(dynamicSecretCfg[0]?.count ?? 0);
};
// get dynamic secret count for a single env
const getDynamicSecretCount = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
path,
environmentSlug,
search,
projectId
}: TGetDynamicSecretsCountDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
@@ -328,15 +368,127 @@ export const dynamicSecretServiceFactory = ({
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id });
const dynamicSecretCfg = await dynamicSecretDAL.find(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{ count: true }
);
return Number(dynamicSecretCfg[0]?.count ?? 0);
};
const listDynamicSecretsByEnv = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
path,
environmentSlug,
limit,
offset,
orderBy,
orderDirection = OrderByDirection.ASC,
search,
...params
}: TListDynamicSecretsDTO) => {
let { projectId } = params;
if (!projectId) {
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
projectId = project.id;
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{
limit,
offset,
sort: orderBy ? [[orderBy, orderDirection]] : undefined
}
);
return dynamicSecretCfg;
};
// get dynamic secrets for multiple envs
const listDynamicSecretsByFolderIds = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
path,
environmentSlugs,
projectId,
...params
}: TListDynamicSecretsMultiEnvDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
const dynamicSecretCfg = await dynamicSecretDAL.listDynamicSecretsByFolderIds({
folderIds: folders.map((folder) => folder.id),
...params
});
return dynamicSecretCfg;
};
const fetchAzureEntraIdUsers = async ({
tenantId,
applicationId,
clientSecret
}: {
tenantId: string;
applicationId: string;
clientSecret: string;
}) => {
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
tenantId,
applicationId,
clientSecret
);
return azureEntraIdUsers;
};
return {
create,
updateByName,
deleteByName,
getDetails,
list
listDynamicSecretsByEnv,
listDynamicSecretsByFolderIds,
getDynamicSecretCount,
getCountMultiEnv,
fetchAzureEntraIdUsers
};
};
@@ -1,6 +1,7 @@
import { z } from "zod";
import { TProjectPermission } from "@app/lib/types";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { DynamicSecretProviderSchema } from "./providers/models";
@@ -50,5 +51,20 @@ export type TDetailsDynamicSecretDTO = {
export type TListDynamicSecretsDTO = {
path: string;
environmentSlug: string;
projectSlug: string;
projectSlug?: string;
projectId?: string;
offset?: number;
limit?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
} & Omit<TProjectPermission, "projectId">;
export type TListDynamicSecretsMultiEnvDTO = Omit<
TListDynamicSecretsDTO,
"projectId" | "environmentSlug" | "projectSlug"
> & { projectId: string; environmentSlugs: string[] };
export type TGetDynamicSecretsCountDTO = Omit<TListDynamicSecretsDTO, "projectSlug" | "projectId"> & {
projectId: string;
};
@@ -0,0 +1,138 @@
import axios from "axios";
import { customAlphabet } from "nanoid";
import { BadRequestError } from "@app/lib/errors";
import { AzureEntraIDSchema, TDynamicProviderFns } from "./models";
const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 64)();
};
type User = { name: string; id: string; email: string };
export const AzureEntraIDProvider = (): TDynamicProviderFns & {
fetchAzureEntraIdUsers: (tenantId: string, applicationId: string, clientSecret: string) => Promise<User[]>;
} => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
return providerInputs;
};
const getToken = async (
tenantId: string,
applicationId: string,
clientSecret: string
): Promise<{ token?: string; success: boolean }> => {
const response = await axios.post<{ access_token: string }>(
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
{
grant_type: "client_credentials",
client_id: applicationId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default"
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
if (response.status === 200) {
return { token: response.data.access_token, success: true };
}
return { success: false };
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret);
return data.success;
};
const renew = async (inputs: unknown, entityId: string) => {
// Do nothing
return { entityId };
};
const create = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const password = generatePassword();
const response = await axios.patch(
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
{
passwordProfile: {
forceChangePasswordNextSignIn: false,
password
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${data.token}`
}
}
);
if (response.status !== 204) {
throw new BadRequestError({ message: "Failed to update password" });
}
return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
// Creates a new password
await create(inputs);
return { entityId };
};
const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => {
const data = await getToken(tenantId, applicationId, clientSecret);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const response = await axios.get<{ value: [{ id: string; displayName: string; userPrincipalName: string }] }>(
`${MSFT_GRAPH_API_URL}/users`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${data.token}`
}
}
);
if (response.status !== 200) {
throw new BadRequestError({ message: "Failed to fetch users" });
}
const users = response.data.value.map((user) => {
return {
name: user.displayName,
id: user.id,
email: user.userPrincipalName
};
});
return users;
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew,
fetchAzureEntraIdUsers
};
};
@@ -1,5 +1,6 @@
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { ElasticSearchProvider } from "./elastic-search";
import { DynamicSecretProviders } from "./models";
@@ -18,5 +19,6 @@ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(),
[DynamicSecretProviders.MongoDB]: MongoDBProvider(),
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider()
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider()
});
@@ -166,6 +166,14 @@ export const DynamicSecretMongoDBSchema = z.object({
)
});
export const AzureEntraIDSchema = z.object({
tenantId: z.string().trim().min(1),
userId: z.string().trim().min(1),
email: z.string().trim().min(1),
applicationId: z.string().trim().min(1),
clientSecret: z.string().trim().min(1)
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -175,7 +183,8 @@ export enum DynamicSecretProviders {
MongoAtlas = "mongo-db-atlas",
ElasticSearch = "elastic-search",
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq"
RabbitMq = "rabbit-mq",
AzureEntraID = "azure-entra-id"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -187,7 +196,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }),
z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }),
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema })
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema })
]);
export type TDynamicProviderFns = {
@@ -1,7 +1,5 @@
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
import { conditionsMatcher } from "@app/lib/casl";
export enum OrgPermissionActions {
Read = "read",
Create = "create",
@@ -27,7 +25,8 @@ export enum OrgPermissionSubjects {
SecretScanning = "secret-scanning",
Identity = "identity",
Kms = "kms",
AdminConsole = "organization-admin-console"
AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs"
}
export type OrgPermissionSet =
@@ -45,10 +44,11 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
const buildAdminPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
@@ -113,15 +113,20 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
return build({ conditionsMatcher });
return rules;
};
export const orgAdminPermissions = buildAdminPermission();
const buildMemberPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
@@ -142,14 +147,16 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
return build({ conditionsMatcher });
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
return rules;
};
export const orgMemberPermissions = buildMemberPermission();
const buildNoAccessPermission = () => {
const { build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
return build({ conditionsMatcher });
const { rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
return rules;
};
export const orgNoAccessPermissions = buildNoAccessPermission();
@@ -1,7 +1,13 @@
import { z } from "zod";
import { TDbClient } from "@app/db";
import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas";
import {
IdentityProjectMembershipRoleSchema,
OrgMembershipsSchema,
TableName,
TProjectRoles,
TProjects
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
@@ -10,18 +16,91 @@ export type TPermissionDALFactory = ReturnType<typeof permissionDALFactory>;
export const permissionDALFactory = (db: TDbClient) => {
const getOrgPermission = async (userId: string, orgId: string) => {
try {
const groupSubQuery = db(TableName.Groups)
.where(`${TableName.Groups}.orgId`, orgId)
.join(TableName.UserGroupMembership, (queryBuilder) => {
queryBuilder
.on(`${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`)
.andOn(`${TableName.UserGroupMembership}.userId`, db.raw("?", [userId]));
})
.leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`)
.select(
db.ref("id").withSchema(TableName.Groups).as("groupId"),
db.ref("orgId").withSchema(TableName.Groups).as("groupOrgId"),
db.ref("name").withSchema(TableName.Groups).as("groupName"),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug"),
db.ref("role").withSchema(TableName.Groups).as("groupRole"),
db.ref("roleId").withSchema(TableName.Groups).as("groupRoleId"),
db.ref("createdAt").withSchema(TableName.Groups).as("groupCreatedAt"),
db.ref("updatedAt").withSchema(TableName.Groups).as("groupUpdatedAt"),
db.ref("permissions").withSchema(TableName.OrgRoles).as("groupCustomRolePermission")
);
const membership = await db
.replicaNode()(TableName.OrgMembership)
.leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`)
.where("userId", userId)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"))
.select("permissions")
.select(selectAllTableCols(TableName.OrgMembership))
.first();
.where(`${TableName.OrgMembership}.userId`, userId)
.leftJoin(TableName.OrgRoles, `${TableName.OrgRoles}.id`, `${TableName.OrgMembership}.roleId`)
.leftJoin<Awaited<typeof groupSubQuery>[0]>(
groupSubQuery.as("userGroups"),
"userGroups.groupOrgId",
db.raw("?", [orgId])
)
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.select(
selectAllTableCols(TableName.OrgMembership),
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"),
db.ref("groupName").withSchema("userGroups"),
db.ref("groupSlug").withSchema("userGroups"),
db.ref("groupRole").withSchema("userGroups"),
db.ref("groupRoleId").withSchema("userGroups"),
db.ref("groupCreatedAt").withSchema("userGroups"),
db.ref("groupUpdatedAt").withSchema("userGroups"),
db.ref("groupCustomRolePermission").withSchema("userGroups")
);
return membership;
const [formatedDoc] = sqlNestRelationships({
data: membership,
key: "id",
parentMapper: (el) =>
OrgMembershipsSchema.extend({
permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(),
customRoleSlug: z.string().optional().nullable()
}).parse(el),
childrenMapper: [
{
key: "groupId",
label: "groups" as const,
mapper: ({
groupId,
groupUpdatedAt,
groupCreatedAt,
groupRole,
groupRoleId,
groupCustomRolePermission,
groupName,
groupSlug,
groupOrgId
}) => ({
id: groupId,
updatedAt: groupUpdatedAt,
createdAt: groupCreatedAt,
role: groupRole,
roleId: groupRoleId,
customRolePermission: groupCustomRolePermission,
name: groupName,
slug: groupSlug,
orgId: groupOrgId
})
}
]
});
return formatedDoc;
} catch (error) {
throw new DatabaseError({ error, name: "GetOrgPermission" });
}
@@ -47,74 +126,31 @@ export const permissionDALFactory = (db: TDbClient) => {
const getProjectPermission = async (userId: string, projectId: string) => {
try {
const groups: string[] = await db
.replicaNode()(TableName.GroupProjectMembership)
.where(`${TableName.GroupProjectMembership}.projectId`, projectId)
.pluck(`${TableName.GroupProjectMembership}.groupId`);
const groupDocs = await db
.replicaNode()(TableName.UserGroupMembership)
.where(`${TableName.UserGroupMembership}.userId`, userId)
.whereIn(`${TableName.UserGroupMembership}.groupId`, groups)
.join(
TableName.GroupProjectMembership,
`${TableName.GroupProjectMembership}.groupId`,
`${TableName.UserGroupMembership}.groupId`
)
.join(
const docs = await db
.replicaNode()(TableName.Users)
.where(`${TableName.Users}.id`, userId)
.leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.GroupProjectMembership, (queryBuilder) => {
void queryBuilder
.on(`${TableName.GroupProjectMembership}.projectId`, db.raw("?", [projectId]))
.andOn(`${TableName.GroupProjectMembership}.groupId`, `${TableName.UserGroupMembership}.groupId`);
})
.leftJoin(
TableName.GroupProjectMembershipRole,
`${TableName.GroupProjectMembershipRole}.projectMembershipId`,
`${TableName.GroupProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
.leftJoin<TProjectRoles>(
{ groupCustomRoles: TableName.ProjectRoles },
`${TableName.GroupProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
`groupCustomRoles.id`
)
.join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.leftJoin(TableName.ProjectMembership, (queryBuilder) => {
void queryBuilder
.on(`${TableName.ProjectMembership}.projectId`, db.raw("?", [projectId]))
.andOn(`${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`);
})
.leftJoin(
TableName.ProjectUserAdditionalPrivilege,
`${TableName.GroupProjectMembership}.projectId`,
`${TableName.Project}.id`
)
.select(selectAllTableCols(TableName.GroupProjectMembershipRole))
.select(
db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"),
db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("membershipUpdatedAt"),
db.ref("projectId").withSchema(TableName.GroupProjectMembership),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles).as("permissions"),
// db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("apPermissions")
// Additional Privileges
db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"),
db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"),
db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"),
db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"),
db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"),
db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessEndTime")
);
// .select(`${TableName.ProjectRoles}.permissions`);
const docs = await db(TableName.ProjectMembership)
.join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
@@ -124,176 +160,229 @@ export const permissionDALFactory = (db: TDbClient) => {
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.leftJoin(
TableName.ProjectUserAdditionalPrivilege,
`${TableName.ProjectUserAdditionalPrivilege}.projectId`,
`${TableName.ProjectMembership}.projectId`
)
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.leftJoin(TableName.ProjectUserAdditionalPrivilege, (queryBuilder) => {
void queryBuilder
.on(`${TableName.ProjectUserAdditionalPrivilege}.projectId`, db.raw("?", [projectId]))
.andOn(`${TableName.ProjectUserAdditionalPrivilege}.userId`, `${TableName.Users}.id`);
})
.join<TProjects>(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId]))
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.where(`${TableName.ProjectMembership}.userId`, userId)
.where(`${TableName.ProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.ProjectUserMembershipRole))
.select(
db.ref("id").withSchema(TableName.Users).as("userId"),
// groups specific
db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"),
db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipUpdatedAt"),
db.ref("slug").withSchema("groupCustomRoles").as("userGroupProjectMembershipRoleCustomRoleSlug"),
db.ref("permissions").withSchema("groupCustomRoles").as("userGroupProjectMembershipRolePermission"),
db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRoleId"),
db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRole"),
db
.ref("customRoleId")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleCustomRoleId"),
db
.ref("isTemporary")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleIsTemporary"),
db
.ref("temporaryMode")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleTemporaryMode"),
db
.ref("temporaryRange")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleTemporaryRange"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.GroupProjectMembershipRole)
.as("userGroupProjectMembershipRoleTemporaryAccessEndTime"),
// user specific
db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"),
db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"),
db.ref("projectId").withSchema(TableName.ProjectMembership),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles),
db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"),
db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"),
db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"),
db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"),
db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"),
db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("userProjectMembershipRoleCustomRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles).as("userProjectCustomRolePermission"),
db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRoleId"),
db.ref("role").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRole"),
db
.ref("temporaryMode")
.withSchema(TableName.ProjectUserMembershipRole)
.as("userProjectMembershipRoleTemporaryMode"),
db
.ref("isTemporary")
.withSchema(TableName.ProjectUserMembershipRole)
.as("userProjectMembershipRoleIsTemporary"),
db
.ref("temporaryRange")
.withSchema(TableName.ProjectUserMembershipRole)
.as("userProjectMembershipRoleTemporaryRange"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.ProjectUserMembershipRole)
.as("userProjectMembershipRoleTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.ProjectUserMembershipRole)
.as("userProjectMembershipRoleTemporaryAccessEndTime"),
db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesId"),
db
.ref("permissions")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userAdditionalPrivilegesPermissions"),
db
.ref("temporaryMode")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userAdditionalPrivilegesTemporaryMode"),
db
.ref("isTemporary")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userAdditionalPrivilegesIsTemporary"),
db
.ref("temporaryRange")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userAdditionalPrivilegesTemporaryRange"),
db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesUserId"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessStartTime"),
.as("userAdditionalPrivilegesTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessEndTime")
.as("userAdditionalPrivilegesTemporaryAccessEndTime"),
// general
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("id").withSchema(TableName.Project).as("projectId")
);
const permission = sqlNestRelationships({
const [userPermission] = sqlNestRelationships({
data: docs,
key: "projectId",
parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({
parentMapper: ({
orgId,
orgAuthEnforced,
membershipId,
groupMembershipId,
membershipCreatedAt,
groupMembershipCreatedAt,
groupMembershipUpdatedAt,
membershipUpdatedAt
}) => ({
orgId,
orgAuthEnforced,
userId,
id: membershipId,
projectId,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt
id: membershipId || groupMembershipId,
createdAt: membershipCreatedAt || groupMembershipCreatedAt,
updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
ProjectUserMembershipRolesSchema.extend({
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
key: "userGroupProjectMembershipRoleId",
label: "userGroupRoles" as const,
mapper: ({
userGroupProjectMembershipRoleId,
userGroupProjectMembershipRole,
userGroupProjectMembershipRolePermission,
userGroupProjectMembershipRoleCustomRoleSlug,
userGroupProjectMembershipRoleIsTemporary,
userGroupProjectMembershipRoleTemporaryMode,
userGroupProjectMembershipRoleTemporaryAccessEndTime,
userGroupProjectMembershipRoleTemporaryAccessStartTime,
userGroupProjectMembershipRoleTemporaryRange
}) => ({
id: userGroupProjectMembershipRoleId,
role: userGroupProjectMembershipRole,
customRoleSlug: userGroupProjectMembershipRoleCustomRoleSlug,
permissions: userGroupProjectMembershipRolePermission,
temporaryRange: userGroupProjectMembershipRoleTemporaryRange,
temporaryMode: userGroupProjectMembershipRoleTemporaryMode,
temporaryAccessStartTime: userGroupProjectMembershipRoleTemporaryAccessStartTime,
temporaryAccessEndTime: userGroupProjectMembershipRoleTemporaryAccessEndTime,
isTemporary: userGroupProjectMembershipRoleIsTemporary
})
},
{
key: "userApId",
key: "userProjectMembershipRoleId",
label: "projecMembershiptRoles" as const,
mapper: ({
userProjectMembershipRoleId,
userProjectMembershipRole,
userProjectCustomRolePermission,
userProjectMembershipRoleIsTemporary,
userProjectMembershipRoleTemporaryMode,
userProjectMembershipRoleTemporaryRange,
userProjectMembershipRoleTemporaryAccessEndTime,
userProjectMembershipRoleTemporaryAccessStartTime,
userProjectMembershipRoleCustomRoleSlug
}) => ({
id: userProjectMembershipRoleId,
role: userProjectMembershipRole,
customRoleSlug: userProjectMembershipRoleCustomRoleSlug,
permissions: userProjectCustomRolePermission,
temporaryRange: userProjectMembershipRoleTemporaryRange,
temporaryMode: userProjectMembershipRoleTemporaryMode,
temporaryAccessStartTime: userProjectMembershipRoleTemporaryAccessStartTime,
temporaryAccessEndTime: userProjectMembershipRoleTemporaryAccessEndTime,
isTemporary: userProjectMembershipRoleIsTemporary
})
},
{
key: "userAdditionalPrivilegesId",
label: "additionalPrivileges" as const,
mapper: ({
userApId,
userApPermissions,
userApIsTemporary,
userApTemporaryMode,
userApTemporaryRange,
userApTemporaryAccessEndTime,
userApTemporaryAccessStartTime
userAdditionalPrivilegesId,
userAdditionalPrivilegesPermissions,
userAdditionalPrivilegesIsTemporary,
userAdditionalPrivilegesTemporaryMode,
userAdditionalPrivilegesTemporaryRange,
userAdditionalPrivilegesTemporaryAccessEndTime,
userAdditionalPrivilegesTemporaryAccessStartTime
}) => ({
id: userApId,
permissions: userApPermissions,
temporaryRange: userApTemporaryRange,
temporaryMode: userApTemporaryMode,
temporaryAccessEndTime: userApTemporaryAccessEndTime,
temporaryAccessStartTime: userApTemporaryAccessStartTime,
isTemporary: userApIsTemporary
id: userAdditionalPrivilegesId,
permissions: userAdditionalPrivilegesPermissions,
temporaryRange: userAdditionalPrivilegesTemporaryRange,
temporaryMode: userAdditionalPrivilegesTemporaryMode,
temporaryAccessStartTime: userAdditionalPrivilegesTemporaryAccessStartTime,
temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime,
isTemporary: userAdditionalPrivilegesIsTemporary
})
}
]
});
const groupPermission = groupDocs.length
? sqlNestRelationships({
data: groupDocs,
key: "projectId",
parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({
orgId,
orgAuthEnforced,
userId,
id: membershipId,
projectId,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
ProjectUserMembershipRolesSchema.extend({
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
},
{
key: "userApId",
label: "additionalPrivileges" as const,
mapper: ({
userApId,
userApProjectId,
userApUserId,
userApPermissions,
userApIsTemporary,
userApTemporaryMode,
userApTemporaryRange,
userApTemporaryAccessEndTime,
userApTemporaryAccessStartTime
}) => ({
id: userApId,
userId: userApUserId,
projectId: userApProjectId,
permissions: userApPermissions,
temporaryRange: userApTemporaryRange,
temporaryMode: userApTemporaryMode,
temporaryAccessEndTime: userApTemporaryAccessEndTime,
temporaryAccessStartTime: userApTemporaryAccessStartTime,
isTemporary: userApIsTemporary
})
}
]
})
: [];
if (!permission?.[0] && !groupPermission[0]) return undefined;
if (!userPermission) return undefined;
if (!userPermission?.userGroupRoles?.[0] && !userPermission?.projecMembershiptRoles?.[0]) return undefined;
// when introducting cron mode change it here
const activeRoles =
permission?.[0]?.roles?.filter(
userPermission?.projecMembershiptRoles?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
) ?? [];
const activeGroupRoles =
groupPermission?.[0]?.roles?.filter(
userPermission?.userGroupRoles?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
) ?? [];
const activeAdditionalPrivileges =
permission?.[0]?.additionalPrivileges?.filter(
userPermission?.additionalPrivileges?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
) ?? [];
const activeGroupAdditionalPrivileges =
groupPermission?.[0]?.additionalPrivileges?.filter(
({ isTemporary, temporaryAccessEndTime, userId: apUserId, projectId: apProjectId }) =>
apProjectId === projectId &&
apUserId === userId &&
(!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime))
) ?? [];
return {
...(permission[0] || groupPermission[0]),
...userPermission,
roles: [...activeRoles, ...activeGroupRoles],
additionalPrivileges: [...activeAdditionalPrivileges, ...activeGroupAdditionalPrivileges]
additionalPrivileges: activeAdditionalPrivileges
};
} catch (error) {
throw new DatabaseError({ error, name: "GetProjectPermission" });
@@ -20,7 +20,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok
import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission";
import { TPermissionDALFactory } from "./permission-dal";
import { validateOrgSAML } from "./permission-fns";
import { TBuildProjectPermissionDTO } from "./permission-types";
import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO } from "./permission-types";
import {
buildServiceTokenProjectPermission,
projectAdminPermissions,
@@ -47,26 +47,29 @@ export const permissionServiceFactory = ({
serviceTokenDAL,
projectDAL
}: TPermissionServiceFactoryDep) => {
const buildOrgPermission = (role: string, permission?: unknown) => {
switch (role) {
case OrgMembershipRole.Admin:
return orgAdminPermissions;
case OrgMembershipRole.Member:
return orgMemberPermissions;
case OrgMembershipRole.NoAccess:
return orgNoAccessPermissions;
case OrgMembershipRole.Custom:
return createMongoAbility<OrgPermissionSet>(
unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(
permission as PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[]
),
{
conditionsMatcher
}
);
default:
throw new BadRequestError({ name: "OrgRoleInvalid", message: "Org role not found" });
}
const buildOrgPermission = (orgUserRoles: TBuildOrgPermissionDTO) => {
const rules = orgUserRoles
.map(({ role, permissions }) => {
switch (role) {
case OrgMembershipRole.Admin:
return orgAdminPermissions;
case OrgMembershipRole.Member:
return orgMemberPermissions;
case OrgMembershipRole.NoAccess:
return orgNoAccessPermissions;
case OrgMembershipRole.Custom:
return unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(
permissions as PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[]
);
default:
throw new BadRequestError({ name: "OrgRoleInvalid", message: "Org role not found" });
}
})
.reduce((curr, prev) => prev.concat(curr), []);
return createMongoAbility<OrgPermissionSet>(rules, {
conditionsMatcher
});
};
const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => {
@@ -129,7 +132,13 @@ export const permissionServiceFactory = ({
validateOrgSAML(authMethod, membership.orgAuthEnforced);
return { permission: buildOrgPermission(membership.role, membership.permissions), membership };
const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat(
membership?.groups?.map(({ role, customRolePermission }) => ({
role,
permissions: customRolePermission
})) || []
);
return { permission: buildOrgPermission(finalPolicyRoles), membership };
};
const getIdentityOrgPermission = async (identityId: string, orgId: string) => {
@@ -138,7 +147,10 @@ export const permissionServiceFactory = ({
if (membership.role === OrgMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" });
}
return { permission: buildOrgPermission(membership.role, membership.permissions), membership };
return {
permission: buildOrgPermission([{ role: membership.role, permissions: membership.permissions }]),
membership
};
};
const getOrgPermission = async (
@@ -169,11 +181,11 @@ export const permissionServiceFactory = ({
const orgRole = await orgRoleDAL.findOne({ slug: role, orgId });
if (!orgRole) throw new BadRequestError({ message: "Role not found" });
return {
permission: buildOrgPermission(OrgMembershipRole.Custom, orgRole.permissions),
permission: buildOrgPermission([{ role: OrgMembershipRole.Custom, permissions: orgRole.permissions }]),
role: orgRole
};
}
return { permission: buildOrgPermission(role, []) };
return { permission: buildOrgPermission([{ role, permissions: [] }]) };
};
// user permission for a project in an organization
@@ -2,3 +2,8 @@ export type TBuildProjectPermissionDTO = {
permissions?: unknown;
role: string;
}[];
export type TBuildOrgPermissionDTO = {
permissions?: unknown;
role: string;
}[];
@@ -145,6 +145,8 @@ export const fullProjectPermissionSet: [ProjectPermissionActions, ProjectPermiss
[ProjectPermissionActions.Edit, ProjectPermissionSub.Tags],
[ProjectPermissionActions.Delete, ProjectPermissionSub.Tags],
// TODO(Daniel): Remove the audit logs permissions from project-level permissions.
// TODO: We haven't done this yet because it might break existing roles, since those roles will become "invalid" since the audit log permission defined on those roles, no longer exist in the project-level defined permissions.
[ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs],
[ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs],
[ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs],
+36 -1
View File
@@ -698,11 +698,46 @@ export const SECRET_IMPORTS = {
}
} as const;
export const DASHBOARD = {
SECRET_OVERVIEW_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
environments:
"The slugs of the environments to list secrets/folders from (comma separated, ie 'environments=dev,staging,prod').",
secretPath: "The secret path to list secrets/folders from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
limit: "The number of secrets/folders to return.",
orderBy: "The column to order secrets/folders by.",
orderDirection: "The direction to order secrets/folders in.",
search: "The text string to filter secret keys and folder names by.",
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
},
SECRET_DETAILS_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
environment: "The slug of the environment to list secrets/folders from.",
secretPath: "The secret path to list secrets/folders from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
limit: "The number of secrets/folders to return.",
orderBy: "The column to order secrets/folders by.",
orderDirection: "The direction to order secrets/folders in.",
search: "The text string to filter secret keys and folder names by.",
tags: "The tags to filter secrets by (comma separated, ie 'tags=billing,engineering').",
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeImports: "Whether to include project secret imports in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
}
} as const;
export const AUDIT_LOGS = {
EXPORT: {
workspaceId: "The ID of the project to export audit logs from.",
projectId:
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
eventType: "The type of the event to export.",
userAgentType: "Choose which consuming application to export audit logs for.",
eventMetadata:
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
startDate: "The date to start the export from.",
endDate: "The date to end the export at.",
offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.",
+2 -2
View File
@@ -147,8 +147,8 @@ const envSchema = z
PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()),
DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"),
SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"),
WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string()).optional(),
WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string()).optional()
WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()),
WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional())
})
.transform((data) => ({
...data,
+17 -5
View File
@@ -51,11 +51,17 @@ export type TFindReturn<TQuery extends Knex.QueryBuilder, TCount extends boolean
: unknown)
>;
export type TFindOpt<R extends object = object, TCount extends boolean = boolean> = {
export type TFindOpt<
R extends object = object,
TCount extends boolean = boolean,
TCountDistinct extends keyof R | undefined = undefined
> = {
limit?: number;
offset?: number;
sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>;
groupBy?: keyof R;
count?: TCount;
countDistinct?: TCountDistinct;
tx?: Knex;
};
@@ -86,13 +92,18 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
throw new DatabaseError({ error, name: "Find one" });
}
},
find: async <TCount extends boolean = false>(
find: async <
TCount extends boolean = false,
TCountDistinct extends keyof Tables[Tname]["base"] | undefined = undefined
>(
filter: TFindFilter<Tables[Tname]["base"]>,
{ offset, limit, sort, count, tx }: TFindOpt<Tables[Tname]["base"], TCount> = {}
{ offset, limit, sort, count, tx, countDistinct }: TFindOpt<Tables[Tname]["base"], TCount, TCountDistinct> = {}
) => {
try {
const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter));
if (count) {
if (countDistinct) {
void query.countDistinct(countDistinct);
} else if (count) {
void query.select(db.raw("COUNT(*) OVER() AS count"));
void query.select("*");
}
@@ -101,7 +112,8 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const res = (await query) as TFindReturn<typeof query, TCount>;
const res = (await query) as TFindReturn<typeof query, TCountDistinct extends undefined ? TCount : true>;
return res;
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
+26 -20
View File
@@ -7,7 +7,11 @@ import {
TScanFullRepoEventPayload,
TScanPushEventPayload
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
import { TSyncSecretsDTO } from "@app/services/secret/secret-types";
import {
TFailedIntegrationSyncEmailsPayload,
TIntegrationSyncPayload,
TSyncSecretsDTO
} from "@app/services/secret/secret-types";
export enum QueueName {
SecretRotation = "secret-rotation",
@@ -42,6 +46,7 @@ export enum QueueJobs {
SecWebhook = "secret-webhook-trigger",
TelemetryInstanceStats = "telemetry-self-hosted-stats",
IntegrationSync = "secret-integration-pull",
SendFailedIntegrationSyncEmails = "send-failed-integration-sync-emails",
SecretScan = "secret-scan",
UpgradeProjectToGhost = "upgrade-project-to-ghost-job",
DynamicSecretRevocation = "dynamic-secret-revocation",
@@ -88,16 +93,26 @@ export type TQueueJobTypes = {
name: QueueJobs.SecWebhook;
payload: { projectId: string; environment: string; secretPath: string; depth?: number };
};
[QueueName.IntegrationSync]: {
name: QueueJobs.IntegrationSync;
payload: {
projectId: string;
environment: string;
secretPath: string;
depth?: number;
deDupeQueue?: Record<string, boolean>;
};
};
[QueueName.AccessTokenStatusUpdate]:
| {
name: QueueJobs.IdentityAccessTokenStatusUpdate;
payload: { identityAccessTokenId: string; numberOfUses: number };
}
| {
name: QueueJobs.ServiceTokenStatusUpdate;
payload: { serviceTokenId: string };
};
[QueueName.IntegrationSync]:
| {
name: QueueJobs.IntegrationSync;
payload: TIntegrationSyncPayload;
}
| {
name: QueueJobs.SendFailedIntegrationSyncEmails;
payload: TFailedIntegrationSyncEmailsPayload;
};
[QueueName.SecretFullRepoScan]: {
name: QueueJobs.SecretScan;
payload: TScanFullRepoEventPayload;
@@ -151,15 +166,6 @@ export type TQueueJobTypes = {
name: QueueJobs.ProjectV3Migration;
payload: { projectId: string };
};
[QueueName.AccessTokenStatusUpdate]:
| {
name: QueueJobs.IdentityAccessTokenStatusUpdate;
payload: { identityAccessTokenId: string; numberOfUses: number };
}
| {
name: QueueJobs.ServiceTokenStatusUpdate;
payload: { serviceTokenId: string };
};
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
@@ -1,5 +1,6 @@
import { ForbiddenError } from "@casl/ability";
import fastifyPlugin from "fastify-plugin";
import jwt from "jsonwebtoken";
import { ZodError } from "zod";
import {
@@ -11,6 +12,12 @@ import {
UnauthorizedError
} from "@app/lib/errors";
enum JWTErrors {
JwtExpired = "jwt expired",
JwtMalformed = "jwt malformed",
InvalidAlgorithm = "invalid algorithm"
}
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
server.setErrorHandler((error, req, res) => {
req.log.error(error);
@@ -36,6 +43,27 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
status: error.status,
detail: error.detail
});
// Handle JWT errors and make them more human-readable for the end-user.
} else if (error instanceof jwt.JsonWebTokenError) {
const message = (() => {
if (error.message === JWTErrors.JwtExpired) {
return "Your token has expired. Please re-authenticate.";
}
if (error.message === JWTErrors.JwtMalformed) {
return "The provided access token is malformed. Please use a valid token or generate a new one and try again.";
}
if (error.message === JWTErrors.InvalidAlgorithm) {
return "The access token is signed with an invalid algorithm. Please provide a valid token and try again.";
}
return error.message;
})();
void res.status(401).send({
statusCode: 401,
error: "TokenError",
message
});
} else {
void res.send(error);
}
+2 -1
View File
@@ -493,7 +493,6 @@ export const registerRoutes = async (
orgRoleDAL,
permissionService,
orgDAL,
userGroupMembershipDAL,
projectBotDAL,
incidentContactDAL,
tokenService,
@@ -811,6 +810,8 @@ export const registerRoutes = async (
projectEnvDAL,
webhookDAL,
orgDAL,
auditLogService,
userDAL,
projectMembershipDAL,
smtpService,
projectDAL,
@@ -4,7 +4,7 @@ import { IntegrationsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { INTEGRATION } from "@app/lib/api-docs";
import { removeTrailingSlash, shake } from "@app/lib/fn";
import { writeLimit } from "@app/server/config/rateLimiter";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -154,6 +154,48 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:integrationId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get an integration by integration id",
security: [
{
bearerAuth: []
}
],
params: z.object({
integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId)
}),
response: {
200: z.object({
integration: IntegrationsSchema.extend({
environment: z.object({
slug: z.string().trim(),
name: z.string().trim(),
id: z.string().trim()
})
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const integration = await server.services.integration.getIntegration({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationId
});
return { integration };
}
});
server.route({
method: "DELETE",
url: "/:integrationId",
@@ -14,7 +14,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs";
import { getLastMidnightDateISO } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
export const registerOrgRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -74,8 +74,36 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
schema: {
description: "Get all audit logs for an organization",
querystring: z.object({
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
actorType: z.nativeEnum(ActorType).optional(),
// eventType is split with , for multiple values, we need to transform it to array
eventType: z
.string()
.optional()
.transform((val) => (val ? val.split(",") : undefined)),
userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType),
eventMetadata: z
.string()
.optional()
.transform((val) => {
if (!val) {
return undefined;
}
const pairs = val.split(",");
return pairs.reduce(
(acc, pair) => {
const [key, value] = pair.split("=");
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
})
.describe(AUDIT_LOGS.EXPORT.eventMetadata),
startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate),
endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate),
offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset),
@@ -93,10 +121,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
})
.merge(
z.object({
project: z.object({
name: z.string(),
slug: z.string()
}),
project: z
.object({
name: z.string(),
slug: z.string()
})
.optional(),
event: z.object({
type: z.string(),
metadata: z.any()
@@ -114,13 +144,19 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const auditLogs = await server.services.auditLog.listAuditLogs({
filter: {
...req.query,
endDate: req.query.endDate,
projectId: req.query.projectId,
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActorId: req.query.actor,
actorType: req.query.actorType,
eventType: req.query.eventType as EventType[] | undefined
},
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.query,
endDate: req.query.endDate,
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActor: req.query.actor,
actor: req.permission.type
});
return { auditLogs };
@@ -0,0 +1,612 @@
import { z } from "zod";
import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { OrderByDirection } from "@app/lib/types";
import { secretsLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerDashboardRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/secrets-overview",
config: {
rateLimit: secretsLimit
},
schema: {
description: "List project secrets overview",
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.projectId),
environments: z
.string()
.trim()
.transform(decodeURIComponent)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.environments),
secretPath: z
.string()
.trim()
.default("/")
.transform(removeTrailingSlash)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.secretPath),
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_OVERVIEW_LIST.offset),
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_OVERVIEW_LIST.limit),
orderBy: z
.nativeEnum(SecretsOrderBy)
.default(SecretsOrderBy.Name)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderDirection)
.optional(),
search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(),
includeSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
includeFolders: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
includeDynamicSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
}),
response: {
200: z.object({
folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(),
secrets: secretRawSchema
.extend({
secretPath: z.string().optional(),
tags: SecretTagsSchema.pick({
id: true,
slug: true,
color: true
})
.extend({ name: z.string() })
.array()
.optional()
})
.array()
.optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const {
secretPath,
projectId,
limit,
offset,
search,
orderBy,
orderDirection,
includeFolders,
includeSecrets,
includeDynamicSecrets
} = req.query;
const environments = req.query.environments.split(",");
if (!projectId || environments.length === 0)
throw new BadRequestError({ message: "Missing workspace id or environment(s)" });
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
// prevent older projects from accessing endpoint
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
let remainingLimit = limit;
let adjustedOffset = offset;
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
let dynamicSecrets:
| Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByFolderIds>>
| undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
if (includeFolders) {
// this is the unique count, ie duplicate folders across envs only count as 1
totalFolderCount = await server.services.folder.getProjectFolderCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.projectId,
path: secretPath,
environments,
search
});
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
folders = await server.services.folder.getFoldersMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environments,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
// get the count of unique folder names to properly adjust remaining limit
const uniqueFolderCount = new Set(folders.map((folder) => folder.name)).size;
remainingLimit -= uniqueFolderCount;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
}
}
if (includeDynamicSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
environmentSlugs: environments,
path: secretPath
});
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
orderBy,
orderDirection,
environmentSlugs: environments,
path: secretPath,
limit: remainingLimit,
offset: adjustedOffset
});
// get the count of unique dynamic secret names to properly adjust remaining limit
const uniqueDynamicSecretsCount = new Set(dynamicSecrets.map((dynamicSecret) => dynamicSecret.name)).size;
remainingLimit -= uniqueDynamicSecretsCount;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
if (includeSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environments,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
search
});
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
secrets = await server.services.secret.getSecretsRawMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environments,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
for await (const environment of environments) {
const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length;
if (secretCountFromEnv) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCountFromEnv
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCountFromEnv,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
}
}
return {
folders,
dynamicSecrets,
secrets,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
};
}
});
server.route({
method: "GET",
url: "/secrets-details",
config: {
rateLimit: secretsLimit
},
schema: {
description: "List project secrets details",
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.projectId),
environment: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.environment),
secretPath: z
.string()
.trim()
.default("/")
.transform(removeTrailingSlash)
.describe(DASHBOARD.SECRET_DETAILS_LIST.secretPath),
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_DETAILS_LIST.offset),
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_DETAILS_LIST.limit),
orderBy: z
.nativeEnum(SecretsOrderBy)
.default(SecretsOrderBy.Name)
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderDirection)
.optional(),
search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(),
tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(),
includeSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets),
includeFolders: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders),
includeDynamicSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets),
includeImports: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports)
}),
response: {
200: z.object({
imports: SecretImportsSchema.omit({ importEnv: true })
.extend({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
.array()
.optional(),
folders: SecretFoldersSchema.array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(),
secrets: secretRawSchema
.extend({
secretPath: z.string().optional(),
tags: SecretTagsSchema.pick({
id: true,
slug: true,
color: true
})
.extend({ name: z.string() })
.array()
.optional()
})
.array()
.optional(),
totalImportCount: z.number().optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const {
secretPath,
environment,
projectId,
limit,
offset,
search,
orderBy,
orderDirection,
includeFolders,
includeSecrets,
includeDynamicSecrets,
includeImports
} = req.query;
if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" });
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
// prevent older projects from accessing endpoint
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
const tags = req.query.tags?.split(",") ?? [];
let remainingLimit = limit;
let adjustedOffset = offset;
let imports: Awaited<ReturnType<typeof server.services.secretImport.getImports>> | undefined;
let folders: Awaited<ReturnType<typeof server.services.folder.getFolders>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRaw>>["secrets"] | undefined;
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnv>> | undefined;
let totalImportCount: number | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
search
});
if (remainingLimit > 0 && totalImportCount > adjustedOffset) {
imports = await server.services.secretImport.getImports({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
search,
limit: remainingLimit,
offset: adjustedOffset
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.GET_SECRET_IMPORTS,
metadata: {
environment,
folderId: imports?.[0]?.folderId,
numberOfImports: imports.length
}
}
});
remainingLimit -= imports.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalImportCount);
}
}
if (includeFolders) {
totalFolderCount = await server.services.folder.getProjectFolderCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
path: secretPath,
environments: [environment],
search
});
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
folders = await server.services.folder.getFolders({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
remainingLimit -= folders.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
}
}
if (includeDynamicSecrets) {
totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
environmentSlug: environment,
path: secretPath
});
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnv({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
orderBy,
orderDirection,
environmentSlug: environment,
path: secretPath,
limit: remainingLimit,
offset: adjustedOffset
});
remainingLimit -= dynamicSecrets.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
if (includeSecrets) {
totalSecretCount = await server.services.secret.getSecretsCount({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environment,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
search,
tagSlugs: tags
});
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
const secretsRaw = await server.services.secret.getSecretsRaw({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environment,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset,
tagSlugs: tags
});
secrets = secretsRaw.secrets;
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secrets.length
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secrets.length,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
return {
imports,
folders,
dynamicSecrets,
secrets,
totalImportCount,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
totalCount:
(totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
};
}
});
};
+2
View File
@@ -1,3 +1,4 @@
import { registerDashboardRouter } from "./dashboard-router";
import { registerGroupProjectRouter } from "./group-project-router";
import { registerLoginRouter } from "./login-router";
import { registerSecretBlindIndexRouter } from "./secret-blind-index-router";
@@ -17,4 +18,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
},
{ prefix: "/workspaces" }
);
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
};
+1
View File
@@ -34,6 +34,7 @@ export enum AuthMode {
}
export enum ActorType { // would extend to AWS, Azure, ...
PLATFORM = "platform", // Useful for when we want to perform logging on automated actions such as integration syncs.
USER = "user", // userIdentity
SERVICE = "service",
IDENTITY = "identity",
@@ -157,7 +157,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
`${TableName.ProjectRoles}.id`
)
.select(
db.ref("id").withSchema(TableName.GroupProjectMembership),
db.ref("id").withSchema(TableName.UserGroupMembership),
db.ref("isGhost").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("email").withSchema(TableName.Users),
@@ -18,7 +18,7 @@ import {
infisicalSymmetricDecrypt,
infisicalSymmetricEncypt
} from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -68,12 +68,12 @@ export const identityOidcAuthServiceFactory = ({
identityId: identityOidcAuth.identityId
});
if (!identityMembershipOrg) {
throw new BadRequestError({ message: "Failed to find identity" });
throw new NotFoundError({ message: "Failed to find identity in organization" });
}
const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId });
if (!orgBot) {
throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" });
throw new NotFoundError({ message: "Org bot not found", name: "OrgBotNotFound" });
}
const key = infisicalSymmetricDecrypt({
@@ -106,7 +106,7 @@ export const identityOidcAuthServiceFactory = ({
const decodedToken = jwt.decode(oidcJwt, { complete: true });
if (!decodedToken) {
throw new BadRequestError({
throw new UnauthorizedError({
message: "Invalid JWT"
});
}
@@ -119,13 +119,24 @@ export const identityOidcAuthServiceFactory = ({
const { kid } = decodedToken.header;
const oidcSigningKey = await client.getSigningKey(kid);
const tokenData = jwt.verify(oidcJwt, oidcSigningKey.getPublicKey(), {
issuer: identityOidcAuth.boundIssuer
}) as Record<string, string>;
let tokenData: Record<string, string>;
try {
tokenData = jwt.verify(oidcJwt, oidcSigningKey.getPublicKey(), {
issuer: identityOidcAuth.boundIssuer
}) as Record<string, string>;
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
throw new UnauthorizedError({
message: `Access denied: ${error.message}`
});
}
throw error;
}
if (identityOidcAuth.boundSubject) {
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
throw new ForbiddenRequestError({
throw new UnauthorizedError({
message: "Access denied: OIDC subject not allowed."
});
}
@@ -137,7 +148,7 @@ export const identityOidcAuthServiceFactory = ({
.split(", ")
.some((policyValue) => doesFieldValueMatchOidcPolicy(tokenData.aud, policyValue))
) {
throw new ForbiddenRequestError({
throw new UnauthorizedError({
message: "Access denied: OIDC audience not allowed."
});
}
@@ -150,7 +161,7 @@ export const identityOidcAuthServiceFactory = ({
if (
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(tokenData[claimKey], claimEntry))
) {
throw new ForbiddenRequestError({
throw new UnauthorizedError({
message: "Access denied: OIDC claim not allowed."
});
}
@@ -58,7 +58,8 @@ export const identityServiceFactory = ({
if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged identity" });
const plan = await licenseService.getPlan(orgId);
if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
message: "Failed to create identity due to identity limit reached. Upgrade plan to create more identities."
@@ -242,37 +242,12 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => {
};
}
const octokit = new Octokit({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const repos = (await new Octokit({
auth: accessToken
});
const getAllRepos = async () => {
let repos: GitHubApp[] = [];
let page = 1;
const perPage = 100;
let hasMore = true;
while (hasMore) {
const response = await octokit.request(
"GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}",
{
per_page: perPage,
page
}
);
if ((response.data as GitHubApp[]).length > 0) {
repos = repos.concat(response.data as GitHubApp[]);
page += 1;
} else {
hasMore = false;
}
}
return repos;
};
const repos = await getAllRepos();
}).paginate("GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}", {
per_page: 100
})) as GitHubApp[];
const apps = repos
.filter((a: GitHubApp) => a.permissions.admin === true)
@@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
@@ -19,6 +19,7 @@ import { TIntegrationDALFactory } from "./integration-dal";
import {
TCreateIntegrationDTO,
TDeleteIntegrationDTO,
TGetIntegrationDTO,
TSyncIntegrationDTO,
TUpdateIntegrationDTO
} from "./integration-types";
@@ -180,6 +181,27 @@ export const integrationServiceFactory = ({
return updatedIntegration;
};
const getIntegration = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => {
const integration = await integrationDAL.findById(id);
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
integration?.projectId || "",
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
if (!integration) {
throw new NotFoundError({
message: "Integration not found"
});
}
return { ...integration, envId: integration.environment.id };
};
const deleteIntegration = async ({
actorId,
id,
@@ -276,6 +298,8 @@ export const integrationServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
await secretQueueService.syncIntegrations({
isManual: true,
actorId,
environment: integration.environment.slug,
secretPath: integration.secretPath,
projectId: integration.projectId
@@ -289,6 +313,7 @@ export const integrationServiceFactory = ({
updateIntegration,
deleteIntegration,
listIntegrationByProject,
getIntegration,
syncIntegration
};
};
@@ -39,6 +39,10 @@ export type TCreateIntegrationDTO = {
};
} & Omit<TProjectPermission, "projectId">;
export type TGetIntegrationDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateIntegrationDTO = {
id: string;
app?: string;
+6 -6
View File
@@ -60,7 +60,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "Admin",
slug: "admin",
description: "Complete administration access over the organization",
permissions: packRules(orgAdminPermissions.rules),
permissions: packRules(orgAdminPermissions),
createdAt: new Date(),
updatedAt: new Date()
};
@@ -72,7 +72,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "Member",
slug: "member",
description: "Non-administrative role in an organization",
permissions: packRules(orgMemberPermissions.rules),
permissions: packRules(orgMemberPermissions),
createdAt: new Date(),
updatedAt: new Date()
};
@@ -84,7 +84,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "No Access",
slug: "no-access",
description: "No access to any resources in the organization",
permissions: packRules(orgNoAccessPermissions.rules),
permissions: packRules(orgNoAccessPermissions),
createdAt: new Date(),
updatedAt: new Date()
};
@@ -151,7 +151,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "Admin",
slug: "admin",
description: "Complete administration access over the organization",
permissions: packRules(orgAdminPermissions.rules),
permissions: packRules(orgAdminPermissions),
createdAt: new Date(),
updatedAt: new Date()
},
@@ -161,7 +161,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "Member",
slug: "member",
description: "Non-administrative role in an organization",
permissions: packRules(orgMemberPermissions.rules),
permissions: packRules(orgMemberPermissions),
createdAt: new Date(),
updatedAt: new Date()
},
@@ -171,7 +171,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
name: "No Access",
slug: "no-access",
description: "No access to any resources in the organization",
permissions: packRules(orgNoAccessPermissions.rules),
permissions: packRules(orgNoAccessPermissions),
createdAt: new Date(),
updatedAt: new Date()
},
+24 -29
View File
@@ -17,7 +17,6 @@ import {
} from "@app/db/schemas";
import { TProjects } from "@app/db/schemas/projects";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -90,7 +89,6 @@ type TOrgServiceFactoryDep = {
>;
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
};
@@ -116,7 +114,6 @@ export const orgServiceFactory = ({
licenseService,
projectRoleDAL,
samlConfigDAL,
userGroupMembershipDAL,
projectBotDAL,
projectUserMembershipRoleDAL
}: TOrgServiceFactoryDep) => {
@@ -461,12 +458,6 @@ export const orgServiceFactory = ({
const org = await orgDAL.findOrgById(orgId);
if (org?.authEnforced) {
throw new BadRequestError({
message: "Failed to invite user due to org-level auth enforced for organization"
});
}
const isEmailInvalid = await isDisposableEmail(inviteeEmails);
if (isEmailInvalid) {
throw new BadRequestError({
@@ -475,19 +466,6 @@ export const orgServiceFactory = ({
});
}
const plan = await licenseService.getPlan(orgId);
if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) {
// limit imposed on number of members allowed / number of members used exceeds the number of members allowed
throw new BadRequestError({
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole);
if (isCustomOrgRole) {
if (!plan?.rbac)
@@ -572,7 +550,7 @@ export const orgServiceFactory = ({
);
}
const [inviteeMembership] = await orgDAL.findMembership(
const [inviteeOrgMembership] = await orgDAL.findMembership(
{
[`${TableName.OrgMembership}.orgId` as "orgId"]: orgId,
[`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId
@@ -581,7 +559,27 @@ export const orgServiceFactory = ({
);
// if there exist no org membership we set is as given by the request
if (!inviteeMembership) {
if (!inviteeOrgMembership) {
if (plan?.slug !== "enterprise" && plan?.memberLimit && plan.membersUsed >= plan.memberLimit) {
// limit imposed on number of members allowed / number of members used exceeds the number of members allowed
throw new BadRequestError({
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
if (org?.authEnforced) {
throw new BadRequestError({
message: "Failed to invite user due to org-level auth enforced for organization"
});
}
// as its used by project invite also
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
let roleId;
@@ -617,7 +615,6 @@ export const orgServiceFactory = ({
}
const userIds = users.map(({ id }) => id);
const usernames = users.map((el) => el.username);
const userEncryptionKeys = await userDAL.findUserEncKeyByUserIdsBatch({ userIds }, tx);
// we don't need to spam with email. Thus org invitation doesn't need project invitation again
const userIdsWithOrgInvitation = new Set(mailsForOrgInvitation.map((el) => el.userId));
@@ -644,12 +641,10 @@ export const orgServiceFactory = ({
{ tx }
);
const existingMembersGroupByUserId = groupBy(existingMembers, (i) => i.userId);
const userIdsToExcludeAsPartOfGroup = new Set(
await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernames, projectId, tx)
);
const userWithEncryptionKeyInvitedToProject = userEncryptionKeys.filter(
(user) => !existingMembersGroupByUserId?.[user.userId] && !userIdsToExcludeAsPartOfGroup.has(user.userId)
(user) => !existingMembersGroupByUserId?.[user.userId]
);
// eslint-disable-next-line no-continue
if (!userWithEncryptionKeyInvitedToProject.length) continue;
@@ -26,7 +26,10 @@ export const getBotKeyFnFactory = (
) => {
const getBotKeyFn = async (projectId: string) => {
const project = await projectDAL.findById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." });
if (!project)
throw new BadRequestError({
message: "Project not found during bot lookup. Are you sure you are using the correct project ID?"
});
if (project.version === 3) {
return { project, shouldUseSecretV2Bridge: true };
@@ -90,15 +90,20 @@ export const projectMembershipServiceFactory = ({
// projectMembers[0].project
if (includeGroupMembers) {
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
const allMembers = [
...projectMembers.map((m) => ({ ...m, isGroupMember: false })),
...groupMembers.map((m) => ({ ...m, isGroupMember: true }))
];
// Ensure the userId is unique
const membersIds = new Set(allMembers.map((entity) => entity.user.id));
const uniqueMembers = allMembers.filter((entity) => membersIds.has(entity.user.id));
const uniqueMembers: typeof allMembers = [];
const addedUserIds = new Set<string>();
allMembers.forEach((member) => {
if (!addedUserIds.has(member.user.id)) {
uniqueMembers.push(member);
addedUserIds.add(member.user.id);
}
});
return uniqueMembers;
}
@@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate }
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { groupBy, removeTrailingSlash } from "@app/lib/fn";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/;
@@ -83,7 +85,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str
.from<TSecretFolders & { depth: number; path: string }>("parent");
};
const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: string, secretPath: string) => {
const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: string[], secretPath: string) => {
// this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2
const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath;
// next goal to sanitize saw the raw sql query is safe
@@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
projectId,
parentId: null
})
.where(`${TableName.Environment}.slug`, environment)
.whereIn(`${TableName.Environment}.slug`, environments)
.select(selectAllTableCols(TableName.SecretFolder))
.union(
(qb) =>
@@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
.from<TSecretFolders & { depth: number; path: string }>("parent")
.leftJoin<TProjectEnvironments>(TableName.Environment, `${TableName.Environment}.id`, "parent.envId")
.select<
TSecretFolders & {
(TSecretFolders & {
depth: number;
path: string;
envId: string;
envSlug: string;
envName: string;
projectId: string;
}
})[]
>(
selectAllTableCols("parent" as TableName.SecretFolder),
db.ref("id").withSchema(TableName.Environment).as("envId"),
@@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environment,
[environment],
removeTrailingSlash(path)
)
.orderBy("depth", "desc")
@@ -230,6 +232,35 @@ export const secretFolderDALFactory = (db: TDbClient) => {
}
};
// finds folders by path for multiple envs
const findBySecretPathMultiEnv = async (projectId: string, environments: string[], path: string, tx?: Knex) => {
try {
const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1;
const folders = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environments,
removeTrailingSlash(path)
)
.orderBy("depth", "desc")
.where("depth", pathDepth);
const firstFolder = folders[0];
if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) {
return [];
}
return folders.map((folder) => {
const { envId: id, envName: name, envSlug: slug, ...el } = folder;
return { ...el, envId: id, environment: { id, name, slug } };
});
} catch (error) {
throw new DatabaseError({ error, name: "Find folders by secret path multi env" });
}
};
// used in folder creation
// even if its the original given /path1/path2
// it will stop automatically at /path2
@@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environment,
[environment],
removeTrailingSlash(path)
)
.orderBy("depth", "desc")
@@ -352,14 +383,77 @@ export const secretFolderDALFactory = (db: TDbClient) => {
}
};
// find project folders for multiple envs
const findByMultiEnv = async (
{
environmentIds,
parentIds,
search,
limit,
offset = 0,
orderBy = SecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
environmentIds: string[];
parentIds: string[];
search?: string;
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.SecretFolder)
.whereIn("parentId", parentIds)
.whereIn("envId", environmentIds)
.where("isReserved", false)
.where((bd) => {
if (search) {
void bd.whereILike(`${TableName.SecretFolder}.name`, `%${search}%`);
}
})
.leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`)
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw(
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${
orderDirection ?? OrderByDirection.ASC
}) as rank`
),
db.ref("slug").withSchema(TableName.Environment).as("environment")
)
.orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection);
if (limit) {
const rankOffset = offset + 1; // ranks start from 1
return await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
}
const folders = await query;
return folders;
} catch (error) {
throw new DatabaseError({ error, name: "Find folders multi env" });
}
};
return {
...secretFolderOrm,
update,
findBySecretPath,
findBySecretPathMultiEnv,
findById,
findByManySecretPath,
findSecretPathByFolderIds,
findClosestFolder,
findByProjectId
findByProjectId,
findByMultiEnv
};
};
@@ -7,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderVersionDAL: TSecretFolderVersionDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
};
@@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({
actorOrgId,
actorAuthMethod,
environment,
path: secretPath
path: secretPath,
search,
orderBy,
orderDirection,
limit,
offset
}: TGetFolderDTO) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
@@ -408,11 +414,92 @@ export const secretFolderServiceFactory = ({
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!parentFolder) return [];
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false });
const folders = await folderDAL.find(
{
envId: env.id,
parentId: parentFolder.id,
isReserved: false,
$search: search ? { name: `%${search}%` } : undefined
},
{
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined,
limit,
offset
}
);
return folders;
};
// get folders for multiple envs
const getFoldersMultiEnv = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
path: secretPath,
...params
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
if (!envs.length)
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
if (!parentFolders.length) return [];
const folders = await folderDAL.findByMultiEnv({
environmentIds: envs.map((env) => env.id),
parentIds: parentFolders.map((folder) => folder.id),
...params
});
return folders;
};
// get the unique count of folders within a project path
const getProjectFolderCount = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
path: secretPath,
search
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
if (!envs.length)
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
if (!parentFolders.length) return 0;
const folders = await folderDAL.find(
{
$in: {
envId: envs.map((env) => env.id),
parentId: parentFolders.map((folder) => folder.id)
},
isReserved: false,
$search: search ? { name: `%${search}%` } : undefined
},
{ countDistinct: "name" }
);
return Number(folders[0]?.count ?? 0);
};
const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => {
const folder = await folderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: "folder not found" });
@@ -429,6 +516,8 @@ export const secretFolderServiceFactory = ({
updateManyFolders,
deleteFolder,
getFolders,
getFolderById
getFolderById,
getProjectFolderCount,
getFoldersMultiEnv
};
};
@@ -1,4 +1,5 @@
import { TProjectPermission } from "@app/lib/types";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export enum ReservedFolders {
SecretReplication = "__reserve_replication_"
@@ -36,6 +37,11 @@ export type TDeleteFolderDTO = {
export type TGetFolderDTO = {
environment: string;
path: string;
search?: string;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetFolderByIdDTO = {
@@ -49,10 +49,30 @@ export const secretImportDALFactory = (db: TDbClient) => {
}
};
const find = async (filter: Partial<TSecretImports & { projectId: string }>, tx?: Knex) => {
const find = async (
{
search,
limit,
offset,
...filter
}: Partial<
TSecretImports & {
projectId: string;
search?: string;
limit?: number;
offset?: number;
}
>,
tx?: Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
const query = (tx || db.replicaNode())(TableName.SecretImport)
.where(filter)
.where((bd) => {
if (search) {
void bd.whereILike("importPath", `%${search}%`);
}
})
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.select(
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
@@ -61,6 +81,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.Environment).as("envId")
)
.orderBy("position", "asc");
if (limit) {
void query.limit(limit).offset(offset ?? 0);
}
const docs = await query;
return docs.map(({ envId, slug, name, ...el }) => ({
...el,
importEnv: { id: envId, slug, name }
@@ -70,6 +97,28 @@ export const secretImportDALFactory = (db: TDbClient) => {
}
};
const getProjectImportCount = async (
{ search, ...filter }: Partial<TSecretImports & { projectId: string; search?: string }>,
tx?: Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
.where(filter)
.where("isReplication", false)
.where((bd) => {
if (search) {
void bd.whereILike("importPath", `%${search}%`);
}
})
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.count();
return Number(docs[0]?.count ?? 0);
} catch (error) {
throw new DatabaseError({ error, name: "get secret imports count" });
}
};
const findByFolderIds = async (folderIds: string[], tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
@@ -97,6 +146,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
find,
findByFolderIds,
findLastImportPosition,
updateAllPosition
updateAllPosition,
getProjectImportCount
};
};
@@ -220,7 +220,7 @@ export const fnSecretsV2FromImports = async ({
const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId);
const processedImports = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => {
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0];
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0];
const folderDeeperImportSecrets =
secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || [];
const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || [])
@@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service";
import { BadRequestError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
@@ -394,6 +394,36 @@ export const secretImportServiceFactory = ({
return { message: "replication started" };
};
const getProjectImportCount = async ({
path: secretPath,
environment,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
search
}: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new NotFoundError({ message: "Folder not found", name: "Get imports" });
const count = await secretImportDAL.getProjectImportCount({ folderId: folder.id, search });
return count;
};
const getImports = async ({
path: secretPath,
environment,
@@ -401,7 +431,10 @@ export const secretImportServiceFactory = ({
actor,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
search,
limit,
offset
}: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -418,7 +451,7 @@ export const secretImportServiceFactory = ({
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
const secImports = await secretImportDAL.find({ folderId: folder.id });
const secImports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset });
return secImports;
};
@@ -512,7 +545,11 @@ export const secretImportServiceFactory = ({
return importedSecrets;
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const importedSecrets = await fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL });
return importedSecrets.map((el) => ({
@@ -531,6 +568,7 @@ export const secretImportServiceFactory = ({
getSecretsFromImports,
getRawSecretsFromImports,
resyncSecretImportReplication,
getProjectImportCount,
fnSecretsFromImports
};
};
@@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = {
export type TGetSecretImportsDTO = {
environment: string;
path: string;
search?: string;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetSecretsFromImportDTO = {
@@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>;
@@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
}
};
const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => {
// get unique secret count by folder IDs
const countByFolderIds = async (
folderIds: string[],
userId?: string,
tx?: Knex,
filters?: {
search?: string;
tagSlugs?: string[];
}
) => {
try {
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
if (userId && !uuidValidate(userId)) {
@@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
userId = undefined;
}
const secs = await (tx || db.replicaNode())(TableName.SecretV2)
const query = (tx || db.replicaNode())(TableName.SecretV2)
.whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
void bd.whereILike("key", `%${filters?.search}%`);
}
})
.where((bd) => {
void bd.whereNull("userId").orWhere({ userId: userId || null });
})
.countDistinct("key");
// only need to join tags if filtering by tag slugs
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
void query
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.whereIn("slug", slugs);
}
const secrets = await query;
return Number(secrets[0]?.count ?? 0);
} catch (error) {
throw new DatabaseError({ error, name: "get folder secret count" });
}
};
const findByFolderIds = async (
folderIds: string[],
userId?: string,
tx?: Knex,
filters?: {
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
tagSlugs?: string[];
}
) => {
try {
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
if (userId && !uuidValidate(userId)) {
// eslint-disable-next-line no-param-reassign
userId = undefined;
}
const query = (tx || db.replicaNode())(TableName.SecretV2)
.whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
void bd.whereILike("key", `%${filters?.search}%`);
}
})
.where((bd) => {
void bd.whereNull("userId").orWhere({ userId: userId || null });
})
@@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.SecretV2))
.select(
selectAllTableCols(TableName.SecretV2),
db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`)
)
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.orderBy("id", "asc");
.where((bd) => {
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
void bd.whereIn("slug", slugs);
}
})
.orderBy(
filters?.orderBy === SecretsOrderBy.Name ? "key" : "id",
filters?.orderDirection ?? OrderByDirection.ASC
);
let secs: Awaited<typeof query>;
if (filters?.limit) {
const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1
secs = await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + filters.limit);
} else {
secs = await query;
}
const data = sqlNestRelationships({
data: secs,
@@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
findBySecretKeys,
upsertSecretReferences,
findReferencedSecretReferences,
findAllProjectSecretValues
findAllProjectSecretValues,
countByFolderIds
};
};
@@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
folderDAL: Pick<
TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find"
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv"
>;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
@@ -431,6 +431,165 @@ export const secretV2BridgeServiceFactory = ({
});
};
// get unique secrets count for multiple envs
const getSecretsCountMultiEnv = async ({
actorId,
path,
projectId,
actor,
actorOrgId,
actorAuthMethod,
environments,
...params
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
environments: string[];
}) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// verify user has access to all environments
environments.forEach((environment) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
if (!folders.length) return 0;
const count = await secretDAL.countByFolderIds(
folders.map((folder) => folder.id),
actorId,
undefined,
params
);
return count;
};
// get secret count for individual env
const getSecretsCount = async ({
actorId,
path,
environment,
projectId,
actor,
actorOrgId,
actorAuthMethod,
...params
}: Pick<
TGetSecretsDTO,
| "actorId"
| "actor"
| "path"
| "projectId"
| "actorOrgId"
| "actorAuthMethod"
| "tagSlugs"
| "environment"
| "search"
>) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return 0;
const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params);
return count;
};
// get secrets for multiple envs
const getSecretsMultiEnv = async ({
actorId,
path,
environments,
projectId,
actor,
actorOrgId,
actorAuthMethod,
...params
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
environments: string[];
}) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
let paths: { folderId: string; path: string; environment: string }[] = [];
// verify user has access to all environments
environments.forEach((environment) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
if (!folders.length) {
return [];
}
paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug }));
const groupedPaths = groupBy(paths, (p) => p.folderId);
const secrets = await secretDAL.findByFolderIds(
paths.map((p) => p.folderId),
actorId,
undefined,
params
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedSecrets = secrets.map((secret) =>
reshapeBridgeSecret(
projectId,
groupedPaths[secret.folderId][0].environment,
groupedPaths[secret.folderId][0].path,
{
...secret,
value: secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
: "",
comment: secret.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
: ""
}
)
);
return decryptedSecrets;
};
const getSecrets = async ({
actorId,
path,
@@ -441,8 +600,8 @@ export const secretV2BridgeServiceFactory = ({
actorAuthMethod,
includeImports,
recursive,
tagSlugs = [],
expandSecretReferences: shouldExpandSecretReferences
expandSecretReferences: shouldExpandSecretReferences,
...params
}: TGetSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -490,7 +649,9 @@ export const secretV2BridgeServiceFactory = ({
const secrets = await secretDAL.findByFolderIds(
paths.map((p) => p.folderId),
actorId
actorId,
undefined,
params
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -509,9 +670,7 @@ export const secretV2BridgeServiceFactory = ({
: ""
})
);
const filteredSecrets = tagSlugs.length
? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug))))
: decryptedSecrets;
const expandSecretReferences = expandSecretReferencesFactory({
projectId,
folderDAL,
@@ -520,7 +679,7 @@ export const secretV2BridgeServiceFactory = ({
});
if (shouldExpandSecretReferences) {
const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath);
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
await Promise.allSettled(
Object.keys(secretsGroupByPath).map((groupedPath) =>
Promise.allSettled(
@@ -541,7 +700,7 @@ export const secretV2BridgeServiceFactory = ({
if (!includeImports) {
return {
secrets: filteredSecrets
secrets: decryptedSecrets
};
}
@@ -569,7 +728,7 @@ export const secretV2BridgeServiceFactory = ({
});
return {
secrets: filteredSecrets,
secrets: decryptedSecrets,
imports: importedSecrets
};
};
@@ -1416,6 +1575,9 @@ export const secretV2BridgeServiceFactory = ({
getSecrets,
getSecretVersions,
backfillSecretReferences,
moveSecrets
moveSecrets,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsMultiEnv
};
};
@@ -1,8 +1,9 @@
import { Knex } from "knex";
import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
@@ -21,6 +22,11 @@ export type TGetSecretsDTO = {
includeImports?: boolean;
recursive?: boolean;
tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission;
export type TGetASecretDTO = {
+10 -2
View File
@@ -832,7 +832,11 @@ export const createManySecretsRawFnFactory = ({
secretDAL
});
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const inputSecrets = secrets.map((secret) => {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey);
@@ -993,7 +997,11 @@ export const updateManySecretsRawFnFactory = ({
return updatedSecrets;
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId });
if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
+399 -246
View File
@@ -1,7 +1,15 @@
/* eslint-disable no-await-in-loop */
import { AxiosError } from "axios";
import { ProjectUpgradeStatus, ProjectVersion, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas";
import {
ProjectMembershipRole,
ProjectUpgradeStatus,
ProjectVersion,
TSecretSnapshotSecretsV2,
TSecretVersionsV2
} from "@app/db/schemas";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { Actor, EventType } from "@app/ee/services/audit-log/audit-log-types";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
@@ -21,6 +29,7 @@ import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { ActorType } from "../auth/auth-type";
import { TIntegrationDALFactory } from "../integration/integration-dal";
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service";
@@ -40,13 +49,16 @@ import { expandSecretReferencesFactory, getAllNestedSecretReferences } from "../
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TWebhookDALFactory } from "../webhook/webhook-dal";
import { fnTriggerWebhook } from "../webhook/webhook-fns";
import { TSecretDALFactory } from "./secret-dal";
import { interpolateSecrets } from "./secret-fns";
import {
TCreateSecretReminderDTO,
TFailedIntegrationSyncEmailsPayload,
THandleReminderDTO,
TIntegrationSyncPayload,
TRemoveSecretReminderDTO,
TSyncSecretsDTO
} from "./secret-types";
@@ -71,6 +83,7 @@ type TSecretQueueFactoryDep = {
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
userDAL: Pick<TUserDALFactory, "findById">;
secretVersionTagDAL: TSecretVersionTagDALFactory;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
secretV2BridgeDAL: TSecretV2BridgeDALFactory;
@@ -81,6 +94,7 @@ type TSecretQueueFactoryDep = {
snapshotDAL: Pick<TSnapshotDALFactory, "findNSecretV1SnapshotByFolderId" | "deleteSnapshotsAboveLimit">;
snapshotSecretV2BridgeDAL: Pick<TSnapshotSecretV2DALFactory, "insertMany" | "batchInsert">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export type TGetSecrets = {
@@ -106,6 +120,7 @@ export const secretQueueFactory = ({
secretDAL,
secretImportDAL,
folderDAL,
userDAL,
webhookDAL,
projectEnvDAL,
orgDAL,
@@ -125,7 +140,8 @@ export const secretQueueFactory = ({
snapshotDAL,
snapshotSecretV2BridgeDAL,
secretApprovalRequestDAL,
keyStore
keyStore,
auditLogService
}: TSecretQueueFactoryDep) => {
const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => {
const appCfg = getConfig();
@@ -430,7 +446,9 @@ export const secretQueueFactory = ({
return content;
};
const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record<string, boolean> }) => {
const syncIntegrations = async (
dto: TGetSecrets & { isManual?: boolean; actorId?: string; deDupeQueue?: Record<string, boolean> }
) => {
await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, {
attempts: 3,
delay: 1000,
@@ -499,6 +517,19 @@ export const secretQueueFactory = ({
);
};
const sendFailedIntegrationSyncEmails = async (payload: TFailedIntegrationSyncEmailsPayload) => {
const appCfg = getConfig();
if (!appCfg.isSmtpConfigured) return;
await queueService.queue(QueueName.IntegrationSync, QueueJobs.SendFailedIntegrationSyncEmails, payload, {
jobId: `send-failed-integration-sync-emails-${payload.projectId}-${payload.secretPath}-${payload.environmentSlug}`,
delay: 1_000 * 60, // 1 minute
removeOnFail: true,
removeOnComplete: true
});
};
queueService.start(QueueName.SecretSync, async (job) => {
const {
_deDupeQueue: deDupeQueue,
@@ -528,7 +559,7 @@ export const secretQueueFactory = ({
}
}
);
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue });
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
if (!excludeReplication) {
await replicateSecrets({
_deDupeReplicationQueue: deDupeReplicationQueue,
@@ -544,274 +575,396 @@ export const secretQueueFactory = ({
});
queueService.start(QueueName.IntegrationSync, async (job) => {
const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data;
if (depth > MAX_SYNC_SECRET_DEPTH) return;
if (job.name === QueueJobs.SendFailedIntegrationSyncEmails) {
const appCfg = getConfig();
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) {
throw new Error("Secret path not found");
const jobPayload = job.data as TFailedIntegrationSyncEmailsPayload;
const projectMembers = await projectMembershipDAL.findAllProjectMembers(jobPayload.projectId);
const project = await projectDAL.findById(jobPayload.projectId);
// Only send emails to admins, and if its a manual trigger, only send it to the person who triggered it (if actor is admin as well)
const filteredProjectMembers = projectMembers
.filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin))
.filter((member) =>
jobPayload.manuallyTriggeredByUserId ? member.userId === jobPayload.manuallyTriggeredByUserId : true
);
await smtpService.sendMail({
recipients: filteredProjectMembers.map((member) => member.user.email!),
template: SmtpTemplates.IntegrationSyncFailed,
subjectLine: `Integration Sync Failed`,
substitutions: {
syncMessage: jobPayload.count === 1 ? jobPayload.syncMessage : undefined, // We are only displaying the sync message if its a singular integration, so we can just grab the first one in the array.
secretPath: jobPayload.secretPath,
environment: jobPayload.environmentName,
count: jobPayload.count,
projectName: project.name,
integrationUrl: `${appCfg.SITE_URL}/integrations/${project.id}`
}
});
}
// find all imports made with the given environment and secret path
const linkSourceDto = {
projectId,
importEnv: folder.environment.id,
importPath: secretPath,
isReplication: false
};
const imports = await secretImportDAL.find(linkSourceDto);
if (imports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
imports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: foldersGroupedById[folderId][0]?.path as string,
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
let referencedFolderIds;
if (shouldUseSecretV2Bridge) {
const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences(
if (job.name === QueueJobs.IntegrationSync) {
const {
environment,
actorId,
isManual,
projectId,
folder.environment.slug,
secretPath
);
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
} else {
const secretReferences = await secretDAL.findReferencedSecretReferences(
projectId,
folder.environment.slug,
secretPath
);
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
}
if (referencedFolderIds.length) {
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
referencedFolderIds
.filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
// filter out already synced ones
.filter(
(folderId) =>
!deDupeQueue[
uniqueSecretQueueKey(
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
referencedFoldersGroupedById[folderId][0]?.path as string
)
]
)
.map((folderId) =>
syncSecrets({
projectId,
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
secretPath,
depth = 1,
deDupeQueue = {}
} = job.data as TIntegrationSyncPayload;
if (depth > MAX_SYNC_SECRET_DEPTH) return;
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment
const toBeSyncedIntegrations = integrations.filter(
// note: sync only the integrations sourced from secretPath
({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath)
);
if (!integrations.length) return;
logger.info(
`getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]`
);
const lock = await keyStore.acquireLock(
[KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)],
10000,
{
retryCount: 3,
retryDelay: 2000
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) {
throw new Error("Secret path not found");
}
);
const lockAcquiredTime = new Date();
const lastRunSyncIntegrationTimestamp = await keyStore.getItem(
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath)
);
// find all imports made with the given environment and secret path
const linkSourceDto = {
projectId,
importEnv: folder.environment.id,
importPath: secretPath,
isReplication: false
};
const imports = await secretImportDAL.find(linkSourceDto);
// check whether the integration should wait or not
if (lastRunSyncIntegrationTimestamp) {
const INTEGRATION_INTERVAL = 2000;
const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp);
if (isStaleSyncIntegration) {
if (imports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]`
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
imports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: foldersGroupedById[folderId][0]?.path as string,
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
let referencedFolderIds;
if (shouldUseSecretV2Bridge) {
const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences(
projectId,
folder.environment.slug,
secretPath
);
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
} else {
const secretReferences = await secretDAL.findReferencedSecretReferences(
projectId,
folder.environment.slug,
secretPath
);
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
}
if (referencedFolderIds.length) {
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
referencedFolderIds
.filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
// filter out already synced ones
.filter(
(folderId) =>
!deDupeQueue[
uniqueSecretQueueKey(
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
referencedFoldersGroupedById[folderId][0]?.path as string
)
]
)
.map((folderId) =>
syncSecrets({
projectId,
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
return;
}
const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds(
lockAcquiredTime.toISOString(),
lastRunSyncIntegrationTimestamp
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment
const toBeSyncedIntegrations = integrations.filter(
// note: sync only the integrations sourced from secretPath
({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath)
);
if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0)
await new Promise((resolve) => {
setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000);
});
}
// akhilmhdh: this try catch is for lock release
try {
const secrets = shouldUseSecretV2Bridge
? await getIntegrationSecretsV2({
environment,
projectId,
folderId: folder.id,
depth: 1,
secretPath,
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
})
: await getIntegrationSecrets({
environment,
projectId,
folderId: folder.id,
key: botKey as string,
depth: 1,
secretPath
});
const integrationsFailedToSync: { integrationId: string; syncMessage?: string }[] = [];
for (const integration of toBeSyncedIntegrations) {
const integrationAuth = {
...integration.integrationAuth,
createdAt: new Date(),
updatedAt: new Date(),
projectId: integration.projectId
};
if (!integrations.length) return;
logger.info(
`getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(
integrationAuth,
shouldUseSecretV2Bridge,
botKey
);
let awsAssumeRoleArn = null;
if (shouldUseSecretV2Bridge) {
if (integrationAuth.encryptedAwsAssumeIamRoleArn) {
awsAssumeRoleArn = secretManagerDecryptor({
cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn)
}).toString();
}
} else if (
integrationAuth.awsAssumeIamRoleArnTag &&
integrationAuth.awsAssumeIamRoleArnIV &&
integrationAuth.awsAssumeIamRoleArnCipherText
) {
awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({
ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText,
iv: integrationAuth.awsAssumeIamRoleArnIV,
tag: integrationAuth.awsAssumeIamRoleArnTag,
key: botKey as string
});
const lock = await keyStore.acquireLock(
[KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)],
10000,
{
retryCount: 3,
retryDelay: 2000
}
);
const lockAcquiredTime = new Date();
const suffixedSecrets: typeof secrets = {};
const metadata = integration.metadata as Record<string, string>;
if (metadata) {
Object.keys(secrets).forEach((key) => {
const prefix = metadata?.secretPrefix || "";
const suffix = metadata?.secretSuffix || "";
const newKey = prefix + key + suffix;
suffixedSecrets[newKey] = secrets[key];
});
}
const lastRunSyncIntegrationTimestamp = await keyStore.getItem(
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath)
);
// akhilmhdh: this try catch is for catching integration error and saving it in db
try {
// akhilmhdh: this needs to changed later to be more easier to use
// at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places
const response = await syncIntegrationSecrets({
createManySecretsRawFn,
updateManySecretsRawFn,
integrationDAL,
integration,
integrationAuth,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
accessId: accessId as string,
awsAssumeRoleArn,
accessToken,
projectId,
appendices: {
prefix: metadata?.secretPrefix || "",
suffix: metadata?.secretSuffix || ""
}
});
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
lastUsed: new Date(),
syncMessage: response?.syncMessage ?? "",
isSynced: response?.isSynced ?? true
});
} catch (err) {
logger.error(
err,
`Secret integration sync error [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}]`
// check whether the integration should wait or not
if (lastRunSyncIntegrationTimestamp) {
const INTEGRATION_INTERVAL = 2000;
const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp);
if (isStaleSyncIntegration) {
logger.info(
`getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
return;
}
const message =
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
"Unknown error occurred.";
const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds(
lockAcquiredTime.toISOString(),
lastRunSyncIntegrationTimestamp
);
if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0)
await new Promise((resolve) => {
setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000);
});
}
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
lastUsed: new Date(),
syncMessage: message,
isSynced: false
const generateActor = async (): Promise<Actor> => {
if (isManual && actorId) {
const user = await userDAL.findById(actorId);
if (!user) {
throw new Error("User not found");
}
return {
type: ActorType.USER,
metadata: {
email: user.email,
username: user.username,
userId: user.id
}
};
}
return {
type: ActorType.PLATFORM,
metadata: {}
};
};
// akhilmhdh: this try catch is for lock release
try {
const secrets = shouldUseSecretV2Bridge
? await getIntegrationSecretsV2({
environment,
projectId,
folderId: folder.id,
depth: 1,
secretPath,
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
})
: await getIntegrationSecrets({
environment,
projectId,
folderId: folder.id,
key: botKey as string,
depth: 1,
secretPath
});
for (const integration of toBeSyncedIntegrations) {
const integrationAuth = {
...integration.integrationAuth,
createdAt: new Date(),
updatedAt: new Date(),
projectId: integration.projectId
};
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(
integrationAuth,
shouldUseSecretV2Bridge,
botKey
);
let awsAssumeRoleArn = null;
if (shouldUseSecretV2Bridge) {
if (integrationAuth.encryptedAwsAssumeIamRoleArn) {
awsAssumeRoleArn = secretManagerDecryptor({
cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn)
}).toString();
}
} else if (
integrationAuth.awsAssumeIamRoleArnTag &&
integrationAuth.awsAssumeIamRoleArnIV &&
integrationAuth.awsAssumeIamRoleArnCipherText
) {
awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({
ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText,
iv: integrationAuth.awsAssumeIamRoleArnIV,
tag: integrationAuth.awsAssumeIamRoleArnTag,
key: botKey as string
});
}
const suffixedSecrets: typeof secrets = {};
const metadata = integration.metadata as Record<string, string>;
if (metadata) {
Object.keys(secrets).forEach((key) => {
const prefix = metadata?.secretPrefix || "";
const suffix = metadata?.secretSuffix || "";
const newKey = prefix + key + suffix;
suffixedSecrets[newKey] = secrets[key];
});
}
// akhilmhdh: this try catch is for catching integration error and saving it in db
try {
// akhilmhdh: this needs to changed later to be more easier to use
// at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places
const response = await syncIntegrationSecrets({
createManySecretsRawFn,
updateManySecretsRawFn,
integrationDAL,
integration,
integrationAuth,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
accessId: accessId as string,
awsAssumeRoleArn,
accessToken,
projectId,
appendices: {
prefix: metadata?.secretPrefix || "",
suffix: metadata?.secretSuffix || ""
}
});
await auditLogService.createAuditLog({
projectId,
actor: await generateActor(),
event: {
type: EventType.INTEGRATION_SYNCED,
metadata: {
integrationId: integration.id,
isSynced: response?.isSynced ?? true,
lastSyncJobId: job?.id ?? "",
lastUsed: new Date(),
syncMessage: response?.syncMessage ?? ""
}
}
});
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
lastUsed: new Date(),
syncMessage: response?.syncMessage ?? "",
isSynced: response?.isSynced ?? true
});
// May be undefined, if it's undefined we assume the sync was successful, hence the strict equality type check.
if (response?.isSynced === false) {
integrationsFailedToSync.push({
integrationId: integration.id,
syncMessage: response.syncMessage
});
}
} catch (err) {
logger.error(
err,
`Secret integration sync error [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}]`
);
const message =
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
"Unknown error occurred.";
await auditLogService.createAuditLog({
projectId,
actor: await generateActor(),
event: {
type: EventType.INTEGRATION_SYNCED,
metadata: {
integrationId: integration.id,
isSynced: false,
lastSyncJobId: job?.id ?? "",
lastUsed: new Date(),
syncMessage: message
}
}
});
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
syncMessage: message,
isSynced: false
});
integrationsFailedToSync.push({
integrationId: integration.id,
syncMessage: message
});
}
}
} finally {
await lock.release();
if (integrationsFailedToSync.length) {
await sendFailedIntegrationSyncEmails({
count: integrationsFailedToSync.length,
environmentName: folder.environment.name,
environmentSlug: environment,
...(isManual &&
actorId && {
manuallyTriggeredByUserId: actorId
}),
projectId,
secretPath,
syncMessage: integrationsFailedToSync[0].syncMessage
});
}
}
} finally {
await lock.release();
}
await keyStore.setItemWithExpiry(
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath),
KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds,
lockAcquiredTime.toISOString()
);
logger.info("Secret integration sync ended: %s", job.id);
await keyStore.setItemWithExpiry(
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath),
KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds,
lockAcquiredTime.toISOString()
);
logger.info("Secret integration sync ended: %s", job.id);
}
});
queueService.start(QueueName.SecretReminder, async ({ data }) => {
+169 -13
View File
@@ -954,6 +954,120 @@ export const secretServiceFactory = ({
return secretsDeleted;
};
const getSecretsCount = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environment,
tagSlugs = [],
...v2Params
}: Pick<
TGetSecretsRawDTO,
| "projectId"
| "path"
| "actor"
| "actorId"
| "actorOrgId"
| "actorAuthMethod"
| "environment"
| "tagSlugs"
| "search"
>) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const count = await secretV2BridgeService.getSecretsCount({
projectId,
actorId,
actor,
actorOrgId,
environment,
path,
actorAuthMethod,
tagSlugs,
...v2Params
});
return count;
};
const getSecretsCountMultiEnv = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
...v2Params
}: Pick<
TGetSecretsRawDTO,
"projectId" | "path" | "actor" | "actorId" | "actorOrgId" | "actorAuthMethod" | "search"
> & { environments: string[] }) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const count = await secretV2BridgeService.getSecretsCountMultiEnv({
projectId,
actorId,
actor,
actorOrgId,
environments,
path,
actorAuthMethod,
...v2Params
});
return count;
};
const getSecretsRawMultiEnv = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
...params
}: Omit<TGetSecretsRawDTO, "environment" | "includeImports" | "expandSecretReferences" | "recursive" | "tagSlugs"> & {
environments: string[];
}) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const secrets = await secretV2BridgeService.getSecretsMultiEnv({
projectId,
actorId,
actor,
actorOrgId,
environments,
path,
actorAuthMethod,
...params
});
return secrets;
};
const getSecretsRaw = async ({
projectId,
path,
@@ -965,7 +1079,8 @@ export const secretServiceFactory = ({
includeImports,
expandSecretReferences,
recursive,
tagSlugs = []
tagSlugs = [],
...paramsV2
}: TGetSecretsRawDTO) => {
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
@@ -980,12 +1095,17 @@ export const secretServiceFactory = ({
recursive,
actorAuthMethod,
includeImports,
tagSlugs
tagSlugs,
...paramsV2
});
return { secrets, imports };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const { secrets, imports } = await getSecrets({
actorId,
@@ -1146,7 +1266,10 @@ export const secretServiceFactory = ({
});
if (!botKey)
throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" });
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey);
if (expandSecretReferences) {
@@ -1238,7 +1361,11 @@ export const secretServiceFactory = ({
return { secret, type: SecretProtectionType.Direct as const };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretName, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey);
@@ -1376,7 +1503,11 @@ export const secretServiceFactory = ({
return { type: SecretProtectionType.Direct as const, secret };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey);
@@ -1498,7 +1629,11 @@ export const secretServiceFactory = ({
});
return { type: SecretProtectionType.Direct as const, secret };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
if (policy) {
const approval = await secretApprovalRequestService.generateSecretApprovalRequest({
policy,
@@ -1598,7 +1733,11 @@ export const secretServiceFactory = ({
return { secrets, type: SecretProtectionType.Direct as const };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const sanitizedSecrets = inputSecrets.map(
({ secretComment, secretKey, metadata, tagIds, secretValue, skipMultilineEncoding }) => {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretKey, botKey);
@@ -1720,7 +1859,11 @@ export const secretServiceFactory = ({
return { type: SecretProtectionType.Direct as const, secrets };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
const sanitizedSecrets = inputSecrets.map(
({
secretComment,
@@ -1848,7 +1991,11 @@ export const secretServiceFactory = ({
return { type: SecretProtectionType.Direct as const, secrets };
}
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
if (!botKey)
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
if (policy) {
const approval = await secretApprovalRequestService.generateSecretApprovalRequest({
@@ -2182,7 +2329,10 @@ export const secretServiceFactory = ({
}
if (!botKey)
throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" });
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
await secretDAL.transaction(async (tx) => {
const secrets = await secretDAL.findAllProjectSecretValues(projectId, tx);
@@ -2265,7 +2415,10 @@ export const secretServiceFactory = ({
const { botKey } = await projectBotService.getBotKey(project.id);
if (!botKey) {
throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
throw new BadRequestError({
message: "Project bot not found. Please upgrade your project.",
name: "bot_not_found_error"
});
}
const sourceFolder = await folderDAL.findBySecretPath(project.id, sourceEnvironment, sourceSecretPath);
@@ -2656,6 +2809,9 @@ export const secretServiceFactory = ({
getSecretVersions,
backfillSecretReferences,
moveSecrets,
startSecretV2Migration
startSecretV2Migration,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsRawMultiEnv
};
};
+36 -1
View File
@@ -1,7 +1,8 @@
import { Knex } from "knex";
import { z } from "zod";
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
@@ -21,6 +22,29 @@ type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secret
type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id">;
export const FailedIntegrationSyncEmailsPayloadSchema = z.object({
projectId: z.string(),
secretPath: z.string(),
environmentName: z.string(),
environmentSlug: z.string(),
count: z.number(),
syncMessage: z.string().optional(),
manuallyTriggeredByUserId: z.string().optional()
});
export type TFailedIntegrationSyncEmailsPayload = z.infer<typeof FailedIntegrationSyncEmailsPayloadSchema>;
export type TIntegrationSyncPayload = {
isManual?: boolean;
actorId?: string;
projectId: string;
environment: string;
secretPath: string;
depth?: number;
deDupeQueue?: Record<string, boolean>;
};
export type TCreateSecretDTO = {
secretName: string;
path: string;
@@ -81,6 +105,8 @@ export type TGetSecretsDTO = {
environment: string;
includeImports?: boolean;
recursive?: boolean;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetASecretDTO = {
@@ -143,6 +169,10 @@ export type TDeleteBulkSecretDTO = {
}>;
} & TProjectPermission;
export enum SecretsOrderBy {
Name = "name" // "key" for secrets but using name for use across resources
}
export type TGetSecretsRawDTO = {
expandSecretReferences?: boolean;
path: string;
@@ -150,6 +180,11 @@ export type TGetSecretsRawDTO = {
includeImports?: boolean;
recursive?: boolean;
tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission;
export type TGetASecretRawDTO = {
+2 -1
View File
@@ -33,7 +33,8 @@ export enum SmtpTemplates {
SecretLeakIncident = "secretLeakIncident.handlebars",
WorkspaceInvite = "workspaceInvitation.handlebars",
ScimUserProvisioned = "scimUserProvisioned.handlebars",
PkiExpirationAlert = "pkiExpirationAlert.handlebars"
PkiExpirationAlert = "pkiExpirationAlert.handlebars",
IntegrationSyncFailed = "integrationSyncFailed.handlebars"
}
export enum SmtpHost {
@@ -0,0 +1,31 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Integration Sync Failed</title>
</head>
<body>
<h2>Infisical</h2>
<div>
<p>{{count}} integration(s) failed to sync.</p>
<a href="{{integrationUrl}}">
View your project integrations.
</a>
</div>
<br />
<div>
<p><strong>Project</strong>: {{projectName}}</p>
<p><strong>Environment</strong>: {{environment}}</p>
<p><strong>Secret Path</strong>: {{secretPath}}</p>
</div>
{{#if syncMessage}}
<p><b>Reason: </b>{{syncMessage}}</p>
{{/if}}
</body>
</html>