Merge branch 'ENG-3506' into ENG-3506-LDAP

This commit is contained in:
x032205
2025-08-27 05:10:49 -04:00
193 changed files with 7318 additions and 1356 deletions
+1
View File
@@ -148,6 +148,7 @@ declare module "fastify" {
interface Session { interface Session {
callbackPort: string; callbackPort: string;
isAdminLogin: boolean; isAdminLogin: boolean;
orgSlug?: string;
} }
interface FastifyRequest { interface FastifyRequest {
@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
t.index("expiresAt"); t.index("expiresAt");
t.index("orgId"); t.index("orgId");
t.index("projectId"); t.index("projectId");
t.index("eventType");
t.index("userAgentType");
t.index("actor");
}); });
console.log("Adding GIN indices..."); console.log("Adding GIN indices...");
@@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise<void> => {
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
// create partitions 4 years ahead // create partitions 20 years ahead
const partitionMonths = 4 * 12; const partitionMonths = 20 * 12;
const partitionPromises: Promise<void>[] = []; const partitionPromises: Promise<void>[] = [];
for (let x = 1; x <= partitionMonths; x += 1) { for (let x = 1; x <= partitionMonths; x += 1) {
partitionPromises.push( partitionPromises.push(
@@ -0,0 +1,49 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
const BATCH_SIZE = 1000;
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) {
// Add the column
await knex.schema.alterTable(TableName.UserAliases, (t) => {
t.boolean("isEmailVerified").defaultTo(false);
});
const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases)
.join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`)
.select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]);
for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) {
const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE);
const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId);
if (trueIds.length > 0) {
// eslint-disable-next-line no-await-in-loop
await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true });
}
}
}
if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) {
await knex.schema.alterTable(TableName.AuthTokens, (t) => {
t.string("aliasId").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) {
await knex.schema.alterTable(TableName.UserAliases, (t) => {
t.dropColumn("isEmailVerified");
});
}
if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) {
await knex.schema.alterTable(TableName.AuthTokens, (t) => {
t.dropColumn("aliasId");
});
}
}
@@ -0,0 +1,38 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (!hasEditNoteCol || !hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (!hasEditedByUserId) {
t.uuid("editedByUserId").nullable();
t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
}
if (!hasEditNoteCol) {
t.string("editNote").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (hasEditNoteCol || hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (hasEditedByUserId) {
t.dropColumn("editedByUserId");
}
if (hasEditNoteCol) {
t.dropColumn("editNote");
}
});
}
}
@@ -0,0 +1,39 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced";
const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed";
export async function up(knex: Knex): Promise<void> {
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
);
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
);
await knex.schema.alterTable(TableName.Organization, (table) => {
if (!hasGoogleSsoAuthEnforcedColumn)
table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable();
if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable();
});
}
export async function down(knex: Knex): Promise<void> {
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
);
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
TableName.Organization,
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
);
await knex.schema.alterTable(TableName.Organization, (table) => {
if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME);
if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME);
});
}
@@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({
requestedByUserId: z.string().uuid(), requestedByUserId: z.string().uuid(),
note: z.string().nullable().optional(), note: z.string().nullable().optional(),
privilegeDeletedAt: z.date().nullable().optional(), privilegeDeletedAt: z.date().nullable().optional(),
status: z.string().default("pending") status: z.string().default("pending"),
editedByUserId: z.string().uuid().nullable().optional(),
editNote: z.string().nullable().optional()
}); });
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>; export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
+2 -1
View File
@@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
userId: z.string().uuid().nullable().optional(), userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid().nullable().optional() orgId: z.string().uuid().nullable().optional(),
aliasId: z.string().nullable().optional()
}); });
export type TAuthTokens = z.infer<typeof AuthTokensSchema>; export type TAuthTokens = z.infer<typeof AuthTokensSchema>;
+3 -1
View File
@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
scannerProductEnabled: z.boolean().default(true).nullable().optional(), scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
maxSharedSecretViewLimit: z.number().nullable().optional() maxSharedSecretViewLimit: z.number().nullable().optional(),
googleSsoAuthEnforced: z.boolean().default(false),
googleSsoAuthLastUsed: z.date().nullable().optional()
}); });
export type TOrganizations = z.infer<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
+2 -1
View File
@@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({
emails: z.string().array().nullable().optional(), emails: z.string().array().nullable().optional(),
orgId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
isEmailVerified: z.boolean().default(false).nullable().optional()
}); });
export type TUserAliases = z.infer<typeof UserAliasesSchema>; export type TUserAliases = z.infer<typeof UserAliasesSchema>;
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
body: z.object({ body: z.object({
permissions: z.any().array(), permissions: z.any().array(),
isTemporary: z.boolean(), isTemporary: z.boolean(),
temporaryRange: z.string().optional(), temporaryRange: z
.string()
.optional()
.transform((val, ctx) => {
if (!val || val === "permanent") return undefined;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
note: z.string().max(255).optional() note: z.string().max(255).optional()
}), }),
querystring: z.object({ querystring: z.object({
@@ -116,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
approvals: z.number(), approvals: z.number(),
approvers: z approvers: z
.object({ .object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string().nullable().optional(), userId: z.string().nullable().optional(),
sequence: z.number().nullable().optional(), sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(),
@@ -133,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
}), }),
reviewers: z reviewers: z
.object({ .object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string(), userId: z.string(),
status: z.string() status: z.string()
}) })
@@ -190,4 +209,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
return { review }; return { review };
} }
}); });
server.route({
url: "/:requestId",
method: "PATCH",
schema: {
params: z.object({
requestId: z.string().trim()
}),
body: z.object({
temporaryRange: z.string().transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
editNote: z.string().max(255)
}),
response: {
200: z.object({
approval: AccessApprovalRequestsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
temporaryRange: req.body.temporaryRange,
editNote: req.body.editNote,
requestId: req.params.requestId
});
return { approval: request };
}
});
}; };
@@ -294,12 +294,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
200: z.object({ 200: z.object({
approval: SecretApprovalRequestsSchema.merge( approval: SecretApprovalRequestsSchema.merge(
z.object({ z.object({
// secretPath: z.string(),
policy: z.object({ policy: z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
approvers: approvalRequestUser.array(), approvers: approvalRequestUser
.extend({ isOrgMembershipActive: z.boolean().nullable().optional() })
.array(),
bypassers: approvalRequestUser.array(), bypassers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
@@ -309,7 +310,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
environment: z.string(), environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(), statusChangedByUser: approvalRequestUser.optional(),
committerUser: approvalRequestUser.nullish(), committerUser: approvalRequestUser.nullish(),
reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), reviewers: approvalRequestUser
.extend({
status: z.string(),
comment: z.string().optional(),
isOrgMembershipActive: z.boolean().nullable().optional()
})
.array(),
secretPath: z.string(), secretPath: z.string(),
commits: secretRawSchema commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
@@ -5,6 +5,7 @@ import {
AccessApprovalRequestsSchema, AccessApprovalRequestsSchema,
TableName, TableName,
TAccessApprovalRequests, TAccessApprovalRequests,
TOrgMemberships,
TUserGroupMembership, TUserGroupMembership,
TUsers TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
@@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -151,6 +153,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -202,6 +205,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
reviewers: { reviewers: {
userId: string; userId: string;
status: string; status: string;
isOrgMembershipActive: boolean;
}[]; }[];
approvers: ( approvers: (
| { | {
@@ -210,6 +214,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -217,6 +222,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -288,6 +294,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
`requestedByUser.id` `requestedByUser.id`
) )
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`${TableName.Users}.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(selectAllTableCols(TableName.AccessApprovalRequest))
@@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
) )
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
@@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
{ {
key: "reviewerUserId", key: "reviewerUserId",
label: "reviewers" as const, label: "reviewers" as const,
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) =>
userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined
}, },
{ {
key: "approverUserId", key: "approverUserId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ mapper: ({
approverUserId,
approverSequence,
approvalsRequired,
approverUsername,
approverEmail,
approverIsOrgMembershipActive
}) => ({
userId: approverUserId, userId: approverUserId,
sequence: approverSequence, sequence: approverSequence,
approvalsRequired, approvalsRequired,
email: approverEmail, email: approverEmail,
username: approverUsername username: approverUsername,
isOrgMembershipActive: approverIsOrgMembershipActive
}) })
}, },
{ {
@@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
approverSequence, approverSequence,
approvalsRequired, approvalsRequired,
approverGroupEmail, approverGroupEmail,
approverGroupUsername approverGroupUsername,
approverGroupIsOrgMembershipActive
}) => ({ }) => ({
userId: approverGroupUserId, userId: approverGroupUserId,
sequence: approverSequence, sequence: approverSequence,
approvalsRequired, approvalsRequired,
email: approverGroupEmail, email: approverGroupEmail,
username: approverGroupUsername username: approverGroupUsername,
isOrgMembershipActive: approverGroupIsOrgMembershipActive
}) })
}, },
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">; accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
accessApprovalRequestReviewerDAL: Pick< accessApprovalRequestReviewerDAL: Pick<
TAccessApprovalRequestReviewerDALFactory, TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction" "create" | "find" | "findOne" | "transaction" | "delete"
>; >;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">; groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
@@ -301,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
return { request: approval }; return { request: approval };
}; };
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
temporaryRange,
actorId,
actor,
actorOrgId,
actorAuthMethod,
editNote,
requestId
}) => {
const cfg = getConfig();
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
if (!accessApprovalRequest) {
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
}
const { policy, requestedByUser } = accessApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this access request has been deleted."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: accessApprovalRequest.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
if (!membership) {
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
}
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
}
const project = await projectDAL.findById(accessApprovalRequest.projectId);
if (!project) {
throw new NotFoundError({
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
});
}
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
throw new BadRequestError({ message: "The request has been closed" });
}
const editedByUser = await userDAL.findById(actorId);
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
}
}
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
permissions: accessApprovalRequest.permissions
});
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
const approvalRequest = await accessApprovalRequestDAL.updateById(
requestId,
{
temporaryRange,
isTemporary: true,
editNote,
editedByUserId: actorId
},
tx
);
// reset review progress
await accessApprovalRequestReviewerDAL.delete(
{
requestId
},
tx
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
await triggerWorkflowIntegrationNotification({
input: {
notification: {
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
payload: {
projectName: project.name,
requesterFullName,
isTemporary: true,
requesterEmail: requestedByUser.email as string,
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
}
},
projectId: project.id
},
dependencies: {
projectDAL,
projectSlackConfigDAL,
kmsService,
microsoftTeamsService,
projectMicrosoftTeamsConfigDAL
}
});
await smtpService.sendMail({
recipients: policy.approvers
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
.map((approver) => approver.email!),
subjectLine: "Access Approval Request Updated",
substitutions: {
projectName: project.name,
requesterFullName,
requesterEmail: requestedByUser.email,
isTemporary: true,
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorFullName,
editorEmail: editedByUser.email
},
template: SmtpTemplates.AccessApprovalRequestUpdated
});
return approvalRequest;
});
return { request: approval };
};
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug, projectSlug,
authorUserId, authorUserId,
@@ -650,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
return { return {
createAccessApprovalRequest, createAccessApprovalRequest,
updateAccessApprovalRequest,
listApprovalRequests, listApprovalRequests,
reviewAccessRequest, reviewAccessRequest,
getCount getCount
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
note?: string; note?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalRequestDTO = {
requestId: string;
temporaryRange: string;
editNote: string;
} & Omit<TProjectPermission, "projectId">;
export type TListApprovalRequestsDTO = { export type TListApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
authorUserId?: string; authorUserId?: string;
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
privilegeDeletedAt?: Date | null | undefined; privilegeDeletedAt?: Date | null | undefined;
}; };
}>; }>;
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
request: {
status: string;
id: string;
createdAt: Date;
updatedAt: Date;
policyId: string;
isTemporary: boolean;
requestedByUserId: string;
privilegeId?: string | null | undefined;
requestedBy?: string | null | undefined;
temporaryRange?: string | null | undefined;
permissions?: unknown;
note?: string | null | undefined;
privilegeDeletedAt?: Date | null | undefined;
};
}>;
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{ listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
requests: { requests: {
policy: { policy: {
@@ -64,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -71,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -122,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory {
reviewers: { reviewers: {
userId: string; userId: string;
status: string; status: string;
isOrgMembershipActive: boolean;
}[]; }[];
approvers: ( approvers: (
| { | {
@@ -130,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -137,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
import { EventType, filterableSecretEvents } from "./audit-log-types"; import { EventType, filterableSecretEvents } from "./audit-log-types";
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> { export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
pruneAuditLog: (tx?: knex.Knex) => Promise<void>; pruneAuditLog: () => Promise<void>;
find: ( find: (
arg: Omit<TFindQuery, "actor" | "eventType"> & { arg: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string | undefined; actorId?: string | undefined;
@@ -41,6 +41,10 @@ type TFindQuery = {
offset?: number; offset?: number;
}; };
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
export const auditLogDALFactory = (db: TDbClient) => { export const auditLogDALFactory = (db: TDbClient) => {
const auditLogOrm = ormify(db, TableName.AuditLog); const auditLogOrm = ormify(db, TableName.AuditLog);
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
}; };
// delete all audit log that have expired // delete all audit log that have expired
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
const runPrune = async (dbClient: knex.Knex) => { const today = new Date();
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; let deletedAuditLogIds: { id: string }[] = [];
const MAX_RETRY_ON_FAILURE = 3; let numberOfRetryOnFailure = 0;
let isRetrying = false;
const today = new Date(); logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
let deletedAuditLogIds: { id: string }[] = []; do {
let numberOfRetryOnFailure = 0; try {
let isRetrying = false; // eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); const findExpiredLogSubQuery = trx(TableName.AuditLog)
do {
try {
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
.where("expiresAt", "<", today) .where("expiresAt", "<", today)
.where("createdAt", "<", today) // to use audit log partition .where("createdAt", "<", today) // to use audit log partition
.orderBy(`${TableName.AuditLog}.createdAt`, "desc") .orderBy(`${TableName.AuditLog}.createdAt`, "desc")
@@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE); .limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await dbClient(TableName.AuditLog) const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
.whereIn("id", findExpiredLogSubQuery)
.del()
.returning("id");
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, "Failed to delete audit log on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
};
if (tx) { return results;
await runPrune(tx); });
} else {
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes numberOfRetryOnFailure = 0; // reset
await db.transaction(async (trx) => { } catch (error) {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); numberOfRetryOnFailure += 1;
await runPrune(trx); deletedAuditLogIds = [];
}); logger.error(error, "Failed to delete audit log on pruning");
} } finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
}; };
const create: TAuditLogDALFactory["create"] = async (tx) => { const create: TAuditLogDALFactory["create"] = async (tx) => {
@@ -1,8 +1,6 @@
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError, RawAxiosRequestHeaders } from "axios";
import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; import { SecretKeyEncoding } from "@app/db/schemas";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { TopicName, toPublishableEvent } from "@app/ee/services/event/types";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = {
queueService: TQueueServiceFactory; queueService: TQueueServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
eventBusService: TEventBusService;
}; };
export type TAuditLogQueueServiceFactory = { export type TAuditLogQueueServiceFactory = {
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL, auditLogStreamDAL
eventBusService
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => { }: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
const pushToLog = async (data: TCreateAuditLogDTO) => { const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, { await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
@@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({
) )
); );
} }
const publishable = toPublishableEvent(event);
if (publishable) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: publishable.data
});
}
}); });
return { return {
@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import axios from "axios";
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
type TCreateCouchbaseUser = {
name: string;
password: string;
access: {
privileges: string[];
resources: {
buckets: {
name: string;
scopes?: {
name: string;
collections?: string[];
}[];
}[];
};
}[];
};
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12);
}
return sanitized;
};
/**
* Normalizes bucket configuration to handle wildcard (*) access consistently.
*
* Key behaviors:
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
*
* @param buckets - Either a string or array of bucket configurations
* @returns Normalized bucket resources for Couchbase API
*/
const normalizeBucketConfiguration = (
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>
) => {
if (typeof buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names
const bucketNames = buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
if (bucketNames.includes("*") || buckets === "*") {
return [{ name: "*" }];
}
return bucketNames.map((bucketName) => ({ name: bucketName }));
}
// Array of bucket objects with scopes and collections
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
if (hasWildcardBucket) {
return [{ name: "*" }];
}
return buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
return sanitizeCouchbaseUsername(compiledUsername);
};
const generatePassword = (requirements?: PasswordRequirements): string => {
const {
length = 12,
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
} = requirements || {};
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const symbols = allowedSymbols;
let password = "";
let remaining = length;
// Add required characters
for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)];
remaining -= 1;
}
for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)];
remaining -= 1;
}
// Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)];
}
// Shuffle the password
return password
.split("")
.sort(() => crypto.randomInt(3) - 1)
.join("");
};
const couchbaseApiRequest = async (
method: string,
url: string,
apiKey: string,
data?: unknown
): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url);
try {
const response = await axios({
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: data || undefined,
timeout: 30000
});
return response.data as CouchbaseUserResponse;
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [apiKey]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
}
};
export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs;
};
const validateConnection = async (inputs: unknown): Promise<boolean> => {
try {
const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true;
} catch (error) {
throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const create = async ({
inputs,
usernameTemplate,
identity
}: {
inputs: unknown;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
const userData: TCreateCouchbaseUser = {
name: username,
password,
access: [
{
privileges: providerInputs.roles,
resources: {
buckets: bucketResources
}
}
]
};
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username;
return {
entityId: userUuid,
data: {
username,
password
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId };
};
const renew = async (_inputs: unknown, entityId: string) => {
// Couchbase Cloud API doesn't support renewing user credentials
// The user remains valid until explicitly deleted
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};
@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam"; import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id"; import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra"; import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search"; import { ElasticSearchProvider } from "./elastic-search";
import { GcpIamProvider } from "./gcp-iam"; import { GcpIamProvider } from "./gcp-iam";
import { GithubProvider } from "./github"; import { GithubProvider } from "./github";
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
[DynamicSecretProviders.GcpIam]: GcpIamProvider(), [DynamicSecretProviders.GcpIam]: GcpIamProvider(),
[DynamicSecretProviders.Github]: GithubProvider() [DynamicSecretProviders.Github]: GithubProvider(),
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
}); });
@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
.describe("The private key generated for your GitHub App.") .describe("The private key generated for your GitHub App.")
}); });
export const DynamicSecretCouchbaseSchema = z.object({
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
orgId: z.string().trim().min(1).describe("Organization ID"),
projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster ID"),
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z
.union([
z
.string()
.trim()
.min(1)
.default("*")
.refine((val) => {
if (val.includes(",")) {
const buckets = val
.split(",")
.map((b) => b.trim())
.filter((b) => b.length > 0);
if (buckets.includes("*") && buckets.length > 1) {
return false;
}
}
return true;
}, "Cannot combine '*' with other bucket names"),
z
.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"),
scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
})
)
.optional()
.describe("Scopes within the bucket")
})
)
.refine((buckets) => {
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
if (hasWildcard && buckets.length > 1) {
return false;
}
return true;
}, "Cannot combine '*' bucket with other buckets")
])
.default("*")
.describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: z
.object({
length: z.number().min(8, "Password must be at least 8 characters").max(128),
required: z
.object({
lowercase: z.number().min(1, "At least 1 lowercase character required"),
uppercase: z.number().min(1, "At least 1 uppercase character required"),
digits: z.number().min(1, "At least 1 digit required"),
symbols: z.number().min(1, "At least 1 special character required")
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128;
}, "Sum of required characters cannot exceed 128"),
allowedSymbols: z
.string()
.refine((symbols) => {
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements for Couchbase"),
auth: z.object({
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
})
});
export enum DynamicSecretProviders { export enum DynamicSecretProviders {
SqlDatabase = "sql-database", SqlDatabase = "sql-database",
Cassandra = "cassandra", Cassandra = "cassandra",
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes", Kubernetes = "kubernetes",
Vertica = "vertica", Vertica = "vertica",
GcpIam = "gcp-iam", GcpIam = "gcp-iam",
Github = "github" Github = "github",
Couchbase = "couchbase"
} }
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }), z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }) z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
]); ]);
export type TDynamicProviderFns = { export type TDynamicProviderFns = {
@@ -3,7 +3,7 @@ import { z } from "zod";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { EventSchema, TopicName } from "./types"; import { BusEventSchema, TopicName } from "./types";
export const eventBusFactory = (redis: Redis) => { export const eventBusFactory = (redis: Redis) => {
const publisher = redis.duplicate(); const publisher = redis.duplicate();
@@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => {
* @param topic - The topic to publish the event to. * @param topic - The topic to publish the event to.
* @param event - The event data to publish. * @param event - The event data to publish.
*/ */
const publish = async <T extends z.input<typeof EventSchema>>(topic: TopicName, event: T) => { const publish = async <T extends z.input<typeof BusEventSchema>>(topic: TopicName, event: T) => {
const json = JSON.stringify(event); const json = JSON.stringify(event);
return publisher.publish(topic, json, (err) => { return publisher.publish(topic, json, (err) => {
@@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => {
* @template T - The type of the event data, which should match the schema defined in EventSchema. * @template T - The type of the event data, which should match the schema defined in EventSchema.
* @returns A function that can be called to unsubscribe from the event bus. * @returns A function that can be called to unsubscribe from the event bus.
*/ */
const subscribe = <T extends z.infer<typeof EventSchema>>(fn: (data: T) => Promise<void> | void) => { const subscribe = <T extends z.infer<typeof BusEventSchema>>(fn: (data: T) => Promise<void> | void) => {
// Not using async await cause redis client's `on` method does not expect async listeners. // Not using async await cause redis client's `on` method does not expect async listeners.
const listener = (channel: string, message: string) => { const listener = (channel: string, message: string) => {
try { try {
@@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger";
import { TEventBusService } from "./event-bus-service"; import { TEventBusService } from "./event-bus-service";
import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream";
import { EventData, RegisteredEvent, toBusEventName } from "./types"; import { BusEvent, RegisteredEvent } from "./types";
const AUTH_REFRESH_INTERVAL = 60 * 1000; const AUTH_REFRESH_INTERVAL = 60 * 1000;
const HEART_BEAT_INTERVAL = 15 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000;
@@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => {
} }
}; };
function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) {
const eventType = toBusEventName(event.data.eventType); const eventType = event.data.event;
const match = registered.find((r) => r.event === eventType); const match = registered.find((r) => r.event === eventType);
if (!match) return; if (!match) return;
@@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore";
import { conditionsMatcher } from "@app/lib/casl"; import { conditionsMatcher } from "@app/lib/casl";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { EventData, RegisteredEvent } from "./types"; import { BusEvent, RegisteredEvent } from "./types";
export const getServerSentEventsHeaders = () => export const getServerSentEventsHeaders = () =>
({ ({
@@ -55,7 +55,7 @@ export type EventStreamClient = {
id: string; id: string;
stream: Readable; stream: Readable;
open: () => Promise<void>; open: () => Promise<void>;
send: (data: EventMessage | EventData) => void; send: (data: EventMessage | BusEvent) => void;
ping: () => Promise<void>; ping: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
close: () => void; close: () => void;
@@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
return { return {
subject: options.type, subject: options.type,
action: "subscribe", action: "subscribe",
conditions: { conditions: hasConditions
eventType: r.event, ? {
...(hasConditions environment: r.conditions?.environmentSlug ?? "",
? { secretPath: { $glob: secretPath }
environment: r.conditions?.environmentSlug ?? "", }
secretPath: { $glob: secretPath } : undefined
}
: {})
}
}; };
}); });
@@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
// We will manually push data to the stream // We will manually push data to the stream
stream._read = () => {}; stream._read = () => {};
const send = (data: EventMessage | EventData) => { const send = (data: EventMessage | BusEvent) => {
const chunk = serializeSseEvent(data); const chunk = serializeSseEvent(data);
if (!stream.push(chunk)) { if (!stream.push(chunk)) {
logger.debug("Backpressure detected: dropped manual event"); logger.debug("Backpressure detected: dropped manual event");
@@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
await redis.set(key, "1", "EX", 60); await redis.set(key, "1", "EX", 60);
stream.push("1"); send({ type: "ping" });
}; };
const close = () => { const close = () => {
+45 -71
View File
@@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ProjectPermissionSecretEventActions } from "../permission/project-permission";
export enum TopicName { export enum TopicName {
CoreServers = "infisical::core-servers" CoreServers = "infisical::core-servers"
@@ -10,84 +11,44 @@ export enum TopicName {
export enum BusEventName { export enum BusEventName {
CreateSecret = "secret:create", CreateSecret = "secret:create",
UpdateSecret = "secret:update", UpdateSecret = "secret:update",
DeleteSecret = "secret:delete" DeleteSecret = "secret:delete",
ImportMutation = "secret:import-mutation"
} }
type PublisableEventTypes = export const Mappings = {
| EventType.CREATE_SECRET BusEventToAction(input: BusEventName) {
| EventType.CREATE_SECRETS switch (input) {
| EventType.DELETE_SECRET case BusEventName.CreateSecret:
| EventType.DELETE_SECRETS return ProjectPermissionSecretEventActions.SubscribeCreated;
| EventType.UPDATE_SECRETS case BusEventName.DeleteSecret:
| EventType.UPDATE_SECRET; return ProjectPermissionSecretEventActions.SubscribeDeleted;
case BusEventName.ImportMutation:
export function toBusEventName(input: EventType) { return ProjectPermissionSecretEventActions.SubscribeImportMutations;
switch (input) { case BusEventName.UpdateSecret:
case EventType.CREATE_SECRET: return ProjectPermissionSecretEventActions.SubscribeUpdated;
case EventType.CREATE_SECRETS: default:
return BusEventName.CreateSecret; throw new Error("Unknown bus event name");
case EventType.UPDATE_SECRET:
case EventType.UPDATE_SECRETS:
return BusEventName.UpdateSecret;
case EventType.DELETE_SECRET:
case EventType.DELETE_SECRETS:
return BusEventName.DeleteSecret;
default:
return null;
}
}
const isBulkEvent = (event: Event): event is Extract<Event, { metadata: { secrets: Array<unknown> } }> => {
return event.type.endsWith("-secrets"); // Feels so wrong
};
export const toPublishableEvent = (event: Event) => {
const name = toBusEventName(event.type);
if (!name) return null;
const e = event as Extract<Event, { type: PublisableEventTypes }>;
if (isBulkEvent(e)) {
return {
name,
isBulk: true,
data: {
eventType: e.type,
payload: e.metadata.secrets.map((s) => ({
environment: e.metadata.environment,
secretPath: e.metadata.secretPath,
...s
}))
}
} as const;
}
return {
name,
isBulk: false,
data: {
eventType: e.type,
payload: {
...e.metadata,
environment: e.metadata.environment
}
} }
} as const; }
}; };
export const EventName = z.nativeEnum(BusEventName); export const EventName = z.nativeEnum(BusEventName);
const EventSecretPayload = z.object({ const EventSecretPayload = z.object({
secretPath: z.string().optional(),
secretId: z.string(), secretId: z.string(),
secretPath: z.string().optional(),
secretKey: z.string(), secretKey: z.string(),
environment: z.string() environment: z.string()
}); });
const EventImportMutationPayload = z.object({
secretPath: z.string(),
environment: z.string()
});
export type EventSecret = z.infer<typeof EventSecretPayload>; export type EventSecret = z.infer<typeof EventSecretPayload>;
export const EventSchema = z.object({ export const BusEventSchema = z.object({
datacontenttype: z.literal("application/json").optional().default("application/json"), datacontenttype: z.literal("application/json").optional().default("application/json"),
type: z.nativeEnum(ProjectType), type: z.nativeEnum(ProjectType),
source: z.string(), source: z.string(),
@@ -95,25 +56,38 @@ export const EventSchema = z.object({
.string() .string()
.optional() .optional()
.default(() => new Date().toISOString()), .default(() => new Date().toISOString()),
data: z.discriminatedUnion("eventType", [ data: z.discriminatedUnion("event", [
z.object({ z.object({
specversion: z.number().optional().default(1), specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]),
payload: EventSecretPayload payload: z.union([EventSecretPayload, EventSecretPayload.array()])
}), }),
z.object({ z.object({
specversion: z.number().optional().default(1), specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), event: z.enum([BusEventName.ImportMutation]),
payload: EventSecretPayload.array() payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()])
}) })
// Add more event types as needed // Add more event types as needed
]) ])
}); });
export type EventData = z.infer<typeof EventSchema>; export type BusEvent = z.infer<typeof BusEventSchema>;
type PublishableEventPayload = z.input<typeof BusEventSchema>["data"];
type PublishableSecretEvent = Extract<
PublishableEventPayload,
{ event: Exclude<BusEventName, BusEventName.ImportMutation> }
>["payload"];
export type PublishableEvent = {
created?: PublishableSecretEvent;
updated?: PublishableSecretEvent;
deleted?: PublishableSecretEvent;
importMutation?: Extract<PublishableEventPayload, { event: BusEventName.ImportMutation }>["payload"];
};
export const EventRegisterSchema = z.object({ export const EventRegisterSchema = z.object({
event: EventName, event: z.nativeEnum(BusEventName),
conditions: z conditions: z
.object({ .object({
secretPath: z.string().optional().default("/"), secretPath: z.string().optional().default("/"),
@@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({
userAlias = await userDAL.transaction(async (tx) => { userAlias = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; let newUser: TUsers | undefined;
if (serverCfg.trustLdapEmails) { newUser = await userDAL.findOne(
newUser = await userDAL.findOne( {
{ email: email.toLowerCase(),
email: email.toLowerCase(), isEmailVerified: true
isEmailVerified: true },
}, tx
tx );
);
}
if (!newUser) { if (!newUser) {
const uniqueUsername = await normalizeUsername(username, userDAL); const uniqueUsername = await normalizeUsername(username, userDAL);
@@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({
aliasType: UserAliasType.LDAP, aliasType: UserAliasType.LDAP,
externalId, externalId,
emails: [email], emails: [email],
orgId orgId,
isEmailVerified: serverCfg.trustLdapEmails
}, },
tx tx
); );
@@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({
return newUser; return newUser;
}); });
const isUserCompleted = Boolean(user.isAccepted); const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
@@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({
organizationSlug: organization.slug, organizationSlug: organization.slug,
authMethod: AuthMethod.LDAP, authMethod: AuthMethod.LDAP,
authType: UserAliasType.LDAP, authType: UserAliasType.LDAP,
aliasId: userAlias.id,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
? { ? {
@@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({
} }
); );
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
auditLogStreams: false, auditLogStreams: false,
auditLogStreamLimit: 3, auditLogStreamLimit: 3,
samlSSO: false, samlSSO: false,
enforceGoogleSSO: false,
hsm: false, hsm: false,
oidcSSO: false, oidcSSO: false,
scim: false, scim: false,
@@ -47,6 +47,7 @@ export type TFeatureSet = {
auditLogStreamLimit: 3; auditLogStreamLimit: 3;
githubOrgSync: false; githubOrgSync: false;
samlSSO: false; samlSSO: false;
enforceGoogleSSO: false;
hsm: false; hsm: false;
oidcSSO: false; oidcSSO: false;
secretAccessInsights: false; secretAccessInsights: false;
@@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({
} }
const appCfg = getConfig(); const appCfg = getConfig();
const userAlias = await userAliasDAL.findOne({ let userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: UserAliasType.OIDC aliasType: UserAliasType.OIDC
@@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({
} else { } else {
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; let newUser: TUsers | undefined;
// we prioritize getting the most complete user to create the new alias under
newUser = await userDAL.findOne(
{
email,
isEmailVerified: true
},
tx
);
if (serverCfg.trustOidcEmails) { if (!newUser) {
// we prioritize getting the most complete user to create the new alias under // this fetches user entries created via invites
newUser = await userDAL.findOne( newUser = await userDAL.findOne(
{ {
email, username: email
isEmailVerified: true
}, },
tx tx
); );
if (!newUser) { if (newUser && !newUser.isEmailVerified) {
// this fetches user entries created via invites // we automatically mark it as email-verified because we've configured trust for OIDC emails
newUser = await userDAL.findOne( newUser = await userDAL.updateById(newUser.id, {
{ isEmailVerified: serverCfg.trustOidcEmails
username: email });
},
tx
);
if (newUser && !newUser.isEmailVerified) {
// we automatically mark it as email-verified because we've configured trust for OIDC emails
newUser = await userDAL.updateById(newUser.id, {
isEmailVerified: true
});
}
} }
} }
@@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({
); );
} }
await userAliasDAL.create( userAlias = await userAliasDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
aliasType: UserAliasType.OIDC, aliasType: UserAliasType.OIDC,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email] : [],
orgId orgId,
isEmailVerified: serverCfg.trustOidcEmails
}, },
tx tx
); );
@@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({
await licenseService.updateSubscriptionOrgMemberCount(organization.id); await licenseService.updateSubscriptionOrgMemberCount(organization.id);
const isUserCompleted = Boolean(user.isAccepted); const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
organizationId: organization.id, organizationId: organization.id,
organizationSlug: organization.slug, organizationSlug: organization.slug,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
aliasId: userAlias.id,
authMethod: AuthMethod.OIDC, authMethod: AuthMethod.OIDC,
authType: UserAliasType.OIDC, authType: UserAliasType.OIDC,
isUserCompleted, isUserCompleted,
@@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({
await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() });
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService await smtpService
@@ -13,6 +13,7 @@ import {
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
ProjectPermissionSecretRotationActions, ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningConfigActions,
ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSecretScanningDataSourceActions,
@@ -161,8 +162,7 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete, ProjectPermissionSecretActions.Delete
ProjectPermissionSecretActions.Subscribe
], ],
ProjectPermissionSub.Secrets ProjectPermissionSub.Secrets
); );
@@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretScanningConfigs ProjectPermissionSub.SecretScanningConfigs
); );
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -266,8 +276,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Delete, ProjectPermissionSecretActions.Delete
ProjectPermissionSecretActions.Subscribe
], ],
ProjectPermissionSub.Secrets ProjectPermissionSub.Secrets
); );
@@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
projectFavorites?: string[] | null | undefined; projectFavorites?: string[] | null | undefined;
customRoleSlug?: string | null | undefined; customRoleSlug?: string | null | undefined;
orgAuthEnforced?: boolean | null | undefined; orgAuthEnforced?: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
} & { } & {
groups: { groups: {
id: string; id: string;
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
}[]; }[];
orgId: string; orgId: string;
orgAuthEnforced: boolean | null | undefined; orgAuthEnforced: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
orgRole: OrgMembershipRole; orgRole: OrgMembershipRole;
userId: string; userId: string;
projectId: string; projectId: string;
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"), db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"),
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
OrgMembershipsSchema.extend({ OrgMembershipsSchema.extend({
permissions: z.unknown(), permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(), orgAuthEnforced: z.boolean().optional().nullable(),
orgGoogleSsoAuthEnforced: z.boolean(),
bypassOrgAuthEnabled: z.boolean(), bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(), customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean() shouldUseNewPrivilegeSystem: z.boolean()
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project), db.ref("orgId").withSchema(TableName.Project),
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
orgId, orgId,
username, username,
orgAuthEnforced, orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole, orgRole,
membershipId, membershipId,
groupMembershipId, groupMembershipId,
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
}) => ({ }) => ({
orgId, orgId,
orgAuthEnforced, orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole: orgRole as OrgMembershipRole, orgRole: orgRole as OrgMembershipRole,
userId, userId,
projectId, projectId,
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
function validateOrgSSO( function validateOrgSSO(
actorAuthMethod: ActorAuthMethod, actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"], isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole orgRole: OrgMembershipRole
) { ) {
@@ -128,10 +129,16 @@ function validateOrgSSO(
throw new UnauthorizedError({ name: "No auth method defined" }); throw new UnauthorizedError({ name: "No auth method defined" });
} }
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return; return;
} }
// case: google sso is enforced, but the actor is not using google sso
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
}
// case: SAML SSO is enforced, but the actor is not using SAML SSO
if ( if (
isOrgSsoEnforced && isOrgSsoEnforced &&
actorAuthMethod !== null && actorAuthMethod !== null &&
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
validateOrgSSO( validateOrgSSO(
authMethod, authMethod,
membership.orgAuthEnforced, membership.orgAuthEnforced,
membership.orgGoogleSsoAuthEnforced,
membership.bypassOrgAuthEnabled, membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole membership.role as OrgMembershipRole
); );
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
validateOrgSSO( validateOrgSSO(
authMethod, authMethod,
userProjectPermission.orgAuthEnforced, userProjectPermission.orgAuthEnforced,
userProjectPermission.orgGoogleSsoAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole userProjectPermission.orgRole
); );
@@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions {
ReadValue = "readValue", ReadValue = "readValue",
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete"
Subscribe = "subscribe"
} }
export enum ProjectPermissionCmekActions { export enum ProjectPermissionCmekActions {
@@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
Update = "update-configs" Update = "update-configs"
} }
export enum ProjectPermissionSecretEventActions {
SubscribeCreated = "subscribe-on-created",
SubscribeUpdated = "subscribe-on-updated",
SubscribeDeleted = "subscribe-on-deleted",
SubscribeImportMutations = "subscribe-on-import-mutations"
}
export enum ProjectPermissionSub { export enum ProjectPermissionSub {
Role = "role", Role = "role",
Member = "member", Member = "member",
@@ -197,7 +203,8 @@ export enum ProjectPermissionSub {
Kmip = "kmip", Kmip = "kmip",
SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningDataSources = "secret-scanning-data-sources",
SecretScanningFindings = "secret-scanning-findings", SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs" SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events"
} }
export type SecretSubjectFields = { export type SecretSubjectFields = {
@@ -205,7 +212,13 @@ export type SecretSubjectFields = {
secretPath: string; secretPath: string;
secretName?: string; secretName?: string;
secretTags?: string[]; secretTags?: string[];
eventType?: string; };
export type SecretEventSubjectFields = {
environment: string;
secretPath: string;
secretName?: string;
secretTags?: string[];
}; };
export type SecretFolderSubjectFields = { export type SecretFolderSubjectFields = {
@@ -344,7 +357,11 @@ export type ProjectPermissionSet =
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
| [
ProjectPermissionSecretEventActions,
ProjectPermissionSub.SecretEvents | (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource." "When specified, only matching conditions will be allowed to access given resource."
).optional() ).optional()
}), }),
z.object({
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe(
"Describe what action an entity can take."
),
conditions: SecretSyncConditionV2Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema ...GeneralPermissionSchema
]); ]);
@@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({
}); });
} }
const userAlias = await userAliasDAL.findOne({ let userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: UserAliasType.SAML aliasType: UserAliasType.SAML
@@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; let newUser: TUsers | undefined;
if (serverCfg.trustSamlEmails) { newUser = await userDAL.findOne(
newUser = await userDAL.findOne( {
{ email,
email, isEmailVerified: true
isEmailVerified: true },
}, tx
tx );
);
}
if (!newUser) { if (!newUser) {
const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL);
@@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({
); );
} }
await userAliasDAL.create( userAlias = await userAliasDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
aliasType: UserAliasType.SAML, aliasType: UserAliasType.SAML,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email] : [],
orgId orgId,
isEmailVerified: serverCfg.trustSamlEmails
}, },
tx tx
); );
@@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({
} }
await licenseService.updateSubscriptionOrgMemberCount(organization.id); await licenseService.updateSubscriptionOrgMemberCount(organization.id);
const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified);
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
@@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({
organizationSlug: organization.slug, organizationSlug: organization.slug,
authMethod: authProvider, authMethod: authProvider,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
aliasId: userAlias.id,
authType: UserAliasType.SAML, authType: UserAliasType.SAML,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
@@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({
await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); await samlConfigDAL.update({ orgId }, { lastUsed: new Date() });
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import { import {
SecretApprovalRequestsSchema, SecretApprovalRequestsSchema,
TableName, TableName,
TOrgMemberships,
TSecretApprovalRequests, TSecretApprovalRequests,
TSecretApprovalRequestsSecrets, TSecretApprovalRequestsSecrets,
TUserGroupMembership, TUserGroupMembership,
@@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`secretApprovalReviewerUser.id` `secretApprovalReviewerUser.id`
) )
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.SecretApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`secretApprovalPolicyGroupApproverUser.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.select(selectAllTableCols(TableName.SecretApprovalRequest)) .select(selectAllTableCols(TableName.SecretApprovalRequest))
.select( .select(
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
@@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment), tx.ref("projectId").withSchema(TableName.Environment),
@@ -211,9 +234,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerLastName: lastName, reviewerLastName: lastName,
reviewerUsername: username, reviewerUsername: username,
reviewerFirstName: firstName, reviewerFirstName: firstName,
reviewerComment: comment reviewerComment: comment,
reviewerIsOrgMembershipActive: isOrgMembershipActive
}) => }) =>
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined userId
? {
userId,
status,
email,
firstName,
lastName,
username,
comment: comment ?? "",
isOrgMembershipActive
}
: undefined
}, },
{ {
key: "approverUserId", key: "approverUserId",
@@ -223,13 +258,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email, approverEmail: email,
approverUsername: username, approverUsername: username,
approverLastName: lastName, approverLastName: lastName,
approverFirstName: firstName approverFirstName: firstName,
approverIsOrgMembershipActive: isOrgMembershipActive
}) => ({ }) => ({
userId, userId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
isOrgMembershipActive
}) })
}, },
{ {
@@ -240,13 +277,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email, approverGroupEmail: email,
approverGroupUsername: username, approverGroupUsername: username,
approverGroupLastName: lastName, approverGroupLastName: lastName,
approverGroupFirstName: firstName approverGroupFirstName: firstName,
approverGroupIsOrgMembershipActive: isOrgMembershipActive
}) => ({ }) => ({
userId, userId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
isOrgMembershipActive
}) })
}, },
{ {
@@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
if (!secretApprovalRequest) if (!secretApprovalRequest)
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` });
@@ -952,13 +953,39 @@ export const secretApprovalRequestServiceFactory = ({
if (!folder) { if (!folder) {
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
} }
const { secrets } = mergeStatus;
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
secretPath: folder.path, secretPath: folder.path,
environmentSlug: folder.environmentSlug, environmentSlug: folder.environmentSlug,
actorId, actorId,
actor actor,
event: {
created: secrets.created.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
updated: secrets.updated.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
deleted: secrets.deleted.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
}))
}
}); });
if (isSoftEnforcement) { if (isSoftEnforcement) {
@@ -1421,6 +1448,7 @@ export const secretApprovalRequestServiceFactory = ({
const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = []; const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = [];
const commitTagIds: Record<string, string[]> = {}; const commitTagIds: Record<string, string[]> = {};
const existingTagIds: Record<string, string[]> = {};
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
@@ -1486,6 +1514,11 @@ export const secretApprovalRequestServiceFactory = ({
type: SecretType.Shared type: SecretType.Shared
})) }))
); );
secretsToUpdateStoredInDB.forEach((el) => {
if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id);
});
if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length)
throw new NotFoundError({ throw new NotFoundError({
message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}`
@@ -1529,7 +1562,10 @@ export const secretApprovalRequestServiceFactory = ({
secretMetadata secretMetadata
}) => { }) => {
const secretId = updatingSecretsGroupByKey[secretKey][0].id; const secretId = updatingSecretsGroupByKey[secretKey][0].id;
if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds; if (tagIds?.length || existingTagIds[secretKey]?.length) {
commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey];
}
return { return {
...latestSecretVersions[secretId], ...latestSecretVersions[secretId],
secretMetadata, secretMetadata,
+1
View File
@@ -2517,6 +2517,7 @@ export const SecretSyncs = {
}, },
RENDER: { RENDER: {
serviceId: "The ID of the Render service to sync secrets to.", serviceId: "The ID of the Render service to sync secrets to.",
environmentGroupId: "The ID of the Render environment group to sync secrets to.",
scope: "The Render scope that secrets should be synced to.", scope: "The Render scope that secrets should be synced to.",
type: "The Render resource type to sync secrets to." type: "The Render resource type to sync secrets to."
}, },
+4 -4
View File
@@ -1,11 +1,11 @@
/** /**
* Safely retrieves a value from a nested object using dot notation path * Safely retrieves a value from a nested object using dot notation path
*/ */
export const getStringValueByDot = ( export const getValueByDot = (
obj: Record<string, unknown> | null | undefined, obj: Record<string, unknown> | null | undefined,
path: string, path: string,
defaultValue?: string defaultValue?: string | number | boolean
): string | undefined => { ): string | number | boolean | undefined => {
// Handle null or undefined input // Handle null or undefined input
if (!obj) { if (!obj) {
return defaultValue; return defaultValue;
@@ -26,7 +26,7 @@ export const getStringValueByDot = (
current = (current as Record<string, unknown>)[part]; current = (current as Record<string, unknown>)[part];
} }
if (typeof current !== "string") { if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
return defaultValue; return defaultValue;
} }
@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
if (slackConfig) { if (slackConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) { if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
await sendSlackNotification({ await sendSlackNotification({
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
} }
if (microsoftTeamsConfig) { if (microsoftTeamsConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) { if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
microsoftTeamsConfig.accessRequestChannels microsoftTeamsConfig.accessRequestChannels
+18 -1
View File
@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
export enum TriggerFeature { export enum TriggerFeature {
SECRET_APPROVAL = "secret-approval", SECRET_APPROVAL = "secret-approval",
ACCESS_REQUEST = "access-request" ACCESS_REQUEST = "access-request",
ACCESS_REQUEST_UPDATED = "access-request-updated"
} }
export type TNotification = export type TNotification =
@@ -34,6 +35,22 @@ export type TNotification =
approvalUrl: string; approvalUrl: string;
note?: string; note?: string;
}; };
}
| {
type: TriggerFeature.ACCESS_REQUEST_UPDATED;
payload: {
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
projectName: string;
permissions: string[];
approvalUrl: string;
editNote?: string;
editorFullName?: string;
editorEmail?: string;
};
}; };
export type TTriggerWorkflowNotificationDTO = { export type TTriggerWorkflowNotificationDTO = {
+6 -4
View File
@@ -560,8 +560,7 @@ export const registerRoutes = async (
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL, auditLogStreamDAL
eventBusService
}); });
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
@@ -727,7 +726,8 @@ export const registerRoutes = async (
permissionService, permissionService,
groupProjectDAL, groupProjectDAL,
smtpService, smtpService,
projectMembershipDAL projectMembershipDAL,
userAliasDAL
}); });
const totpService = totpServiceFactory({ const totpService = totpServiceFactory({
@@ -1121,7 +1121,9 @@ export const registerRoutes = async (
resourceMetadataDAL, resourceMetadataDAL,
folderCommitService, folderCommitService,
secretSyncQueue, secretSyncQueue,
reminderService reminderService,
eventBusService,
licenseService
}); });
const projectService = projectServiceFactory({ const projectService = projectServiceFactory({
+1 -10
View File
@@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
email: z.string().email().trim(), email: z.string().email().trim(),
password: z.string().trim(), password: z.string().trim(),
firstName: z.string().trim(), firstName: z.string().trim(),
lastName: z.string().trim().optional(), lastName: z.string().trim().optional()
protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(),
publicKey: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
encryptedPrivateKeyIV: z.string().trim(),
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider)
return services; return services;
} }
}); });
server.route({
method: "GET",
url: `/:connectionId/environment-groups`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z
.object({
id: z.string(),
name: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission);
return groups;
}
});
}; };
+1 -1
View File
@@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: () => ({ message: "Authenticated" as const }) handler: () => ({ message: "Authenticated" as const })
}); });
+8 -10
View File
@@ -5,8 +5,8 @@ import { z } from "zod";
import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { ActionProjectType, ProjectType } from "@app/db/schemas";
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
import { EventRegisterSchema } from "@app/ee/services/event/types"; import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs"; import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
@@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => {
req.body.register.forEach((r) => { req.body.register.forEach((r) => {
const fields = { const fields = {
environment: r.conditions?.environmentSlug ?? "", environment: r.conditions?.environmentSlug ?? "",
secretPath: r.conditions?.secretPath ?? "/", secretPath: r.conditions?.secretPath ?? "/"
eventType: r.event
}; };
const allowed = info.permission.can( const action = Mappings.BusEventToAction(r.event);
ProjectPermissionSecretActions.Subscribe,
subject(ProjectPermissionSub.Secrets, fields) const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields));
);
if (!allowed) { if (!allowed) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
name: "PermissionDenied", name: "PermissionDenied",
message: `You are not allowed to subscribe on secrets`, message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`,
details: { details: {
event: fields.eventType, action,
environmentSlug: fields.environment, environmentSlug: fields.environment,
secretPath: fields.secretPath secretPath: fields.secretPath
} }
@@ -479,4 +479,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
return { identityMemberships }; return { identityMemberships };
} }
}); });
server.route({
method: "GET",
url: "/details",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
identityDetails: z.object({
organization: z.object({
id: z.string(),
name: z.string(),
slug: z.string()
})
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN], { requireOrg: false }),
handler: async (req) => {
const organization = await server.services.org.findIdentityOrganization(req.permission.id);
return { identityDetails: { organization } };
}
});
}; };
@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
name: GenericResourceNameSchema.optional(), name: GenericResourceNameSchema.optional(),
slug: slugSchema({ max: 64 }).optional(), slug: slugSchema({ max: 64 }).optional(),
authEnforced: z.boolean().optional(), authEnforced: z.boolean().optional(),
googleSsoAuthEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional(), scimEnabled: z.boolean().optional(),
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
enforceMfa: z.boolean().optional(), enforceMfa: z.boolean().optional(),
@@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
firstName: true, firstName: true,
lastName: true, lastName: true,
id: true id: true
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), })
.merge(UserEncryptionKeysSchema.pick({ publicKey: true }))
.extend({
isOrgMembershipActive: z.boolean()
}),
project: SanitizedProjectSchema.pick({ name: true, id: true }), project: SanitizedProjectSchema.pick({ name: true, id: true }),
roles: z.array( roles: z.array(
z.object({ z.object({
+9 -2
View File
@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
try { try {
// @ts-expect-error this is because this is express type and not fastify // @ts-expect-error this is because this is express type and not fastify
const callbackPort = req.session.get("callbackPort"); const callbackPort = req.session.get("callbackPort");
// @ts-expect-error this is because this is express type and not fastify
const orgSlug = req.session.get("orgSlug");
const email = profile?.emails?.[0]?.value; const email = profile?.emails?.[0]?.value;
if (!email) if (!email)
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
firstName: profile?.name?.givenName || "", firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "", lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE, authMethod: AuthMethod.GOOGLE,
callbackPort callbackPort,
orgSlug
}); });
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
querystring: z.object({ querystring: z.object({
callback_port: z.string().optional(), callback_port: z.string().optional(),
org_slug: z.string().optional(),
is_admin_login: z is_admin_login: z
.string() .string()
.optional() .optional()
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
}, },
preValidation: [ preValidation: [
async (req, res) => { async (req, res) => {
const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query;
// ensure fresh session state per login attempt // ensure fresh session state per login attempt
await req.session.regenerate(); await req.session.regenerate();
if (callbackPort) { if (callbackPort) {
req.session.set("callbackPort", callbackPort); req.session.set("callbackPort", callbackPort);
} }
if (orgSlug) {
req.session.set("orgSlug", orgSlug);
}
if (isAdminLogin) { if (isAdminLogin) {
req.session.set("isAdminLogin", isAdminLogin); req.session.set("isAdminLogin", isAdminLogin);
} }
+2 -2
View File
@@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
username: z.string().trim() token: z.string().trim()
}), }),
response: { response: {
200: z.object({}) 200: z.object({})
} }
}, },
handler: async (req) => { handler: async (req) => {
await server.services.user.sendEmailVerificationCode(req.body.username); await server.services.user.sendEmailVerificationCode(req.body.token);
return {}; return {};
} }
}); });
@@ -1,5 +1,3 @@
import { createAppAuth } from "@octokit/auth-app";
import { request } from "@octokit/request";
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https"; import https from "https";
import RE2 from "re2"; import RE2 from "re2";
@@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { request as httpRequest } from "@app/lib/config/request"; import { request as httpRequest } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async <T>(
); );
}; };
export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { export const getGitHubAppAuthToken = async (
appConnection: TGitHubConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const appCfg = getConfig(); const appCfg = getConfig();
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
if (!appId || !appPrivateKey) { if (!appId || !appPrivateKey) {
throw new InternalServerError({ throw new InternalServerError({
@@ -125,33 +127,65 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
}); });
} }
appPrivateKey = appPrivateKey
.split("\n")
.map((line) => line.trim())
.join("\n");
if (appConnection.method !== GitHubConnectionMethod.App) { if (appConnection.method !== GitHubConnectionMethod.App) {
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
} }
const appAuth = createAppAuth({ const now = Math.floor(Date.now() / 1000);
appId, const payload = {
privateKey: appPrivateKey, iat: now,
installationId: appConnection.credentials.installationId, exp: now + 5 * 60,
request: request.defaults({ iss: appId
baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` };
})
});
const { token } = await appAuth({ type: "installation" }); const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" });
return token;
const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection);
const { installationId } = appConnection.credentials;
const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>(
appConnection,
gatewayService,
{
url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`,
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${appJwt}`,
"X-GitHub-Api-Version": "2022-11-28"
}
}
);
return response.data.token;
};
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
if (!linkHeader) return {};
const links: Record<string, string> = {};
const segments = linkHeader.split(",");
const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/);
for (const segment of segments) {
const match = re.exec(segment.trim());
if (match) {
const url = match[1];
const rel = match[2];
links[rel] = url;
}
}
return links;
}; };
function extractNextPageUrl(linkHeader: string | undefined): string | null { function extractNextPageUrl(linkHeader: string | undefined): string | null {
if (!linkHeader) return null; const links = parseGitHubLinkHeader(linkHeader);
return links.next || null;
const links = linkHeader.split(",");
const nextLink = links.find((link) => link.includes('rel="next"'));
if (!nextLink) return null;
const match = new RE2(/<([^>]+)>/).exec(nextLink);
return match ? match[1] : null;
} }
export const makePaginatedGitHubRequest = async <T, R = T[]>( export const makePaginatedGitHubRequest = async <T, R = T[]>(
@@ -163,28 +197,86 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
const { credentials, method } = appConnection; const { credentials, method } = appConnection;
const token = const token =
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); method === GitHubConnectionMethod.OAuth
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; ? credentials.accessToken
: await getGitHubAppAuthToken(appConnection, gatewayService);
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl);
initialUrlObj.searchParams.set("per_page", "100");
let results: T[] = []; let results: T[] = [];
let i = 0; const maxIterations = 1000;
while (url && i < 1000) { // Make initial request to get link header
// eslint-disable-next-line no-await-in-loop const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, { url: initialUrlObj.toString(),
url, method: "GET",
method: "GET", headers: {
headers: { Accept: "application/vnd.github+json",
Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28"
"X-GitHub-Api-Version": "2022-11-28" }
} });
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
results = results.concat(items); results = results.concat(firstPageItems);
url = extractNextPageUrl(response.headers.link as string | undefined); const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
i += 1; const lastPageUrl = linkHeader.last;
// If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last
if (lastPageUrl) {
const lastPageParam = new URL(lastPageUrl).searchParams.get("page");
const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1;
const pageRequests: Promise<AxiosResponse<R>>[] = [];
for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) {
const pageUrlObj = new URL(initialUrlObj.toString());
pageUrlObj.searchParams.set("page", pageNum.toString());
pageRequests.push(
requestWithGitHubGateway<R>(appConnection, gatewayService, {
url: pageUrlObj.toString(),
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
})
);
}
const responses = await Promise.all(pageRequests);
for (const response of responses) {
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
}
} else {
// Fallback in case last link isn't present
let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined);
let i = 1;
while (url && i < maxIterations) {
// eslint-disable-next-line no-await-in-loop
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
url,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
url = extractNextPageUrl(response.headers.link as string | undefined);
i += 1;
}
} }
return results; return results;
@@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { RenderConnectionMethod } from "./render-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums";
import { import {
TRawRenderEnvironmentGroup,
TRawRenderService, TRawRenderService,
TRenderConnection, TRenderConnection,
TRenderConnectionConfig, TRenderConnectionConfig,
TRenderEnvironmentGroup,
TRenderService TRenderService
} from "./render-connection-types"; } from "./render-connection-types";
@@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
const perPage = 100; const perPage = 100;
let cursor; let cursor;
let maxIterations = 10;
while (hasMorePages) { while (hasMorePages) {
if (maxIterations <= 0) break;
const res: TRawRenderService[] = ( const res: TRawRenderService[] = (
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
params: new URLSearchParams({ params: new URLSearchParams({
@@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
} else { } else {
cursor = res[res.length - 1].cursor; cursor = res[res.length - 1].cursor;
} }
maxIterations -= 1;
} }
return services; return services;
@@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect
return inputCredentials; return inputCredentials;
}; };
export const listRenderEnvironmentGroups = async (
appConnection: TRenderConnection
): Promise<TRenderEnvironmentGroup[]> => {
const {
credentials: { apiKey }
} = appConnection;
const groups: TRenderEnvironmentGroup[] = [];
let hasMorePages = true;
const perPage = 100;
let cursor;
let maxIterations = 10;
while (hasMorePages) {
if (maxIterations <= 0) break;
const res: TRawRenderEnvironmentGroup[] = (
await request.get<TRawRenderEnvironmentGroup[]>(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, {
params: new URLSearchParams({
...(cursor ? { cursor: String(cursor) } : {}),
limit: String(perPage)
}),
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
"Accept-Encoding": "application/json"
}
})
).data;
res.forEach((item) => {
groups.push({
name: item.envGroup.name,
id: item.envGroup.id
});
});
if (res.length < perPage) {
hasMorePages = false;
} else {
cursor = res[res.length - 1].cursor;
}
maxIterations -= 1;
}
return groups;
};
@@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { listRenderServices } from "./render-connection-fns"; import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns";
import { TRenderConnection } from "./render-connection-types"; import { TRenderConnection } from "./render-connection-types";
type TGetAppConnectionFunc = ( type TGetAppConnectionFunc = (
@@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc)
} }
}; };
const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor);
try {
const groups = await listRenderEnvironmentGroups(appConnection);
return groups;
} catch (error) {
logger.error(error, "Failed to list environment groups for Render connection");
return [];
}
};
return { return {
listServices listServices,
listEnvironmentGroups
}; };
}; };
@@ -33,3 +33,16 @@ export type TRawRenderService = {
name: string; name: string;
}; };
}; };
export type TRenderEnvironmentGroup = {
name: string;
id: string;
};
export type TRawRenderEnvironmentGroup = {
cursor: string;
envGroup: {
id: string;
name: string;
};
};
@@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
}; };
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type); const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig(); const appCfg = getConfig();
const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS);
@@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
type, type,
userId, userId,
orgId, orgId,
triesLeft: tkCfg?.triesLeft triesLeft: tkCfg?.triesLeft,
aliasId
}, },
tx tx
); );
@@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = {
type: TokenType; type: TokenType;
userId: string; userId: string;
orgId?: string; orgId?: string;
aliasId?: string;
}; };
export type TCreateOrgInviteTokenDTO = { export type TCreateOrgInviteTokenDTO = {
@@ -448,15 +448,41 @@ export const authLoginServiceFactory = ({
// Check if the user actually has access to the specified organization. // Check if the user actually has access to the specified organization.
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited");
const selectedOrg = await orgDAL.findById(organizationId); const selectedOrg = await orgDAL.findById(organizationId);
if (!hasOrganizationMembership) { // Check if authEnforced is true, if that's the case, throw an error
if (selectedOrg.authEnforced) {
throw new BadRequestError({
message: "Authentication is required by your organization before you can log in."
});
}
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}` message: `User does not have access to the organization named ${selectedOrg?.name}`
}); });
} }
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
if (!canBypass) {
throw new ForbiddenRequestError({
message: "Google SSO is enforced for this organization. Please use Google SSO to login.",
error: "GoogleSsoEnforced"
});
}
}
if (decodedToken.authMethod === AuthMethod.GOOGLE) {
await orgDAL.updateById(selectedOrg.id, {
googleSsoAuthLastUsed: new Date()
});
}
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
@@ -502,7 +528,8 @@ export const authLoginServiceFactory = ({
selectedOrg.authEnforced && selectedOrg.authEnforced &&
selectedOrg.bypassOrgAuthEnabled && selectedOrg.bypassOrgAuthEnabled &&
!isAuthMethodSaml(decodedToken.authMethod) && !isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC decodedToken.authMethod !== AuthMethod.OIDC &&
decodedToken.authMethod !== AuthMethod.GOOGLE
) { ) {
await auditLogService.createAuditLog({ await auditLogService.createAuditLog({
orgId: organizationId, orgId: organizationId,
@@ -705,7 +732,7 @@ export const authLoginServiceFactory = ({
/* /*
* OAuth2 login for google,github, and other oauth2 provider * OAuth2 login for google,github, and other oauth2 provider
* */ * */
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => {
// akhilmhdh: case sensitive email resolution // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserByUsername(email); const usersByUsername = await userDAL.findUserByUsername(email);
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
@@ -759,6 +786,8 @@ export const authLoginServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
let orgId = "";
let orgName: undefined | string;
if (!user) { if (!user) {
// Create a new user based on oAuth // Create a new user based on oAuth
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
@@ -784,7 +813,6 @@ export const authLoginServiceFactory = ({
}); });
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
let orgId = "";
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
if (!defaultOrg) { if (!defaultOrg) {
throw new BadRequestError({ throw new BadRequestError({
@@ -824,11 +852,39 @@ export const authLoginServiceFactory = ({
} }
} }
if (!orgId && orgSlug) {
const org = await orgDAL.findOrgBySlug(orgSlug);
if (org) {
// checks for the membership and only sets the orgId / orgName if the user is a member of the specified org
const orgMembership = await orgDAL.findMembership({
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
[`${TableName.OrgMembership}.orgId` as "orgId"]: org.id,
[`${TableName.OrgMembership}.isActive` as "isActive"]: true,
[`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted
});
if (orgMembership) {
orgId = org.id;
orgName = org.name;
}
}
}
const isUserCompleted = user.isAccepted; const isUserCompleted = user.isAccepted;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
...(orgId && orgSlug && orgName !== undefined
? {
organizationId: orgId,
organizationName: orgName,
organizationSlug: orgSlug
}
: {}),
username: user.username, username: user.username,
email: user.email, email: user.email,
isEmailVerified: user.isEmailVerified, isEmailVerified: user.isEmailVerified,
@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
lastName?: string; lastName?: string;
authMethod: AuthMethod; authMethod: AuthMethod;
callbackPort?: string; callbackPort?: string;
orgSlug?: string;
}; };
export type TOauthTokenExchangeDTO = { export type TOauthTokenExchangeDTO = {
@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
`${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.GroupProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select( .select(
db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("id").withSchema(TableName.UserGroupMembership),
db.ref("createdAt").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership),
@@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project) db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
) )
.where({ isGhost: false }); .where({ isGhost: false });
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id, id,
userId, userId,
projectName, projectName,
createdAt createdAt,
isActive
}) => ({ }) => ({
isGroupMember: true, isGroupMember: true,
id, id,
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id: projectId, id: projectId,
name: projectName name: projectName
}, },
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
createdAt createdAt
}), }),
key: "id", key: "id",
@@ -21,7 +21,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { getStringValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({
if (identityJwtAuth.boundClaims) { if (identityJwtAuth.boundClaims) {
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey]; const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({
} }
// handle both single and multi-valued claims // handle both single and multi-valued claims
if ( if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry))
) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: claim mismatch for field ${claimKey}` message: `Access denied: claim mismatch for field ${claimKey}`
}); });
@@ -1,7 +1,16 @@
import picomatch from "picomatch"; import picomatch from "picomatch";
export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => {
policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); if (typeof fieldValue === "boolean") {
return fieldValue === (policyValue === "true");
}
if (typeof fieldValue === "number") {
return fieldValue === parseInt(policyValue, 10);
}
return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
};
export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => { export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => {
if (Array.isArray(fieldValue)) { if (Array.isArray(fieldValue)) {
@@ -22,7 +22,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { getStringValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({
if (identityOidcAuth.boundClaims) { if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey]; const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({
if (identityOidcAuth.claimMetadataMapping) { if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey]; const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field` message: `Access denied: token has no ${claimKey} field`
}); });
} }
filteredClaims[permissionKey] = value; filteredClaims[permissionKey] = value.toString();
}); });
} }
@@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => {
}; };
} }
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const adaptiveCard = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Updated access approval request pending for review",
weight: "Bolder",
size: "Large"
},
{
type: "TextBlock",
text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`,
wrap: true
},
{
type: "TextBlock",
text: `The following permissions are requested: ${payload.permissions.join(", ")}`,
wrap: true
},
payload.editNote
? {
type: "TextBlock",
text: `**Editor Note**: ${payload.editNote}`,
wrap: true
}
: null
].filter(Boolean),
actions: [
{
type: "Action.OpenUrl",
title: "View request in Infisical",
url: payload.approvalUrl
}
]
};
return {
adaptiveCard
};
}
default: { default: {
throw new BadRequestError({ throw new BadRequestError({
message: "Teams notification type not supported." message: "Teams notification type not supported."
+21 -1
View File
@@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => {
} }
}; };
const findIdentityOrganization = async (
identityId: string
): Promise<{ id: string; name: string; slug: string; role: string }> => {
try {
const org = await db
.replicaNode()(TableName.IdentityOrgMembership)
.where({ identityId })
.join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`)
.select(db.ref("id").withSchema(TableName.Organization).as("id"))
.select(db.ref("name").withSchema(TableName.Organization).as("name"))
.select(db.ref("slug").withSchema(TableName.Organization).as("slug"))
.select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role"));
return org?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find identity organization" });
}
};
return withTransaction(db, { return withTransaction(db, {
...orgOrm, ...orgOrm,
findOrgByProjectId, findOrgByProjectId,
@@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => {
updateMembershipById, updateMembershipById,
deleteMembershipById, deleteMembershipById,
deleteMembershipsById, deleteMembershipsById,
updateMembership updateMembership,
findIdentityOrganization
}); });
}; };
+1
View File
@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
authEnforced: true, authEnforced: true,
googleSsoAuthEnforced: true,
scimEnabled: true, scimEnabled: true,
kmsDefaultKeyId: true, kmsDefaultKeyId: true,
defaultMembershipRole: true, defaultMembershipRole: true,
+42
View File
@@ -198,6 +198,15 @@ export const orgServiceFactory = ({
// Filter out orgs where the membership object is an invitation // Filter out orgs where the membership object is an invitation
return orgs.filter((org) => org.userStatus !== "invited"); return orgs.filter((org) => org.userStatus !== "invited");
}; };
/*
* Get all organization an identity is part of
* */
const findIdentityOrganization = async (identityId: string) => {
const org = await orgDAL.findIdentityOrganization(identityId);
return org;
};
/* /*
* Get all workspace members * Get all workspace members
* */ * */
@@ -355,6 +364,7 @@ export const orgServiceFactory = ({
name, name,
slug, slug,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
defaultMembershipRoleSlug, defaultMembershipRoleSlug,
enforceMfa, enforceMfa,
@@ -421,6 +431,21 @@ export const orgServiceFactory = ({
} }
} }
if (googleSsoAuthEnforced !== undefined) {
if (!plan.enforceGoogleSSO) {
throw new BadRequestError({
message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO."
});
}
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
}
if (authEnforced && googleSsoAuthEnforced) {
throw new BadRequestError({
message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time."
});
}
if (authEnforced) { if (authEnforced) {
const samlCfg = await samlConfigDAL.findOne({ const samlCfg = await samlConfigDAL.findOne({
orgId, orgId,
@@ -451,6 +476,21 @@ export const orgServiceFactory = ({
} }
} }
if (googleSsoAuthEnforced) {
if (googleSsoAuthEnforced && currentOrg.authEnforced) {
throw new BadRequestError({
message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled."
});
}
if (!currentOrg.googleSsoAuthLastUsed) {
throw new BadRequestError({
message:
"Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization."
});
}
}
let defaultMembershipRole: string | undefined; let defaultMembershipRole: string | undefined;
if (defaultMembershipRoleSlug) { if (defaultMembershipRoleSlug) {
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
@@ -465,6 +505,7 @@ export const orgServiceFactory = ({
name, name,
slug: slug ? slugify(slug) : undefined, slug: slug ? slugify(slug) : undefined,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
defaultMembershipRole, defaultMembershipRole,
enforceMfa, enforceMfa,
@@ -1403,6 +1444,7 @@ export const orgServiceFactory = ({
findOrganizationById, findOrganizationById,
findAllOrgMembers, findAllOrgMembers,
findAllOrganizationOfUser, findAllOrganizationOfUser,
findIdentityOrganization,
inviteUserToOrganization, inviteUserToOrganization,
verifyUserToOrg, verifyUserToOrg,
updateOrg, updateOrg,
+1
View File
@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
name: string; name: string;
slug: string; slug: string;
authEnforced: boolean; authEnforced: boolean;
googleSsoAuthEnforced: boolean;
scimEnabled: boolean; scimEnabled: boolean;
defaultMembershipRoleSlug: string; defaultMembershipRoleSlug: string;
enforceMfa: boolean; enforceMfa: boolean;
@@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({
} }
} }
const envs = await projectEnvDAL.find({ projectId });
const project = await projectDAL.findById(projectId);
const plan = await licenseService.getPlan(project.orgId);
if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) {
// case: limit imposed on number of environments allowed
// case: number of environments used exceeds the number of environments allowed
throw new BadRequestError({
message:
"Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments."
});
}
const env = await projectEnvDAL.transaction(async (tx) => { const env = await projectEnvDAL.transaction(async (tx) => {
if (position) { if (position) {
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx); const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);
@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join(TableName.OrgMembership, (qb) => {
qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn(
`${TableName.OrgMembership}.orgId`,
"=",
`${TableName.Project}.orgId`
);
})
.where((qb) => { .where((qb) => {
if (filter.usernames) { if (filter.usernames) {
void qb.whereIn("username", filter.usernames); void qb.whereIn("username", filter.usernames);
@@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project) db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
) )
.where({ isGhost: false }) .where({ isGhost: false })
.orderBy(`${TableName.Users}.username` as "username"); .orderBy(`${TableName.Users}.username` as "username");
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
id, id,
userId, userId,
projectName, projectName,
createdAt createdAt,
isActive
}) => ({ }) => ({
id, id,
userId, userId,
projectId, projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, user: {
email,
username,
firstName,
lastName,
id: userId,
publicKey,
isGhost,
isOrgMembershipActive: isActive
},
project: { project: {
id: projectId, id: projectId,
name: projectName name: projectName
@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
// projectMembers[0].project
if (includeGroupMembers) { if (includeGroupMembers) {
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
const allMembers = [ const allMembers = [
@@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({
projectId, projectId,
environmentSlug: environment, environmentSlug: environment,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath,
environment
}
}
}); });
} }
@@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({
projectId, projectId,
environmentSlug: environment, environmentSlug: environment,
actor, actor,
actorId actorId,
event: {
importMutation: {
secretPath,
environment
}
}
}); });
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
@@ -1,4 +1,5 @@
import AWS, { AWSError } from "aws-sdk"; import AWS, { AWSError } from "aws-sdk";
import handlebars from "handlebars";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
@@ -34,18 +35,51 @@ const sleep = async () =>
setTimeout(resolve, 1000); setTimeout(resolve, 1000);
}); });
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => { const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => {
if (!keySchema || !keySchema.includes("/")) return path;
if (keySchema.startsWith("/")) {
throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false });
}
const keySchemaSegments = handlebars
.compile(keySchema)({
environment,
secretKey: "{{secretKey}}"
})
.split("/");
const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1);
if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) {
throw new SecretSyncError({
message: "Key schema cannot contain '/' after {{secretKey}}",
shouldRetry: false
});
}
return `${path}${pathSegments.join("/")}/`;
};
const getParametersByPath = async (
ssm: AWS.SSM,
path: string,
keySchema: string | undefined,
environment: string
): Promise<TAWSParameterStoreRecord> => {
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {}; const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
let hasNext = true; let hasNext = true;
let nextToken: string | undefined; let nextToken: string | undefined;
let attempt = 0; let attempt = 0;
const fullPath = getFullPath({ path, keySchema, environment });
while (hasNext) { while (hasNext) {
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const parameters = await ssm const parameters = await ssm
.getParametersByPath({ .getParametersByPath({
Path: path, Path: fullPath,
Recursive: false, Recursive: false,
WithDecryption: true, WithDecryption: true,
MaxResults: BATCH_SIZE, MaxResults: BATCH_SIZE,
@@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
parameters.Parameters.forEach((parameter) => { parameters.Parameters.forEach((parameter) => {
if (parameter.Name) { if (parameter.Name) {
// no leading slash if path is '/' // no leading slash if path is '/'
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreSecretsRecord[secKey] = parameter; awsParameterStoreSecretsRecord[secKey] = parameter;
} }
}); });
@@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
return awsParameterStoreSecretsRecord; return awsParameterStoreSecretsRecord;
}; };
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => { const getParameterMetadataByPath = async (
ssm: AWS.SSM,
path: string,
keySchema: string | undefined,
environment: string
): Promise<TAWSParameterStoreMetadataRecord> => {
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {}; const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
let hasNext = true; let hasNext = true;
let nextToken: string | undefined; let nextToken: string | undefined;
let attempt = 0; let attempt = 0;
const fullPath = getFullPath({ path, keySchema, environment });
while (hasNext) { while (hasNext) {
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
{ {
Key: "Path", Key: "Path",
Option: "OneLevel", Option: "OneLevel",
Values: [path] Values: [fullPath]
} }
] ]
}) })
@@ -112,7 +153,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
parameters.Parameters.forEach((parameter) => { parameters.Parameters.forEach((parameter) => {
if (parameter.Name) { if (parameter.Name) {
// no leading slash if path is '/' // no leading slash if path is '/'
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreMetadataRecord[secKey] = parameter; awsParameterStoreMetadataRecord[secKey] = parameter;
} }
}); });
@@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = {
const ssm = await getSSM(secretSync); const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path); const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord( const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
ssm, ssm,
@@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = {
await deleteParametersBatch(ssm, parametersToDelete); await deleteParametersBatch(ssm, parametersToDelete);
}, },
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => { getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
const { destinationConfig } = secretSync; const { destinationConfig, syncOptions, environment } = secretSync;
const ssm = await getSSM(secretSync); const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
return Object.fromEntries( return Object.fromEntries(
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }]) Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
); );
}, },
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync; const { destinationConfig, syncOptions, environment } = secretSync;
const ssm = await getSSM(secretSync); const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); const awsParameterStoreSecretsRecord = await getParametersByPath(
ssm,
destinationConfig.path,
syncOptions.keySchema,
environment!.slug
);
const parametersToDelete: AWS.SSM.Parameter[] = []; const parametersToDelete: AWS.SSM.Parameter[] = [];
@@ -207,7 +207,7 @@ export const GithubSyncFns = {
const token = const token =
connection.method === GitHubConnectionMethod.OAuth connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken ? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection); : await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
const publicKey = await getPublicKey(secretSync, gatewayService, token); const publicKey = await getPublicKey(secretSync, gatewayService, token);
@@ -264,7 +264,7 @@ export const GithubSyncFns = {
const token = const token =
connection.method === GitHubConnectionMethod.OAuth connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken ? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection); : await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
@@ -1,5 +1,6 @@
export enum RenderSyncScope { export enum RenderSyncScope {
Service = "service" Service = "service",
EnvironmentGroup = "environment-group"
} }
export enum RenderSyncType { export enum RenderSyncType {
@@ -1,11 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { isAxiosError } from "axios"; import { AxiosRequestConfig, isAxiosError } from "axios";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { RenderSyncScope } from "./render-sync-enums";
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
@@ -27,6 +29,80 @@ const makeRequestWithRetry = async <T>(requestFn: () => Promise<T>, attempt = 0)
} }
}; };
async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) {
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
method: "GET",
headers: {
Authorization: `Bearer ${input.token}`,
Accept: "application/json"
}
};
switch (input.destination.scope) {
case RenderSyncScope.Service: {
req.url = `/services/${input.destination.serviceId}/env-vars`;
const allSecrets: TRenderSecret[] = [];
let cursor: string | undefined;
do {
// eslint-disable-next-line @typescript-eslint/no-loop-func
const { data } = await makeRequestWithRetry(() =>
request.request<
{
envVar: {
key: string;
value: string;
};
cursor: string;
}[]
>({
...req,
params: {
cursor
}
})
);
const secrets = data.map((item) => ({
key: item.envVar.key,
value: item.envVar.value
}));
allSecrets.push(...secrets);
if (data.length > 0 && data[data.length - 1]?.cursor) {
cursor = data[data.length - 1].cursor;
} else {
cursor = undefined;
}
} while (cursor);
return allSecrets;
}
case RenderSyncScope.EnvironmentGroup: {
req.url = `/env-groups/${input.destination.environmentGroupId}`;
const res = await makeRequestWithRetry(() =>
request.request<{
envVars: {
key: string;
value: string;
}[];
}>(req)
);
return res.data.envVars.map((item) => ({
key: item.key,
value: item.value
}));
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
}
const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => { const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => {
const { const {
destinationConfig, destinationConfig,
@@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential
} }
} = secretSync; } = secretSync;
const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; const secrets = await getSecrets({
const allSecrets: TRenderSecret[] = []; destination: destinationConfig,
let cursor: string | undefined; token: apiKey
});
do { return secrets;
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const { data } = await makeRequestWithRetry(() =>
request.get<
{
envVar: {
key: string;
value: string;
};
cursor: string;
}[]
>(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
})
);
const secrets = data.map((item) => ({
key: item.envVar.key,
value: item.envVar.value
}));
allSecrets.push(...secrets);
if (data.length > 0 && data[data.length - 1]?.cursor) {
cursor = data[data.length - 1].cursor;
} else {
cursor = undefined;
}
} while (cursor);
return allSecrets;
}; };
const batchUpdateEnvironmentSecrets = async ( const batchUpdateEnvironmentSecrets = async (
@@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async (
} }
} = secretSync; } = secretSync;
await makeRequestWithRetry(() => const req: AxiosRequestConfig = {
request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
headers: { method: "PUT",
Authorization: `Bearer ${apiKey}`, headers: {
Accept: "application/json" Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
};
switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${destinationConfig.serviceId}/env-vars`,
data: envVars
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
for await (const variable of envVars) {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`,
data: {
value: variable.value
}
})
);
} }
}) break;
); }
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
};
const deleteEnvironmentSecret = async (
secretSync: TRenderSyncWithCredentials,
envVar: { key: string; value: string }
): Promise<void> => {
const {
destinationConfig,
connection: {
credentials: { apiKey }
}
} = secretSync;
const req: AxiosRequestConfig = {
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json"
}
};
switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}`
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}`
})
);
break;
}
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
}; };
const redeployService = async (secretSync: TRenderSyncWithCredentials) => { const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
@@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
} }
} = secretSync; } = secretSync;
await makeRequestWithRetry(() => const req: AxiosRequestConfig = {
request.post( baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, headers: {
{}, Authorization: `Bearer ${apiKey}`,
{ Accept: "application/json"
headers: { }
Authorization: `Bearer ${apiKey}`, };
Accept: "application/json"
} switch (destinationConfig.scope) {
case RenderSyncScope.Service: {
await makeRequestWithRetry(() =>
request.request({
...req,
method: "POST",
url: `/services/${destinationConfig.serviceId}/deploys`,
data: {}
})
);
break;
}
case RenderSyncScope.EnvironmentGroup: {
const { data } = await request.request<{ serviceLinks: { id: string }[] }>({
...req,
method: "GET",
url: `/env-groups/${destinationConfig.environmentGroupId}`
});
for await (const link of data.serviceLinks) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
await makeRequestWithRetry(() =>
request.request({
...req,
url: `/services/${link.id}/deploys`,
data: {}
})
);
} }
) break;
); }
default:
throw new BadRequestError({ message: "Unknown render sync destination scope" });
}
}; };
export const RenderSyncFns = { export const RenderSyncFns = {
@@ -169,14 +321,15 @@ export const RenderSyncFns = {
const finalEnvVars: Array<{ key: string; value: string }> = []; const finalEnvVars: Array<{ key: string; value: string }> = [];
for (const renderSecret of renderSecrets) { for (const renderSecret of renderSecrets) {
if (!(renderSecret.key in secretMap)) { if (renderSecret.key in secretMap) {
finalEnvVars.push({ finalEnvVars.push({
key: renderSecret.key, key: renderSecret.key,
value: renderSecret.value value: renderSecret.value
}); });
} }
} }
await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars);
await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el)));
if (secretSync.syncOptions.autoRedeployServices) { if (secretSync.syncOptions.autoRedeployServices) {
await redeployService(secretSync); await redeployService(secretSync);
@@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId),
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
}),
z.object({
scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
environmentGroupId: z
.string()
.min(1, "Environment Group ID is required")
.describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId),
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
}) })
]); ]);
@@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
throw new BadRequestError({ message: "Missing personal user id" }); throw new BadRequestError({ message: "Missing personal user id" });
} }
void bd.orWhere({ void bd.orWhere({
key: el.key, [`${TableName.SecretV2}.key` as "key"]: el.key,
type: el.type, [`${TableName.SecretV2}.type` as "type"]: el.type,
userId: el.type === SecretType.Personal ? el.userId : null [`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null
}); });
}); });
}) })
@@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
`${TableName.SecretV2}.id`, `${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId` `${TableName.SecretRotationV2SecretMapping}.secretId`
) )
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.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"))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.select(selectAllTableCols(TableName.SecretV2)) .select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
return secrets.map((secret) => ({
...secret, const docs = sqlNestRelationships({
isRotatedSecret: Boolean(secret.rotationId) data: secrets,
})); key: "id",
parentMapper: (secret) => ({
...secret,
isRotatedSecret: Boolean(secret.rotationId)
}),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
},
{
key: "metadataId",
label: "secretMetadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "find by secret keys" }); throw new DatabaseError({ error, name: "find by secret keys" });
} }
@@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({
actorId, actorId,
actor, actor,
projectId, projectId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
created: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
}); });
} }
@@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({
actor, actor,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
updated: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
}); });
} }
@@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({
actor, actor,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
deleted: {
secretId: secretToDelete.id,
environment: folder.environment.slug,
secretKey: secretToDelete.key,
secretPath
}
}
}); });
} }
@@ -1050,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({
currentPath: path currentPath: path
}); });
if (!deepPaths) return { secrets: [], imports: [] }; if (!deepPaths?.length) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else { } else {
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return { secrets: [], imports: [] }; if (!folder) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = [{ folderId: folder.id, path }]; paths = [{ folderId: folder.id, path }];
} }
@@ -1708,7 +1742,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
created: newSecrets.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
}); });
return newSecrets.map((el) => { return newSecrets.map((el) => {
@@ -2075,7 +2117,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath: el.path, secretPath: el.path,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: environment environmentSlug: environment,
event: {
updated: updatedSecrets.map((sec) => ({
secretId: sec.id,
secretKey: sec.key,
secretPath: sec.secretPath,
environment
}))
}
}) })
: undefined : undefined
) )
@@ -2214,7 +2264,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
deleted: secretsDeleted.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
}); });
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -2751,7 +2809,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: destinationFolder.path, secretPath: destinationFolder.path,
environmentSlug: destinationFolder.environment.slug, environmentSlug: destinationFolder.environment.slug,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
}); });
} }
@@ -2763,7 +2827,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: sourceFolder.path, secretPath: sourceFolder.path,
environmentSlug: sourceFolder.environment.slug, environmentSlug: sourceFolder.environment.slug,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
}); });
} }
+79 -5
View File
@@ -5,6 +5,7 @@ import { Knex } from "knex";
import { import {
ProjectMembershipRole, ProjectMembershipRole,
ProjectType,
ProjectUpgradeStatus, ProjectUpgradeStatus,
ProjectVersion, ProjectVersion,
SecretType, SecretType,
@@ -12,6 +13,9 @@ import {
TSecretVersionsV2 TSecretVersionsV2
} from "@app/db/schemas"; } from "@app/db/schemas";
import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; 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 { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
@@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = {
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">; folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">; secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">; reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
eventBusService: TEventBusService;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
export type TGetSecrets = { export type TGetSecrets = {
@@ -172,7 +178,9 @@ export const secretQueueFactory = ({
resourceMetadataDAL, resourceMetadataDAL,
secretSyncQueue, secretSyncQueue,
folderCommitService, folderCommitService,
reminderService reminderService,
eventBusService,
licenseService
}: TSecretQueueFactoryDep) => { }: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@@ -534,17 +542,70 @@ export const secretQueueFactory = ({
}); });
}; };
const publishEvents = async (event: PublishableEvent) => {
if (event.created) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.CreateSecret,
payload: event.created
}
});
}
if (event.updated) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.UpdateSecret,
payload: event.updated
}
});
}
if (event.deleted) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.DeleteSecret,
payload: event.deleted
}
});
}
if (event.importMutation) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.ImportMutation,
payload: event.importMutation
}
});
}
};
const syncSecrets = async <T extends boolean = false>({ const syncSecrets = async <T extends boolean = false>({
// seperate de-dupe queue for integration sync and replication sync // seperate de-dupe queue for integration sync and replication sync
_deDupeQueue: deDupeQueue = {}, _deDupeQueue: deDupeQueue = {},
_depth: depth = 0, _depth: depth = 0,
_deDupeReplicationQueue: deDupeReplicationQueue = {}, _deDupeReplicationQueue: deDupeReplicationQueue = {},
event,
...dto ...dto
}: TSyncSecretsDTO<T>) => { }: TSyncSecretsDTO<T> & { event?: PublishableEvent }) => {
logger.info( logger.info(
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
); );
const plan = await licenseService.getPlan(dto.orgId);
if (event && plan.eventSubscriptions) {
await publishEvents(event);
}
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
if ( if (
!dto.excludeReplication !dto.excludeReplication
@@ -565,7 +626,7 @@ export const secretQueueFactory = ({
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_deDupeReplicationQueue: deDupeReplicationQueue, _deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth _depth: depth
} as TSyncSecretsDTO, } as unknown as TSyncSecretsDTO,
{ {
removeOnFail: true, removeOnFail: true,
removeOnComplete: true, removeOnComplete: true,
@@ -689,6 +750,7 @@ export const secretQueueFactory = ({
isManual, isManual,
projectId, projectId,
secretPath, secretPath,
depth = 1, depth = 1,
deDupeQueue = {} deDupeQueue = {}
} = job.data as TIntegrationSyncPayload; } = job.data as TIntegrationSyncPayload;
@@ -738,7 +800,13 @@ export const secretQueueFactory = ({
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_depth: depth + 1, _depth: depth + 1,
excludeReplication: true excludeReplication: true,
event: {
importMutation: {
secretPath: foldersGroupedById[folderId][0]?.path as string,
environment: foldersGroupedById[folderId][0]?.environmentSlug as string
}
}
}) })
) )
); );
@@ -791,7 +859,13 @@ export const secretQueueFactory = ({
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_depth: depth + 1, _depth: depth + 1,
excludeReplication: true excludeReplication: true,
event: {
importMutation: {
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string
}
}
}) })
) )
); );
+12 -2
View File
@@ -637,7 +637,12 @@ export const secretServiceFactory = ({
} }
}); });
if (!deepPaths) return { secrets: [], imports: [] }; if (!deepPaths?.length) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else { } else {
@@ -647,7 +652,12 @@ export const secretServiceFactory = ({
}); });
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return { secrets: [], imports: [] }; if (!folder) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = [{ folderId: folder.id, path }]; paths = [{ folderId: folder.id, path }];
} }
+38
View File
@@ -115,6 +115,44 @@ User Note: ${payload.note}`
payloadBlocks payloadBlocks
}; };
} }
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
The following permissions are requested: ${payload.permissions.join(", ")}
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.editNote
? `
Editor Note: ${payload.editNote}`
: ""
}`;
const payloadBlocks = [
{
type: "header",
text: {
type: "plain_text",
text: "Updated access approval request pending for review",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
}
}
];
return {
payloadMessage: messageBody,
payloadBlocks
};
}
default: { default: {
throw new BadRequestError({ throw new BadRequestError({
message: "Slack notification type not supported." message: "Slack notification type not supported."
@@ -0,0 +1,95 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface AccessApprovalRequestUpdatedTemplateProps
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
projectName: string;
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
expiresIn: string;
permissions: string[];
editNote: string;
editorFullName: string;
editorEmail: string;
approvalUrl: string;
}
export const AccessApprovalRequestUpdatedTemplate = ({
projectName,
siteUrl,
requesterFullName,
requesterEmail,
isTemporary,
secretPath,
environment,
expiresIn,
permissions,
editNote,
editorEmail,
editorFullName,
approvalUrl
}: AccessApprovalRequestUpdatedTemplateProps) => {
return (
<BaseEmailWrapper
title="Access Approval Request Update"
preview="An access approval request was updated and requires your review."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
An access approval request was updated and is pending your review for the project <strong>{projectName}</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
<strong>{editorFullName}</strong> (<BaseLink href={`mailto:${editorEmail}`}>{editorEmail}</BaseLink>) has
updated the access request submitted by <strong>{requesterFullName}</strong> (
<BaseLink href={`mailto:${requesterEmail}`}>{requesterEmail}</BaseLink>) for <strong>{secretPath}</strong> in
the <strong>{environment}</strong> environment.
</Text>
{isTemporary && (
<Text className="text-[14px] text-red-600 leading-[24px]">
<strong>This access will expire {expiresIn} after approval.</strong>
</Text>
)}
<Text className="text-[14px] leading-[24px] mb-[4px]">
<strong>The following permissions are requested:</strong>
</Text>
{permissions.map((permission) => (
<Text key={permission} className="text-[14px] my-[2px] leading-[24px]">
- {permission}
</Text>
))}
<Text className="text-[14px] text-slate-700 leading-[24px]">
<strong className="text-black">Editor Note:</strong> "{editNote}"
</Text>
</Section>
<Section className="text-center">
<BaseButton href={approvalUrl}>Review Request</BaseButton>
</Section>
</BaseEmailWrapper>
);
};
export default AccessApprovalRequestUpdatedTemplate;
AccessApprovalRequestUpdatedTemplate.PreviewProps = {
requesterFullName: "Abigail Williams",
requesterEmail: "[email protected]",
isTemporary: true,
secretPath: "/api/secrets",
environment: "Production",
siteUrl: "https://infisical.com",
projectName: "Example Project",
expiresIn: "1 day",
permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"],
editNote: "Too permissive, they only need 3 days",
editorEmail: "[email protected]",
editorFullName: "John Smith"
} as AccessApprovalRequestUpdatedTemplateProps;
@@ -1,4 +1,5 @@
export * from "./AccessApprovalRequestTemplate"; export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./EmailMfaTemplate"; export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate"; export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate"; export * from "./ExternalImportFailedTemplate";
@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
import { import {
AccessApprovalRequestTemplate, AccessApprovalRequestTemplate,
AccessApprovalRequestUpdatedTemplate,
EmailMfaTemplate, EmailMfaTemplate,
EmailVerificationTemplate, EmailVerificationTemplate,
ExternalImportFailedTemplate, ExternalImportFailedTemplate,
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
EmailMfa = "emailMfa", EmailMfa = "emailMfa",
UnlockAccount = "unlockAccount", UnlockAccount = "unlockAccount",
AccessApprovalRequest = "accessApprovalRequest", AccessApprovalRequest = "accessApprovalRequest",
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
AccessSecretRequestBypassed = "accessSecretRequestBypassed", AccessSecretRequestBypassed = "accessSecretRequestBypassed",
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate,
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate, [SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
@@ -11,7 +11,6 @@ import {
validateOverrides validateOverrides
} from "@app/lib/config/env"; } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
@@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({
return updatedServerCfg; return updatedServerCfg;
}; };
const adminSignUp = async ({ const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => {
lastName,
firstName,
email,
salt,
password,
verifier,
publicKey,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
ip,
userAgent
}: TAdminSignUpDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const sanitizedEmail = email.trim().toLowerCase(); const sanitizedEmail = email.trim().toLowerCase();
const existingUser = await userDAL.findOne({ username: sanitizedEmail }); const existingUser = await userDAL.findOne({ username: sanitizedEmail });
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
const privateKey = await getUserPrivateKey(password, {
encryptionVersion: 2,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
});
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
const userInfo = await userDAL.transaction(async (tx) => { const userInfo = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create( const newUser = await userDAL.create(
{ {
@@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({
); );
const userEnc = await userDAL.createUserEncryption( const userEnc = await userDAL.createUserEncryption(
{ {
salt,
encryptionVersion: 2, encryptionVersion: 2,
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
verifier,
userId: newUser.id, userId: newUser.id,
hashedPassword, hashedPassword
serverEncryptedPrivateKey: ciphertext,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyEncoding: encoding
}, },
tx tx
); );
return { user: newUser, enc: userEnc }; return { user: newUser, enc: userEnc };
}); });
@@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({
}, },
tx tx
); );
const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password);
const encKeys = await generateUserSrpKeys(sanitizedEmail, password); const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const userEnc = await userDAL.createUserEncryption( const userEnc = await userDAL.createUserEncryption(
{ {
userId: newUser.id, userId: newUser.id,
encryptionVersion: 2, encryptionVersion: 2,
protectedKey: encKeys.protectedKey, hashedPassword
protectedKeyIV: encKeys.protectedKeyIV,
protectedKeyTag: encKeys.protectedKeyTag,
publicKey: encKeys.publicKey,
encryptedPrivateKey: encKeys.encryptedPrivateKey,
iv: encKeys.encryptedPrivateKeyIV,
tag: encKeys.encryptedPrivateKeyTag,
salt: encKeys.salt,
verifier: encKeys.verifier,
serverEncryptedPrivateKeyEncoding: encoding,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKey: ciphertext
}, },
tx tx
); );
@@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env";
export type TAdminSignUpDTO = { export type TAdminSignUpDTO = {
email: string; email: string;
password: string; password: string;
publicKey: string;
salt: string;
lastName?: string; lastName?: string;
verifier: string;
firstName: string; firstName: string;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
ip: string; ip: string;
userAgent: string; userAgent: string;
}; };
+35 -10
View File
@@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
@@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { AuthMethod } from "../auth/auth-type"; import { AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
import { TUserDALFactory } from "./user-dal"; import { TUserDALFactory } from "./user-dal";
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
@@ -37,6 +39,7 @@ type TUserServiceFactoryDep = {
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
smtpService: Pick<TSmtpService, "sendMail">; smtpService: Pick<TSmtpService, "sendMail">;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
}; };
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>; export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
@@ -48,22 +51,38 @@ export const userServiceFactory = ({
groupProjectDAL, groupProjectDAL,
tokenService, tokenService,
smtpService, smtpService,
permissionService permissionService,
userAliasDAL
}: TUserServiceFactoryDep) => { }: TUserServiceFactoryDep) => {
const sendEmailVerificationCode = async (username: string) => { const sendEmailVerificationCode = async (token: string) => {
const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as {
authType: string;
aliasId?: string;
username: string;
authTokenType: AuthTokenType;
};
if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" });
// akhilmhdh: case sensitive email resolution // akhilmhdh: case sensitive email resolution
const users = await userDAL.findUserByUsername(username); const users = await userDAL.findUserByUsername(username);
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0];
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
let { isEmailVerified } = user;
if (aliasId) {
const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId });
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` });
isEmailVerified = userAlias.isEmailVerified;
}
if (!user.email) if (!user.email)
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
if (user.isEmailVerified) if (isEmailVerified)
throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" });
const token = await tokenService.createTokenForUser({ const userToken = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -71,7 +90,7 @@ export const userServiceFactory = ({
subjectLine: "Infisical confirmation code", subjectLine: "Infisical confirmation code",
recipients: [user.email], recipients: [user.email],
substitutions: { substitutions: {
code: token code: userToken
} }
}); });
}; };
@@ -95,15 +114,21 @@ export const userServiceFactory = ({
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
if (!user.email) if (!user.email)
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
if (user.isEmailVerified)
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
await tokenService.validateTokenForUser({ const token = await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id, userId: user.id,
code code
}); });
if (token?.aliasId) {
const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId });
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` });
if (userAlias?.isEmailVerified)
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true });
}
const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; const userEmails = user?.email ? await userDAL.find({ email: user.email }) : [];
await userDAL.updateById(user.id, { await userDAL.updateById(user.id, {
+8 -2
View File
@@ -310,7 +310,8 @@
"self-hosting/guides/mongo-to-postgres", "self-hosting/guides/mongo-to-postgres",
"self-hosting/guides/custom-certificates", "self-hosting/guides/custom-certificates",
"self-hosting/guides/automated-bootstrapping", "self-hosting/guides/automated-bootstrapping",
"self-hosting/guides/production-hardening" "self-hosting/guides/production-hardening",
"self-hosting/guides/monitoring-telemetry"
] ]
}, },
{ {
@@ -416,6 +417,9 @@
"pages": [ "pages": [
"documentation/platform/secrets-mgmt/project", "documentation/platform/secrets-mgmt/project",
"documentation/platform/folder", "documentation/platform/folder",
"documentation/platform/secret-versioning",
"documentation/platform/pit-recovery",
"documentation/platform/secret-reference",
{ {
"group": "Secret Rotation", "group": "Secret Rotation",
"pages": [ "pages": [
@@ -439,6 +443,7 @@
"documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/aws-iam",
"documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/azure-entra-id",
"documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/cassandra",
"documentation/platform/dynamic-secrets/couchbase",
"documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/elastic-search",
"documentation/platform/dynamic-secrets/gcp-iam", "documentation/platform/dynamic-secrets/gcp-iam",
"documentation/platform/dynamic-secrets/github", "documentation/platform/dynamic-secrets/github",
@@ -458,7 +463,8 @@
"documentation/platform/dynamic-secrets/kubernetes", "documentation/platform/dynamic-secrets/kubernetes",
"documentation/platform/dynamic-secrets/vertica" "documentation/platform/dynamic-secrets/vertica"
] ]
} },
"documentation/platform/webhooks"
] ]
}, },
{ {
@@ -0,0 +1,259 @@
---
title: "Couchbase"
description: "Learn how to dynamically generate Couchbase Database user credentials."
---
The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions.
## Prerequisite
Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key).
<Info>The API Key must have permission to manage database users in your Couchbase Cloud organization and project.</Info>
## Set up Dynamic Secrets with Couchbase
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select Couchbase">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="URL" type="string" required default="https://cloudapi.cloud.couchbase.com">
The Couchbase Cloud API URL
</ParamField>
<ParamField path="Organization ID" type="string" required>
Your Couchbase Cloud organization ID
</ParamField>
<ParamField path="Project ID" type="string" required>
Your Couchbase Cloud project ID
</ParamField>
<ParamField path="Cluster ID" type="string" required>
Your Couchbase Cloud cluster ID where users will be created
</ParamField>
<ParamField path="Roles" type="array" required>
Database credential roles to assign to the generated user. Available options:
- **read**: Read access to bucket data (alias for data_reader)
- **write**: Read and write access to bucket data (alias for data_writer)
</ParamField>
<ParamField path="Bucket Access" type="string" required default="*">
Specify bucket access configuration:
- Use `*` for access to all buckets
- Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets
- Use Advanced Bucket Configuration for granular scope and collection access
</ParamField>
<ParamField path="API Key" type="string" required>
Your Couchbase Cloud API Key for authentication
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png)
</Step>
<Step title="(Optional) Advanced Configuration">
![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png)
<ParamField path="Advanced Bucket Configuration" type="boolean" default="false">
Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections
</ParamField>
When Advanced Bucket Configuration is enabled, you can configure:
<ParamField path="Buckets" type="array">
List of buckets with optional scope and collection specifications:
- **Bucket Name**: Name of the bucket (e.g., travel-sample)
- **Scopes**: Optional array of scopes within the bucket
- **Scope Name**: Name of the scope (e.g., inventory, _default)
- **Collections**: Optional array of collection names within the scope
</ParamField>
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
Specifies a template for generating usernames. This field allows customization of how usernames are automatically created.
Allowed template variables are:
- `{{randomUsername}}`: Random username string
- `{{unixTimestamp}}`: Current Unix timestamp
- `{{identity.name}}`: Name of the identity that is generating the secret
- `{{random N}}`: Random string of N characters
Allowed template functions are:
- `truncate`: Truncates a string to a specified length
- `replace`: Replaces a substring with another value
Examples:
```
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
{{unixTimestamp}} // 17490641580
{{identity.name}} // testuser
{{random 5}} // x9k2m
{{truncate identity.name 4}} // test
{{replace identity.name 'user' 'replace'}} // testreplace
```
</ParamField>
<ParamField path="Password Configuration" type="object">
Optional password generation requirements for Couchbase users:
<ParamField path="Password Length" type="number" default="12" min="8" max="128">
Length of the generated password
</ParamField>
<ParamField path="Character Requirements" type="object">
Minimum required character counts:
- **Lowercase Count**: Minimum lowercase letters (default: 1)
- **Uppercase Count**: Minimum uppercase letters (default: 1)
- **Digit Count**: Minimum digits (default: 1)
- **Symbol Count**: Minimum special characters (default: 1)
</ParamField>
<ParamField path="Allowed Symbols" type="string" default="!@#$%^()_+-=[]{}:,?/~`">
Special characters allowed in passwords. Cannot contain: `< > ; . * & | £`
</ParamField>
<Info>
Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £`
</Info>
</ParamField>
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
<Note>
If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs.
</Note>
![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png)
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png)
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png)
</Step>
</Steps>
## Advanced Bucket Configuration Examples
The advanced bucket configuration allows you to specify granular access control:
### Example 1: Specific Bucket Access
```json
[
{
"name": "travel-sample"
}
]
```
### Example 2: Bucket with Specific Scopes
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory"
},
{
"name": "_default"
}
]
}
]
```
### Example 3: Bucket with Scopes and Collections
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory",
"collections": ["airport", "airline"]
},
{
"name": "_default",
"collections": ["users"]
}
]
}
]
```
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you to see the expiration time of the lease or delete a lease before its set time to live.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below.
![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>
## Couchbase Roles and Permissions
The Couchbase dynamic secret integration supports the following database credential roles:
- **read**: Provides read-only access to bucket data
- **write**: Provides read and write access to bucket data
<Note>
These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration.
</Note>
## Troubleshooting
### Common Issues
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users
2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key
3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write)
4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible
@@ -1,6 +1,6 @@
--- ---
title: "Delivering Secrets" title: "Fetching Secrets"
description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them." description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them."
--- ---
Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them. Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them.
Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

+7 -2
View File
@@ -27,7 +27,7 @@ $ ansible-galaxy collection install infisical.vault
The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip:
```bash ```bash
$ pip install infisical-python $ pip install infisicalsdk
``` ```
## Using this collection ## Using this collection
@@ -41,8 +41,13 @@ vars:
read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}"
# [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }]
read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}"
# { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1
read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}"
# [{ "key": "HOST", "value": "google.com" }] # { "key": "HOST", "value": "google.com" }
``` ```
@@ -22,7 +22,7 @@ It can also automatically reload dependent Deployments resources whenever releva
## Install ## Install
The operator can be install via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. The operator can be installed via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications.
**Install the latest Helm repository** **Install the latest Helm repository**
```bash ```bash
@@ -229,9 +229,9 @@ The managed secret created by the operator will not be deleted when the operator
<Tabs> <Tabs>
<Tab title="Helm"> <Tab title="Helm">
Install Infisical Helm repository Uninstall Infisical Helm repository
```bash ```bash
helm uninstall <release name> helm uninstall <release name>
``` ```
</Tab> </Tab>
</Tabs> </Tabs>
+3 -2
View File
@@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical."
![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png)
- **Render Connection**: The Render Connection to authenticate with. - **Render Connection**: The Render Connection to authenticate with.
- **Scope**: Select **Service**. - **Scope**: Select **Service** or **Environment Group**.
- **Service**: Choose the Render service you want to sync secrets to. - **Service**: Choose the Render service you want to sync secrets to.
- **Environment Group**: Choose the Render environment group you want to sync secrets to.
5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
![Configure Options](/images/secret-syncs/render/render-sync-options.png) ![Configure Options](/images/secret-syncs/render/render-sync-options.png)
@@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their
Supports conditions and permission inversion Supports conditions and permission inversion
| Action | Description | Notes | | Action | Description | Notes |
| -------- | ------------------------------- | ----- | | -------- | ------------------------------- | ----- |
| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | | `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. |
| `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | | `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. |
| `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | | `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. |
| `create` | Add new secrets to the project | | | `create` | Add new secrets to the project | |
| `edit` | Modify existing secret values | | | `edit` | Modify existing secret values | |
| `delete` | Remove secrets from the project | | | `delete` | Remove secrets from the project | |
#### Subject: `secret-folders` #### Subject: `secret-folders`
@@ -169,6 +169,15 @@ Supports conditions and permission inversion
| `edit` | Modify secret imports | | `edit` | Modify secret imports |
| `delete` | Remove secret imports | | `delete` | Remove secret imports |
#### Subject: `secret-events`
| Action | Description |
| ------------------------------- | ------------------------------------------------------------- |
| `subscribe-on-created` | Subscribe to events when secrets are created |
| `subscribe-on-updated` | Subscribe to events when secrets are updated |
| `subscribe-on-deleted` | Subscribe to events when secrets are deleted |
| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports |
#### Subject: `secret-rollback` #### Subject: `secret-rollback`
| Action | Description | | Action | Description |
@@ -178,10 +187,10 @@ Supports conditions and permission inversion
#### Subject: `commits` #### Subject: `commits`
| Action | Description | | Action | Description |
| -------- | ---------------------------------- | | ------------------ | --------------------------------------------------------------- |
| `read` | View commits and changes across folders | | `read` | View commits and changes across folders |
| `perform-rollback` | Roll back commits changes and restore folders to previous state| | `perform-rollback` | Roll back commits changes and restore folders to previous state |
#### Subject: `secret-approval` #### Subject: `secret-approval`
@@ -197,14 +206,14 @@ Supports conditions and permission inversion
#### Subject: `secret-rotation` #### Subject: `secret-rotation`
Supports conditions and permission inversion Supports conditions and permission inversion
| Action | Description | | Action | Description |
| ------------------------------ | ---------------------------------------------- | | ------------------------------ | ---------------------------------------------- |
| `read` | View secret rotation configurations | | `read` | View secret rotation configurations |
| `read-generated-credentials` | View the generated credentials of a rotation | | `read-generated-credentials` | View the generated credentials of a rotation |
| `create` | Set up secret rotation configurations | | `create` | Set up secret rotation configurations |
| `edit` | Modify secret rotation configurations | | `edit` | Modify secret rotation configurations |
| `rotate-secrets` | Rotate the generated credentials of a rotation | | `rotate-secrets` | Rotate the generated credentials of a rotation |
| `delete` | Remove secret rotation configurations | | `delete` | Remove secret rotation configurations |
#### Subject: `secret-syncs` #### Subject: `secret-syncs`
@@ -263,12 +272,12 @@ Supports conditions and permission inversion
#### Subject: `certificates` #### Subject: `certificates`
| Action | Description | | Action | Description |
| -------------------- | ----------------------------- | | ------------------ | ----------------------------- |
| `read` | View certificates | | `read` | View certificates |
| `read-private-key` | Read certificate private key | | `read-private-key` | Read certificate private key |
| `create` | Issue new certificates | | `create` | Issue new certificates |
| `delete` | Revoke or remove certificates | | `delete` | Revoke or remove certificates |
#### Subject: `certificate-templates` #### Subject: `certificate-templates`
@@ -330,8 +339,8 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-data-sources` #### Subject: `secret-scanning-data-sources`
| Action | Description | | Action | Description |
| -------- | ---------------------------------------------------- | | ---------------------------- | -------------------------------- |
| `read-data-sources` | View Data Sources | | `read-data-sources` | View Data Sources |
| `create-data-sources` | Create new Data Sources | | `create-data-sources` | Create new Data Sources |
| `edit-data-sources` | Modify Data Sources | | `edit-data-sources` | Modify Data Sources |
@@ -342,15 +351,14 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-findings` #### Subject: `secret-scanning-findings`
| Action | Description | | Action | Description |
| -------- | --------------------------------- | | ----------------- | ------------------------------- |
| `read-findings` | View Secret Scanning Findings | | `read-findings` | View Secret Scanning Findings |
| `update-findings` | Update Secret Scanning Findings | | `update-findings` | Update Secret Scanning Findings |
#### Subject: `secret-scanning-configs` #### Subject: `secret-scanning-configs`
| Action | Description | | Action | Description |
| ---------------- | ------------------------------------------------ | | ---------------- | -------------------------------------------- |
| `read-configs` | View Secret Scanning Project Configuration | | `read-configs` | View Secret Scanning Project Configuration |
| `update-configs` | Update Secret Scanning Project Configuration | | `update-configs` | Update Secret Scanning Project Configuration |
@@ -0,0 +1,440 @@
---
title: "Monitoring and Telemetry Setup"
description: "Learn how to set up monitoring and telemetry for your self-hosted Infisical instance using Grafana, Prometheus, and OpenTelemetry."
---
Infisical provides comprehensive monitoring and telemetry capabilities to help you monitor the health, performance, and usage of your self-hosted instance. This guide covers setting up monitoring using Grafana with two different telemetry collection approaches.
## Overview
Infisical exports metrics in **OpenTelemetry (OTEL) format**, which provides maximum flexibility for your monitoring infrastructure. While this guide focuses on Grafana, the OTEL format means you can easily integrate with:
- **Cloud-native monitoring**: AWS CloudWatch, Google Cloud Monitoring, Azure Monitor
- **Observability platforms**: Datadog, New Relic, Splunk, Dynatrace
- **Custom backends**: Any system that supports OTEL ingestion
- **Traditional monitoring**: Prometheus, Grafana (as covered in this guide)
Infisical supports two telemetry collection methods:
1. **Pull-based (Prometheus)**: Exposes metrics on a dedicated endpoint for Prometheus to scrape
2. **Push-based (OTLP)**: Sends metrics to an OpenTelemetry Collector via OTLP protocol
Both approaches provide the same metrics data in OTEL format, so you can choose the one that best fits your infrastructure and monitoring strategy.
## Prerequisites
- Self-hosted Infisical instance running
- Access to deploy monitoring services (Prometheus, Grafana, etc.)
- Basic understanding of Prometheus and Grafana
## Environment Variables
Configure the following environment variables in your Infisical backend:
```bash
# Enable telemetry collection
OTEL_TELEMETRY_COLLECTION_ENABLED=true
# Choose export type: "prometheus" or "otlp"
OTEL_EXPORT_TYPE=prometheus
# For OTLP push mode, also configure:
# OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics
# OTEL_COLLECTOR_BASIC_AUTH_USERNAME=your_collector_username
# OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=your_collector_password
# OTEL_OTLP_PUSH_INTERVAL=30000
```
**Note**: The `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` values must match the credentials configured in your OpenTelemetry Collector's `basicauth/server` extension. These are not hardcoded values - you configure them in your collector configuration file.
## Option 1: Pull-based Monitoring (Prometheus)
This approach exposes metrics on port 9464 at the `/metrics` endpoint, allowing Prometheus to scrape the data. The metrics are exposed in Prometheus format but originate from OpenTelemetry instrumentation.
### Configuration
1. **Enable Prometheus export in Infisical**:
```bash
OTEL_TELEMETRY_COLLECTION_ENABLED=true
OTEL_EXPORT_TYPE=prometheus
```
2. **Expose the metrics port** in your Infisical backend:
- **Docker**: Expose port 9464
- **Kubernetes**: Create a service exposing port 9464
- **Other**: Ensure port 9464 is accessible to your monitoring stack
3. **Create Prometheus configuration** (`prometheus.yml`):
```yaml
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: "infisical"
scrape_interval: 30s
static_configs:
- targets: ["infisical-backend:9464"] # Adjust hostname/port based on your deployment
metrics_path: "/metrics"
```
**Note**: Replace `infisical-backend:9464` with the actual hostname and port where your Infisical backend is running. This could be:
- **Docker Compose**: `infisical-backend:9464` (service name)
- **Kubernetes**: `infisical-backend.default.svc.cluster.local:9464` (service name)
- **Bare Metal**: `192.168.1.100:9464` (actual IP address)
- **Cloud**: `your-infisical.example.com:9464` (domain name)
### Deployment Options
#### Docker Compose
```yaml
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- "--config.file=/etc/prometheus/prometheus.yml"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
```
#### Kubernetes
```yaml
# prometheus-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
spec:
replicas: 1
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
containers:
- name: prometheus
image: prom/prometheus:latest
ports:
- containerPort: 9090
volumeMounts:
- name: config
mountPath: /etc/prometheus
volumes:
- name: config
configMap:
name: prometheus-config
---
# prometheus-service.yaml
apiVersion: v1
kind: Service
metadata:
name: prometheus
spec:
selector:
app: prometheus
ports:
- port: 9090
targetPort: 9090
type: ClusterIP
```
#### Helm
```bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install prometheus prometheus-community/prometheus \
--set server.config.global.scrape_interval=30s \
--set server.config.scrape_configs[0].job_name=infisical \
--set server.config.scrape_configs[0].static_configs[0].targets[0]=infisical-backend:9464
```
## Option 2: Push-based Monitoring (OTLP)
This approach sends metrics directly to an OpenTelemetry Collector via the OTLP protocol. This gives you the most flexibility as you can configure the collector to export to multiple backends simultaneously.
### Configuration
1. **Enable OTLP export in Infisical**:
```bash
OTEL_TELEMETRY_COLLECTION_ENABLED=true
OTEL_EXPORT_TYPE=otlp
OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics
OTEL_COLLECTOR_BASIC_AUTH_USERNAME=infisical
OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=infisical
OTEL_OTLP_PUSH_INTERVAL=30000
```
2. **Create OpenTelemetry Collector configuration** (`otel-collector-config.yaml`):
```yaml
extensions:
health_check:
pprof:
zpages:
basicauth/server:
htpasswd:
inline: |
your_username:your_password
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
auth:
authenticator: basicauth/server
prometheus:
config:
scrape_configs:
- job_name: otel-collector
scrape_interval: 30s
static_configs:
- targets: [infisical-backend:9464]
metric_relabel_configs:
- action: labeldrop
regex: "service_instance_id|service_name"
processors:
batch:
exporters:
prometheus:
endpoint: "0.0.0.0:8889"
auth:
authenticator: basicauth/server
resource_to_telemetry_conversion:
enabled: true
service:
extensions: [basicauth/server, health_check, pprof, zpages]
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
```
**Important**: Replace `your_username:your_password` with your chosen credentials. These must match the values you set in Infisical's `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` environment variables.
3. **Create Prometheus configuration** for the collector:
```yaml
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: "otel-collector"
scrape_interval: 30s
static_configs:
- targets: ["otel-collector:8889"] # Adjust hostname/port based on your deployment
metrics_path: "/metrics"
```
**Note**: Replace `otel-collector:8889` with the actual hostname and port where your OpenTelemetry Collector is running. This could be:
- **Docker Compose**: `otel-collector:8889` (service name)
- **Kubernetes**: `otel-collector.default.svc.cluster.local:8889` (service name)
- **Bare Metal**: `192.168.1.100:8889` (actual IP address)
- **Cloud**: `your-collector.example.com:8889` (domain name)
### Deployment Options
#### Docker Compose
```yaml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- 4318:4318 # OTLP http receiver
- 8889:8889 # Prometheus exporter metrics
volumes:
- ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro
command:
- "--config=/etc/otelcol-contrib/config.yaml"
```
#### Kubernetes
```yaml
# otel-collector-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: otel-collector
spec:
replicas: 1
selector:
matchLabels:
app: otel-collector
template:
metadata:
labels:
app: otel-collector
spec:
containers:
- name: otel-collector
image: otel/opentelemetry-collector-contrib:latest
ports:
- containerPort: 4318
- containerPort: 8889
volumeMounts:
- name: config
mountPath: /etc/otelcol-contrib
volumes:
- name: config
configMap:
name: otel-collector-config
```
#### Helm
```bash
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm install otel-collector open-telemetry/opentelemetry-collector \
--set config.receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \
--set config.exporters.prometheus.endpoint=0.0.0.0:8889
```
## Alternative Backends
Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus:
### Cloud-Native Examples
```yaml
# Add to your otel-collector-config.yaml exporters section
exporters:
# AWS CloudWatch
awsemf:
region: us-west-2
log_group_name: /aws/emf/infisical
log_stream_name: metrics
# Google Cloud Monitoring
googlecloud:
project_id: your-project-id
# Azure Monitor
azuremonitor:
connection_string: "your-connection-string"
# Datadog
datadog:
api:
key: "your-api-key"
site: "datadoghq.com"
# New Relic
newrelic:
apikey: "your-api-key"
host_override: "otlp.nr-data.net"
```
### Multi-Backend Configuration
```yaml
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus, awsemf, datadog] # Send to multiple backends
```
## Setting Up Grafana
1. **Access Grafana**: Navigate to your Grafana instance
2. **Login**: Use your configured credentials
3. **Add Prometheus Data Source**:
- Go to Configuration → Data Sources
- Click "Add data source"
- Select "Prometheus"
- Set URL to your Prometheus endpoint
- Click "Save & Test"
## Available Metrics
Infisical exposes the following key metrics in OpenTelemetry format:
### API Performance Metrics
- `API_latency` - API request latency histogram in milliseconds
- **Labels**: `route`, `method`, `statusCode`
- **Example**: Monitor response times for specific endpoints
- `API_errors` - API error count histogram
- **Labels**: `route`, `method`, `type`, `name`
- **Example**: Track error rates by endpoint and error type
### Integration & Secret Sync Metrics
- `integration_secret_sync_errors` - Integration secret sync error count
- **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId`
- **Example**: Monitor integration sync failures across different services
- `secret_sync_sync_secrets_errors` - Secret sync operation error count
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
- **Example**: Track secret sync failures to external systems
- `secret_sync_import_secrets_errors` - Secret import operation error count
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
- **Example**: Monitor secret import failures
- `secret_sync_remove_secrets_errors` - Secret removal operation error count
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
- **Example**: Track secret removal operation failures
### System Metrics
These metrics are automatically collected by OpenTelemetry's HTTP instrumentation:
- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum)
- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum)
### Custom Business Metrics
- `infisical_secret_operations_total` - Total secret operations
- `infisical_secrets_processed_total` - Total secrets processed
## Troubleshooting
### Common Issues
1. **Metrics not appearing**:
- Check if `OTEL_TELEMETRY_COLLECTION_ENABLED=true`
- Verify the correct `OTEL_EXPORT_TYPE` is set
- Check network connectivity between services
2. **Authentication errors**:
- Verify basic auth credentials in OTLP configuration
- Check if credentials match between Infisical and collector
@@ -1,4 +1,4 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2"; import { FormLabel, Tooltip } from "../v2";
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
label={label} label={label}
icon={ icon={
<Tooltip <Tooltip
className="max-w-lg"
content={ content={
<span> <span>
Examples: 30m, 1h, 3d, etc.{" "}
<a <a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples" href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary-700" className="text-primary-500 hover:text-mineshaft-100"
> >
More See More Examples{" "}
<FontAwesomeIcon size="xs" className="mt-0.5" icon={faArrowUpRightFromSquare} />
</a> </a>
</span> </span>
} }
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
<FontAwesomeIcon <FontAwesomeIcon
icon={faQuestionCircle} icon={faQuestionCircle}
size="sm" size="sm"
className="relative bottom-px right-1" className="relative right-1 mt-0.5 text-mineshaft-300"
/> />
</Tooltip> </Tooltip>
} }
@@ -0,0 +1,29 @@
import { components, OptionProps } from "react-select";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const RoleOption = ({
isSelected,
children,
...props
}: OptionProps<{ name: string; slug: string; description?: string | undefined }>) => {
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex flex-row items-center justify-between">
<div>
<p className="truncate">{children}</p>
{props.data.description ? (
<p className="truncate text-xs leading-4 text-mineshaft-400">
{props.data.description}
</p>
) : (
<p className="text-xs leading-4 text-mineshaft-400/50">No Description</p>
)}
</div>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
</components.Option>
);
};

Some files were not shown because too many files have changed in this diff Show More