mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-24 20:43:19 +00:00
Compare commits
69 Commits
ENG-3463
...
misc/optim
Author | SHA1 | Date | |
---|---|---|---|
|
28cc919ff7 | ||
|
ef6f79f7a6 | ||
|
43752e1888 | ||
|
bd72129d8c | ||
|
bf10b2f58a | ||
|
d24f5a57a8 | ||
|
166104e523 | ||
|
a7847f177c | ||
|
48e5f550e9 | ||
|
4a4a7fd325 | ||
|
91b8ed8015 | ||
|
6cf978b593 | ||
|
68fbb399fc | ||
|
97366f6e95 | ||
|
c83d4af7a3 | ||
|
c35c937c63 | ||
|
b10752acb5 | ||
|
eb9b75d930 | ||
|
273a7b9657 | ||
|
a3b6fa9a53 | ||
|
f60dd528e8 | ||
|
8ffef1da8e | ||
|
f352f98374 | ||
|
91a76f50ca | ||
|
ea4bb0a062 | ||
|
3d6be7b1b2 | ||
|
12558e8614 | ||
|
987f87e562 | ||
|
4d06d5cbb0 | ||
|
bad934de48 | ||
|
90b93fbd15 | ||
|
c2db2a0bc7 | ||
|
b0d24de008 | ||
|
0473fb0ddb | ||
|
4ccb5dc9b0 | ||
|
930425d5dc | ||
|
f77a53bd8e | ||
|
4bd61e5607 | ||
|
aa4dbfa073 | ||
|
b479406ba0 | ||
|
7cf9d933da | ||
|
ca2825ba95 | ||
|
b8fa4d5255 | ||
|
0d3cb2d41a | ||
|
e0d19d7b65 | ||
|
f5a0d8be78 | ||
|
c7ae7be493 | ||
|
18881749fd | ||
|
fa54c406dc | ||
|
1a2eef3ba6 | ||
|
0c562150f5 | ||
|
6fde132804 | ||
|
799721782a | ||
|
86d430f911 | ||
|
7c28ee844e | ||
|
d5390fcafc | ||
|
1b40f5d475 | ||
|
3cec1b4021 | ||
|
97b2c534a7 | ||
|
d71362ccc3 | ||
|
e4d90eb055 | ||
|
55607a4886 | ||
|
385c75c543 | ||
|
f16dca45d9 | ||
|
118c28df54 | ||
|
249b2933da | ||
|
458dcd31c1 | ||
|
352ef050c3 | ||
|
b6b9fb6ef5 |
@@ -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,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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -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,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({
|
||||||
@@ -190,4 +207,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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@@ -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: {
|
||||||
|
@@ -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 {
|
||||||
|
289
backend/src/ee/services/dynamic-secret/providers/couchbase.ts
Normal file
289
backend/src/ee/services/dynamic-secret/providers/couchbase.ts
Normal file
@@ -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");
|
||||||
|
@@ -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("/"),
|
||||||
|
@@ -161,8 +161,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
|
||||||
);
|
);
|
||||||
@@ -266,8 +265,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
|
||||||
);
|
);
|
||||||
|
@@ -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
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@@ -952,13 +952,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) {
|
||||||
|
@@ -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
|
||||||
|
@@ -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 = {
|
||||||
|
@@ -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 });
|
||||||
@@ -1121,7 +1120,9 @@ export const registerRoutes = async (
|
|||||||
resourceMetadataDAL,
|
resourceMetadataDAL,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
reminderService
|
reminderService,
|
||||||
|
eventBusService,
|
||||||
|
licenseService
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectService = projectServiceFactory({
|
const projectService = projectServiceFactory({
|
||||||
|
@@ -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({
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -478,4 +478,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 } };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@@ -283,6 +283,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
rateLimit: readLimit
|
rateLimit: readLimit
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
|
hide: false,
|
||||||
|
tags: [ApiDocsTags.Projects],
|
||||||
|
description: "Get project details by slug",
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: []
|
||||||
|
}
|
||||||
|
],
|
||||||
params: z.object({
|
params: z.object({
|
||||||
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
||||||
}),
|
}),
|
||||||
|
@@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
|
|||||||
return token;
|
return 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[]>(
|
||||||
@@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
|||||||
|
|
||||||
const token =
|
const token =
|
||||||
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
||||||
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
|
||||||
|
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;
|
||||||
|
@@ -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."
|
||||||
|
@@ -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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -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
|
||||||
* */
|
* */
|
||||||
@@ -1403,6 +1412,7 @@ export const orgServiceFactory = ({
|
|||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findAllOrgMembers,
|
findAllOrgMembers,
|
||||||
findAllOrganizationOfUser,
|
findAllOrganizationOfUser,
|
||||||
|
findIdentityOrganization,
|
||||||
inviteUserToOrganization,
|
inviteUserToOrganization,
|
||||||
verifyUserToOrg,
|
verifyUserToOrg,
|
||||||
updateOrg,
|
updateOrg,
|
||||||
|
@@ -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);
|
||||||
|
@@ -49,6 +49,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
await queueService.stopRepeatableJob(
|
||||||
|
QueueName.AuditLogPrune,
|
||||||
|
QueueJobs.AuditLogPrune,
|
||||||
|
{ pattern: "0 0 * * *", utc: true },
|
||||||
|
QueueName.AuditLogPrune // just a job id
|
||||||
|
);
|
||||||
|
await queueService.stopRepeatableJob(
|
||||||
|
QueueName.DailyResourceCleanUp,
|
||||||
|
QueueJobs.DailyResourceCleanUp,
|
||||||
|
{ pattern: "0 0 * * *", utc: true },
|
||||||
|
QueueName.DailyResourceCleanUp // just a job id
|
||||||
|
);
|
||||||
|
|
||||||
await queueService.startPg<QueueName.DailyResourceCleanUp>(
|
await queueService.startPg<QueueName.DailyResourceCleanUp>(
|
||||||
QueueJobs.DailyResourceCleanUp,
|
QueueJobs.DailyResourceCleanUp,
|
||||||
async () => {
|
async () => {
|
||||||
|
@@ -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[] = [];
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,7 +1732,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 +2107,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 +2254,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 +2799,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 +2817,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
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@@ -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: "abigail@infisical.com",
|
||||||
|
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: "john@infisical.com",
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get Project By Slug"
|
||||||
|
openapi: "GET /api/v2/workspace/{slug}"
|
||||||
|
---
|
@@ -416,6 +416,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 +442,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 +462,8 @@
|
|||||||
"documentation/platform/dynamic-secrets/kubernetes",
|
"documentation/platform/dynamic-secrets/kubernetes",
|
||||||
"documentation/platform/dynamic-secrets/vertica"
|
"documentation/platform/dynamic-secrets/vertica"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"documentation/platform/webhooks"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -995,6 +1000,7 @@
|
|||||||
{
|
{
|
||||||
"group": "Projects",
|
"group": "Projects",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"api-reference/endpoints/workspaces/get-workspace-by-slug",
|
||||||
"api-reference/endpoints/workspaces/create-workspace",
|
"api-reference/endpoints/workspaces/create-workspace",
|
||||||
"api-reference/endpoints/workspaces/delete-workspace",
|
"api-reference/endpoints/workspaces/delete-workspace",
|
||||||
"api-reference/endpoints/workspaces/get-workspace",
|
"api-reference/endpoints/workspaces/get-workspace",
|
||||||
|
259
docs/documentation/platform/dynamic-secrets/couchbase.mdx
Normal file
259
docs/documentation/platform/dynamic-secrets/couchbase.mdx
Normal file
@@ -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">
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Select Couchbase">
|
||||||
|

|
||||||
|
</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>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step title="(Optional) Advanced Configuration">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|

|
||||||
|
|
||||||
|
<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 |
@@ -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>
|
||||||
|
@@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical."
|
|||||||
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
|
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
|
||||||
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
|
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting.
|
||||||
|
</Note>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Infisical UI">
|
<Tab title="Infisical UI">
|
||||||
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
|
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
|
||||||
|
@@ -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 |
|
||||||
|
@@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag
|
|||||||
|
|
||||||
## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
|
## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
|
||||||
|
|
||||||
To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed.
|
To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed.
|
||||||
|
|
||||||
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
|
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
|
||||||
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage.
|
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues.
|
||||||
|
|
||||||
With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages.
|
|
||||||
|
|
||||||
## Penetration testing
|
## Penetration testing
|
||||||
|
|
||||||
|
@@ -53,8 +53,8 @@
|
|||||||
"project-id": "Project ID",
|
"project-id": "Project ID",
|
||||||
"save-changes": "Save Changes",
|
"save-changes": "Save Changes",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"drop-zone": "Drag and drop a .env, .json, or .yml file here.",
|
"drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.",
|
||||||
"drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.",
|
"drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"role_admin": "admin",
|
"role_admin": "admin",
|
||||||
"display-name": "Display Name",
|
"display-name": "Display Name",
|
||||||
|
@@ -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>
|
||||||
}
|
}
|
||||||
|
29
frontend/src/components/roles/RoleOption.tsx
Normal file
29
frontend/src/components/roles/RoleOption.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
1
frontend/src/components/roles/index.tsx
Normal file
1
frontend/src/components/roles/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./RoleOption";
|
@@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectSeparator(csvContent: string): string {
|
||||||
|
const firstLine = csvContent.split("\n")[0];
|
||||||
|
const separators = [",", ";", "\t", "|"];
|
||||||
|
|
||||||
|
const counts = separators.map((sep) => ({
|
||||||
|
separator: sep,
|
||||||
|
count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length
|
||||||
|
}));
|
||||||
|
|
||||||
|
const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max));
|
||||||
|
|
||||||
|
return detected.count > 0 ? detected.separator : ",";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] {
|
||||||
|
let csvContent: string;
|
||||||
|
if (typeof src === "string") {
|
||||||
|
csvContent = src;
|
||||||
|
} else {
|
||||||
|
csvContent = new TextDecoder("utf-8").decode(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = detectSeparator(csvContent);
|
||||||
|
const lines = csvContent.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const matrix: string[][] = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.trim() !== "") {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let currentCell = "";
|
||||||
|
let inQuote = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i += 1) {
|
||||||
|
const char = line[i];
|
||||||
|
const nextChar = line[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuote && nextChar === '"') {
|
||||||
|
currentCell += '"';
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
}
|
||||||
|
} else if (char === separator && !inQuote) {
|
||||||
|
cells.push(currentCell.trim());
|
||||||
|
currentCell = "";
|
||||||
|
} else {
|
||||||
|
currentCell += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(currentCell.trim());
|
||||||
|
matrix.push(cells);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
@@ -143,6 +143,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 PermissionConditionOperators {
|
export enum PermissionConditionOperators {
|
||||||
$IN = "$in",
|
$IN = "$in",
|
||||||
$ALL = "$all",
|
$ALL = "$all",
|
||||||
@@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject =
|
|||||||
| ProjectPermissionSub.CertificateTemplates
|
| ProjectPermissionSub.CertificateTemplates
|
||||||
| ProjectPermissionSub.SecretFolders
|
| ProjectPermissionSub.SecretFolders
|
||||||
| ProjectPermissionSub.SecretImports
|
| ProjectPermissionSub.SecretImports
|
||||||
| ProjectPermissionSub.SecretRotation;
|
| ProjectPermissionSub.SecretRotation
|
||||||
|
| ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||||
[PermissionConditionOperators.$EQ]: "equal to",
|
[PermissionConditionOperators.$EQ]: "equal to",
|
||||||
@@ -250,7 +258,8 @@ export enum ProjectPermissionSub {
|
|||||||
Commits = "commits",
|
Commits = "commits",
|
||||||
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 = {
|
||||||
@@ -260,6 +269,14 @@ export type SecretSubjectFields = {
|
|||||||
secretTags: string[];
|
secretTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SecretEventSubjectFields = {
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
secretName: string;
|
||||||
|
secretTags: string[];
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SecretFolderSubjectFields = {
|
export type SecretFolderSubjectFields = {
|
||||||
environment: string;
|
environment: string;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
@@ -403,6 +420,13 @@ export type ProjectPermissionSet =
|
|||||||
ProjectPermissionSub.SecretScanningDataSources
|
ProjectPermissionSub.SecretScanningDataSources
|
||||||
]
|
]
|
||||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
|
||||||
|
| [
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
|
(
|
||||||
|
| ProjectPermissionSub.SecretEvents
|
||||||
|
| (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||||
|
@@ -1,84 +0,0 @@
|
|||||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
|
||||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} obj
|
|
||||||
* @param {Number} obj.encryptionVersion
|
|
||||||
* @param {String} obj.encryptedPrivateKey
|
|
||||||
* @param {String} obj.iv
|
|
||||||
* @param {String} obj.tag
|
|
||||||
* @param {String} obj.password
|
|
||||||
* @param {String} obj.salt
|
|
||||||
* @param {String} obj.protectedKey
|
|
||||||
* @param {String} obj.protectedKeyIV
|
|
||||||
* @param {String} obj.protectedKeyTag
|
|
||||||
*/
|
|
||||||
const decryptPrivateKeyHelper = async ({
|
|
||||||
encryptionVersion,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyIV,
|
|
||||||
protectedKeyTag
|
|
||||||
}: {
|
|
||||||
encryptionVersion: number;
|
|
||||||
encryptedPrivateKey: string;
|
|
||||||
iv: string;
|
|
||||||
tag: string;
|
|
||||||
password: string;
|
|
||||||
salt: string;
|
|
||||||
protectedKey?: string;
|
|
||||||
protectedKeyIV?: string;
|
|
||||||
protectedKeyTag?: string;
|
|
||||||
}) => {
|
|
||||||
let privateKey;
|
|
||||||
try {
|
|
||||||
if (encryptionVersion === 1) {
|
|
||||||
privateKey = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
secret: password
|
|
||||||
.slice(0, 32)
|
|
||||||
.padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0")
|
|
||||||
});
|
|
||||||
} else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) {
|
|
||||||
const derivedKey = await deriveArgonKey({
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
mem: 65536,
|
|
||||||
time: 3,
|
|
||||||
parallelism: 1,
|
|
||||||
hashLen: 32
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!derivedKey) throw new Error("Failed to generate derived key");
|
|
||||||
|
|
||||||
const key = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: protectedKey,
|
|
||||||
iv: protectedKeyIV,
|
|
||||||
tag: protectedKeyTag,
|
|
||||||
secret: Buffer.from(derivedKey.hash)
|
|
||||||
});
|
|
||||||
|
|
||||||
// decrypt back the private key
|
|
||||||
privateKey = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
secret: Buffer.from(key, "hex")
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Insufficient details to decrypt private key");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to decrypt private key");
|
|
||||||
}
|
|
||||||
|
|
||||||
return privateKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { decryptPrivateKeyHelper };
|
|
@@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request";
|
|||||||
import { accessApprovalKeys } from "./queries";
|
import { accessApprovalKeys } from "./queries";
|
||||||
import {
|
import {
|
||||||
TAccessApproval,
|
TAccessApproval,
|
||||||
|
TAccessApprovalRequest,
|
||||||
TCreateAccessPolicyDTO,
|
TCreateAccessPolicyDTO,
|
||||||
TCreateAccessRequestDTO,
|
TCreateAccessRequestDTO,
|
||||||
TDeleteSecretPolicyDTO,
|
TDeleteSecretPolicyDTO,
|
||||||
TUpdateAccessPolicyDTO
|
TUpdateAccessPolicyDTO,
|
||||||
|
TUpdateAccessRequestDTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const useCreateAccessApprovalPolicy = () => {
|
export const useCreateAccessApprovalPolicy = () => {
|
||||||
@@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useUpdateAccessRequest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<TAccessApprovalRequest, object, TUpdateAccessRequestDTO>({
|
||||||
|
mutationFn: async ({ requestId, ...payload }) => {
|
||||||
|
const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>(
|
||||||
|
`/api/v1/access-approvals/requests/${requestId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.approval;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useReviewAccessRequest = () => {
|
export const useReviewAccessRequest = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<
|
return useMutation<
|
||||||
|
@@ -24,7 +24,7 @@ export const accessApprovalKeys = {
|
|||||||
envSlug?: string,
|
envSlug?: string,
|
||||||
requestedBy?: string,
|
requestedBy?: string,
|
||||||
bypassReason?: string
|
bypassReason?: string
|
||||||
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const,
|
) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const,
|
||||||
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
||||||
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
||||||
};
|
};
|
||||||
|
@@ -103,6 +103,8 @@ export type TAccessApprovalRequest = {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
note?: string;
|
note?: string;
|
||||||
|
editNote?: string;
|
||||||
|
editedByUserId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAccessApproval = {
|
export type TAccessApproval = {
|
||||||
@@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
editNote: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TGetAccessApprovalRequestsDTO = {
|
export type TGetAccessApprovalRequestsDTO = {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
policyId?: string;
|
policyId?: string;
|
||||||
|
@@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
protectedKey: string;
|
|
||||||
protectedKeyTag: string;
|
|
||||||
protectedKeyIV: string;
|
|
||||||
encryptedPrivateKey: string;
|
|
||||||
encryptedPrivateKeyIV: string;
|
|
||||||
encryptedPrivateKeyTag: string;
|
|
||||||
publicKey: string;
|
|
||||||
verifier: string;
|
|
||||||
salt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminGetOrganizationsFilters = {
|
export type AdminGetOrganizationsFilters = {
|
||||||
|
@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
|
|||||||
);
|
);
|
||||||
return data.dynamicSecret;
|
return data.dynamicSecret;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
|
||||||
// TODO: optimize but currently don't pass projectId
|
// TODO: optimize but currently don't pass projectId
|
||||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
|
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -37,7 +37,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 enum KubernetesDynamicSecretCredentialType {
|
export enum KubernetesDynamicSecretCredentialType {
|
||||||
@@ -353,6 +354,38 @@ export type TDynamicSecretProvider =
|
|||||||
installationId: number;
|
installationId: number;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: DynamicSecretProviders.Couchbase;
|
||||||
|
inputs: {
|
||||||
|
url: string;
|
||||||
|
orgId: string;
|
||||||
|
projectId: string;
|
||||||
|
clusterId: string;
|
||||||
|
roles: string[];
|
||||||
|
buckets:
|
||||||
|
| string
|
||||||
|
| Array<{
|
||||||
|
name: string;
|
||||||
|
scopes?: Array<{
|
||||||
|
name: string;
|
||||||
|
collections?: string[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
passwordRequirements?: {
|
||||||
|
length: number;
|
||||||
|
required: {
|
||||||
|
lowercase: number;
|
||||||
|
uppercase: number;
|
||||||
|
digits: number;
|
||||||
|
symbols: number;
|
||||||
|
};
|
||||||
|
allowedSymbols?: string;
|
||||||
|
};
|
||||||
|
auth: {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreateDynamicSecretDTO = {
|
export type TCreateDynamicSecretDTO = {
|
||||||
|
@@ -1,77 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
import jsrp from "jsrp";
|
|
||||||
|
|
||||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
|
||||||
import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
|
||||||
|
|
||||||
export const generateUserPassKey = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
fipsEnabled: boolean
|
|
||||||
) => {
|
|
||||||
// eslint-disable-next-line new-cap
|
|
||||||
const client = new jsrp.client();
|
|
||||||
|
|
||||||
const { publicKey, privateKey } = await generateKeyPair(fipsEnabled);
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
client.init({ username: email, password }, () => resolve(null));
|
|
||||||
});
|
|
||||||
const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>(
|
|
||||||
(resolve, reject) => {
|
|
||||||
client.createVerifier((err, res) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
return resolve(res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedKey = await deriveArgonKey({
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
mem: 65536,
|
|
||||||
time: 3,
|
|
||||||
parallelism: 1,
|
|
||||||
hashLen: 32
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
|
||||||
|
|
||||||
const key = crypto.randomBytes(32);
|
|
||||||
|
|
||||||
// create encrypted private key by encrypting the private
|
|
||||||
// key with the symmetric key [key]
|
|
||||||
const {
|
|
||||||
ciphertext: encryptedPrivateKey,
|
|
||||||
iv: encryptedPrivateKeyIV,
|
|
||||||
tag: encryptedPrivateKeyTag
|
|
||||||
} = Aes256Gcm.encrypt({
|
|
||||||
text: privateKey,
|
|
||||||
secret: key
|
|
||||||
});
|
|
||||||
|
|
||||||
// create the protected key by encrypting the symmetric key
|
|
||||||
// [key] with the derived key
|
|
||||||
const {
|
|
||||||
ciphertext: protectedKey,
|
|
||||||
iv: protectedKeyIV,
|
|
||||||
tag: protectedKeyTag
|
|
||||||
} = Aes256Gcm.encrypt({
|
|
||||||
text: key.toString("hex"),
|
|
||||||
secret: Buffer.from(derivedKey.hash)
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyTag,
|
|
||||||
protectedKeyIV,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
encryptedPrivateKeyIV,
|
|
||||||
encryptedPrivateKeyTag,
|
|
||||||
publicKey,
|
|
||||||
verifier,
|
|
||||||
salt,
|
|
||||||
privateKey
|
|
||||||
};
|
|
||||||
};
|
|
@@ -12,7 +12,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient";
|
|||||||
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
||||||
import { useServerConfig } from "@app/context";
|
import { useServerConfig } from "@app/context";
|
||||||
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
||||||
import { generateUserPassKey } from "@app/lib/crypto";
|
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -48,17 +47,11 @@ export const SignUpPage = () => {
|
|||||||
// avoid multi submission
|
// avoid multi submission
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
try {
|
try {
|
||||||
const { privateKey, ...userPass } = await generateUserPassKey(
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
config.fipsEnabled
|
|
||||||
);
|
|
||||||
const res = await createAdminUser({
|
const res = await createAdminUser({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName
|
||||||
...userPass
|
|
||||||
});
|
});
|
||||||
|
|
||||||
SecurityClient.setToken(res.token);
|
SecurityClient.setToken(res.token);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { SingleValue } from "react-select";
|
import { SingleValue } from "react-select";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
enabled: caType === CaType.ACME
|
enabled: caType === CaType.ACME
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableConnections: TAvailableAppConnection[] = [
|
const availableConnections: TAvailableAppConnection[] = useMemo(
|
||||||
...(availableRoute53Connections || []),
|
() => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])],
|
||||||
...(availableCloudflareConnections || [])
|
[availableRoute53Connections, availableCloudflareConnections]
|
||||||
];
|
);
|
||||||
|
|
||||||
const isPending = isRoute53Pending || isCloudflarePending;
|
const isPending = isRoute53Pending || isCloudflarePending;
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { RoleOption } from "@app/components/roles";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FilterableSelect,
|
FilterableSelect,
|
||||||
@@ -45,7 +46,11 @@ const addMemberFormSchema = z.object({
|
|||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||||
organizationRole: z.object({ name: z.string(), slug: z.string() })
|
organizationRole: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string().optional()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
@@ -238,6 +243,7 @@ export const AddOrgMemberModal = ({
|
|||||||
getOptionLabel={(option) => option.name}
|
getOptionLabel={(option) => option.name}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
components={{ Option: RoleOption }}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
@@ -36,7 +36,8 @@ type GithubFormData = BaseFormData &
|
|||||||
type GithubRadarFormData = BaseFormData &
|
type GithubRadarFormData = BaseFormData &
|
||||||
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
||||||
|
|
||||||
type GitLabFormData = BaseFormData & Pick<TGitLabConnection, "name" | "method" | "description">;
|
type GitLabFormData = BaseFormData &
|
||||||
|
Pick<TGitLabConnection, "name" | "method" | "description" | "credentials">;
|
||||||
|
|
||||||
type AzureKeyVaultFormData = BaseFormData &
|
type AzureKeyVaultFormData = BaseFormData &
|
||||||
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
||||||
@@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => {
|
|||||||
|
|
||||||
clearState(AppConnection.GitLab);
|
clearState(AppConnection.GitLab);
|
||||||
|
|
||||||
const { connectionId, name, description, returnUrl, isUpdate } = formData;
|
const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isUpdate && connectionId) {
|
if (isUpdate && connectionId) {
|
||||||
@@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => {
|
|||||||
app: AppConnection.GitLab,
|
app: AppConnection.GitLab,
|
||||||
connectionId,
|
connectionId,
|
||||||
credentials: {
|
credentials: {
|
||||||
code: code as string
|
code: code as string,
|
||||||
|
instanceUrl: credentials.instanceUrl as string
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => {
|
|||||||
description,
|
description,
|
||||||
method: GitLabConnectionMethod.OAuth,
|
method: GitLabConnectionMethod.OAuth,
|
||||||
credentials: {
|
credentials: {
|
||||||
code: code as string
|
code: code as string,
|
||||||
|
instanceUrl: credentials.instanceUrl as string
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { RoleOption } from "@app/components/roles";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -320,6 +321,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
>
|
>
|
||||||
<FilterableSelect
|
<FilterableSelect
|
||||||
options={roles}
|
options={roles}
|
||||||
|
components={{ Option: RoleOption }}
|
||||||
placeholder="Select roles..."
|
placeholder="Select roles..."
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
@@ -21,6 +21,7 @@ import {
|
|||||||
ProjectPermissionPkiSubscriberActions,
|
ProjectPermissionPkiSubscriberActions,
|
||||||
ProjectPermissionPkiTemplateActions,
|
ProjectPermissionPkiTemplateActions,
|
||||||
ProjectPermissionSecretActions,
|
ProjectPermissionSecretActions,
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
ProjectPermissionSecretRotationActions,
|
ProjectPermissionSecretRotationActions,
|
||||||
ProjectPermissionSecretScanningConfigActions,
|
ProjectPermissionSecretScanningConfigActions,
|
||||||
ProjectPermissionSecretScanningDataSourceActions,
|
ProjectPermissionSecretScanningDataSourceActions,
|
||||||
@@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({
|
|||||||
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
|
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SecretEventsPolicyActionSchema = z.object({
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
const SecretRollbackPolicyActionSchema = z.object({
|
const SecretRollbackPolicyActionSchema = z.object({
|
||||||
read: z.boolean().optional(),
|
read: z.boolean().optional(),
|
||||||
create: z.boolean().optional()
|
create: z.boolean().optional()
|
||||||
@@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({
|
|||||||
[ProjectPermissionSub.SecretScanningFindings]:
|
[ProjectPermissionSub.SecretScanningFindings]:
|
||||||
SecretScanningFindingPolicyActionSchema.array().default([]),
|
SecretScanningFindingPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.SecretScanningConfigs]:
|
[ProjectPermissionSub.SecretScanningConfigs]:
|
||||||
SecretScanningConfigPolicyActionSchema.array().default([])
|
SecretScanningConfigPolicyActionSchema.array().default([]),
|
||||||
|
[ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({
|
||||||
|
conditions: ConditionSchema
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.default([])
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -374,7 +387,8 @@ type TConditionalFields =
|
|||||||
| ProjectPermissionSub.SshHosts
|
| ProjectPermissionSub.SshHosts
|
||||||
| ProjectPermissionSub.SecretRotation
|
| ProjectPermissionSub.SecretRotation
|
||||||
| ProjectPermissionSub.Identity
|
| ProjectPermissionSub.Identity
|
||||||
| ProjectPermissionSub.SecretSyncs;
|
| ProjectPermissionSub.SecretSyncs
|
||||||
|
| ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
export const isConditionalSubjects = (
|
export const isConditionalSubjects = (
|
||||||
subject: ProjectPermissionSub
|
subject: ProjectPermissionSub
|
||||||
@@ -388,7 +402,8 @@ export const isConditionalSubjects = (
|
|||||||
subject === ProjectPermissionSub.SecretRotation ||
|
subject === ProjectPermissionSub.SecretRotation ||
|
||||||
subject === ProjectPermissionSub.PkiSubscribers ||
|
subject === ProjectPermissionSub.PkiSubscribers ||
|
||||||
subject === ProjectPermissionSub.CertificateTemplates ||
|
subject === ProjectPermissionSub.CertificateTemplates ||
|
||||||
subject === ProjectPermissionSub.SecretSyncs;
|
subject === ProjectPermissionSub.SecretSyncs ||
|
||||||
|
subject === ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||||
const formConditions: z.infer<typeof ConditionSchema> = [];
|
const formConditions: z.infer<typeof ConditionSchema> = [];
|
||||||
@@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
ProjectPermissionSub.SshCertificateAuthorities,
|
ProjectPermissionSub.SshCertificateAuthorities,
|
||||||
ProjectPermissionSub.SshCertificates,
|
ProjectPermissionSub.SshCertificates,
|
||||||
ProjectPermissionSub.SshHostGroups,
|
ProjectPermissionSub.SshHostGroups,
|
||||||
ProjectPermissionSub.SecretSyncs
|
ProjectPermissionSub.SecretSyncs,
|
||||||
|
ProjectPermissionSub.SecretEvents
|
||||||
].includes(subject)
|
].includes(subject)
|
||||||
) {
|
) {
|
||||||
// from above statement we are sure it won't be undefined
|
// from above statement we are sure it won't be undefined
|
||||||
@@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subject === ProjectPermissionSub.SecretEvents) {
|
||||||
|
const canSubscribeCreate = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeCreated
|
||||||
|
);
|
||||||
|
const canSubscribeUpdate = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeUpdated
|
||||||
|
);
|
||||||
|
const canSubscribeDelete = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeDeleted
|
||||||
|
);
|
||||||
|
const canSubscribeImportMutations = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
);
|
||||||
|
|
||||||
|
// from above statement we are sure it won't be undefined
|
||||||
|
formVal[subject]!.push({
|
||||||
|
"subscribe-on-created": canSubscribeCreate,
|
||||||
|
"subscribe-on-deleted": canSubscribeDelete,
|
||||||
|
"subscribe-on-updated": canSubscribeUpdate,
|
||||||
|
"subscribe-on-import-mutations": canSubscribeImportMutations,
|
||||||
|
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// for other subjects
|
// for other subjects
|
||||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||||
@@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||||
{ label: "Create", value: ProjectPermissionSecretActions.Create },
|
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||||
{ label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[ProjectPermissionSub.SecretFolders]: {
|
[ProjectPermissionSub.SecretFolders]: {
|
||||||
@@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
value: ProjectPermissionSecretScanningConfigActions.Update
|
value: ProjectPermissionSecretScanningConfigActions.Update
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
[ProjectPermissionSub.SecretEvents]: {
|
||||||
|
title: "Secret Events",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "Subscribe on Created",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeCreated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Deleted",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeDeleted
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Updated",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeUpdated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Import Mutations",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({
|
|||||||
[ProjectPermissionSub.SecretRollback]: enabled,
|
[ProjectPermissionSub.SecretRollback]: enabled,
|
||||||
[ProjectPermissionSub.SecretRotation]: enabled,
|
[ProjectPermissionSub.SecretRotation]: enabled,
|
||||||
[ProjectPermissionSub.ServiceTokens]: enabled,
|
[ProjectPermissionSub.ServiceTokens]: enabled,
|
||||||
[ProjectPermissionSub.Commits]: enabled
|
[ProjectPermissionSub.Commits]: enabled,
|
||||||
|
[ProjectPermissionSub.SecretEvents]: enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
const KmsPermissionSubjects = (enabled = false) => ({
|
const KmsPermissionSubjects = (enabled = false) => ({
|
||||||
|
@@ -32,6 +32,7 @@ import {
|
|||||||
rolePermission2Form,
|
rolePermission2Form,
|
||||||
TFormSchema
|
TFormSchema
|
||||||
} from "./ProjectRoleModifySection.utils";
|
} from "./ProjectRoleModifySection.utils";
|
||||||
|
import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions";
|
||||||
import { SecretPermissionConditions } from "./SecretPermissionConditions";
|
import { SecretPermissionConditions } from "./SecretPermissionConditions";
|
||||||
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
|
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
|
||||||
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
|
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
|
||||||
@@ -72,6 +73,10 @@ export const renderConditionalComponents = (
|
|||||||
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
|
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subject === ProjectPermissionSub.SecretEvents) {
|
||||||
|
return <SecretEventPermissionConditions isDisabled={isDisabled} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types";
|
||||||
|
|
||||||
|
import { ConditionsFields } from "./ConditionsFields";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
position?: number;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||||
|
return (
|
||||||
|
<ConditionsFields
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
subject={ProjectPermissionSub.SecretEvents}
|
||||||
|
position={position}
|
||||||
|
selectOptions={[
|
||||||
|
{ value: "environment", label: "Environment Slug" },
|
||||||
|
{ value: "secretPath", label: "Secret Path" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
|
|||||||
{ value: "environment", label: "Environment Slug" },
|
{ value: "environment", label: "Environment Slug" },
|
||||||
{ value: "secretPath", label: "Secret Path" },
|
{ value: "secretPath", label: "Secret Path" },
|
||||||
{ value: "secretName", label: "Secret Name" },
|
{ value: "secretName", label: "Secret Name" },
|
||||||
{ value: "secretTags", label: "Secret Tags" },
|
{ value: "secretTags", label: "Secret Tags" }
|
||||||
{ value: "eventType", label: "Event Type" }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@@ -24,7 +24,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
|||||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Displaying audit logs from the last {auditLogsRetentionDays} days
|
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<LogsSection
|
<LogsSection
|
||||||
@@ -32,7 +32,9 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
|||||||
showFilters={false}
|
showFilters={false}
|
||||||
presets={{
|
presets={{
|
||||||
eventMetadata: { integrationId: integration.id },
|
eventMetadata: { integrationId: integration.id },
|
||||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
startDate: new Date(
|
||||||
|
new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60))
|
||||||
|
),
|
||||||
eventType: INTEGRATION_EVENTS
|
eventType: INTEGRATION_EVENTS
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -10,6 +10,7 @@ import {
|
|||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useProjectPermission,
|
useProjectPermission,
|
||||||
|
useSubscription,
|
||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||||
@@ -51,6 +52,7 @@ export const SelectionPanel = ({
|
|||||||
usedBySecretSyncs = []
|
usedBySecretSyncs = []
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"bulkDeleteEntries",
|
"bulkDeleteEntries",
|
||||||
@@ -101,6 +103,16 @@ export const SelectionPanel = ({
|
|||||||
return "Do you want to delete the selected folders across environments?";
|
return "Do you want to delete the selected folders across environments?";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeleteModalSubTitle = () => {
|
||||||
|
if (selectedFolderCount > 0) {
|
||||||
|
if (subscription?.pitRecovery) {
|
||||||
|
return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit.";
|
||||||
|
}
|
||||||
|
return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature.";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
let processedEntries = 0;
|
let processedEntries = 0;
|
||||||
|
|
||||||
@@ -279,6 +291,7 @@ export const SelectionPanel = ({
|
|||||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||||
deleteKey="delete"
|
deleteKey="delete"
|
||||||
title={getDeleteModalTitle()}
|
title={getDeleteModalTitle()}
|
||||||
|
subTitle={getDeleteModalSubTitle()}
|
||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
onDeleteApproved={handleBulkDelete}
|
onDeleteApproved={handleBulkDelete}
|
||||||
formContent={
|
formContent={
|
||||||
|
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
|
|||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
refetchRequests();
|
refetchRequests();
|
||||||
}}
|
}}
|
||||||
|
onUpdate={(request) => {
|
||||||
|
// scott: this isn't ideal but our current use of state makes this complicated...
|
||||||
|
// we shouldn't be using state like this...
|
||||||
|
handleSelectRequest({
|
||||||
|
...selectedRequest,
|
||||||
|
isTemporary: request.isTemporary,
|
||||||
|
temporaryRange: request.temporaryRange,
|
||||||
|
reviewers: []
|
||||||
|
});
|
||||||
|
}}
|
||||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@@ -0,0 +1,185 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalContent,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation";
|
||||||
|
import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries";
|
||||||
|
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||||
|
|
||||||
|
type ContentProps = {
|
||||||
|
accessRequest: TAccessApprovalRequest;
|
||||||
|
onComplete: (request: TAccessApprovalRequest) => void;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditSchema = z.object({
|
||||||
|
temporaryRange: z
|
||||||
|
.string()
|
||||||
|
.nonempty("Required")
|
||||||
|
.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().nonempty("Required")
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof EditSchema>;
|
||||||
|
|
||||||
|
const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => {
|
||||||
|
const update = useUpdateAccessRequest();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(EditSchema),
|
||||||
|
defaultValues: {
|
||||||
|
temporaryRange: accessRequest.temporaryRange ?? "1h",
|
||||||
|
editNote: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (form: FormData) => {
|
||||||
|
try {
|
||||||
|
const request = await update.mutateAsync({
|
||||||
|
requestId: accessRequest.id,
|
||||||
|
projectSlug,
|
||||||
|
...form
|
||||||
|
});
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Access request updated successfully."
|
||||||
|
});
|
||||||
|
onComplete(request);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to update access request"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="mb-4 flex w-full items-start rounded-md border border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
|
||||||
|
<FontAwesomeIcon icon={faWarning} className="mr-2.5 mt-1 text-base text-yellow" />
|
||||||
|
Updating this access request will restart the review process and require all approvers to
|
||||||
|
re-approve it.
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue="1h"
|
||||||
|
name="temporaryRange"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Access Duration" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="editNote"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Reason for Editing"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
className="!resize-none"
|
||||||
|
rows={4}
|
||||||
|
{...field}
|
||||||
|
placeholder="Provide a reason for updating this request..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex gap-x-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Update Request
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="plain" colorSchema="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
accessRequest?: TAccessApprovalRequest;
|
||||||
|
onComplete: (request: TAccessApprovalRequest) => void;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditAccessRequestModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
accessRequest,
|
||||||
|
onComplete,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
if (!accessRequest) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
title="Edit Access Request"
|
||||||
|
subTitle="Modify this access request for re-approval."
|
||||||
|
>
|
||||||
|
<Content
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
accessRequest={accessRequest}
|
||||||
|
onComplete={(request) => {
|
||||||
|
onComplete(request);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
faBan,
|
faBan,
|
||||||
faCheck,
|
faCheck,
|
||||||
|
faEdit,
|
||||||
faHourglass,
|
faHourglass,
|
||||||
faTriangleExclamation
|
faTriangleExclamation
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
FormControl,
|
FormControl,
|
||||||
GenericFieldLabel,
|
GenericFieldLabel,
|
||||||
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge } from "@app/components/v2/Badge";
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
||||||
import {
|
import {
|
||||||
Approver,
|
Approver,
|
||||||
@@ -32,6 +35,7 @@ import {
|
|||||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
import { groupBy } from "@app/lib/fn/array";
|
import { groupBy } from "@app/lib/fn/array";
|
||||||
|
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
|
||||||
|
|
||||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||||
if (status === ApprovalStatus.APPROVED)
|
if (status === ApprovalStatus.APPROVED)
|
||||||
@@ -62,7 +66,8 @@ export const ReviewAccessRequestModal = ({
|
|||||||
selectedEnvSlug,
|
selectedEnvSlug,
|
||||||
canBypass,
|
canBypass,
|
||||||
policies = [],
|
policies = [],
|
||||||
members = []
|
members = [],
|
||||||
|
onUpdate
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
@@ -78,6 +83,7 @@ export const ReviewAccessRequestModal = ({
|
|||||||
canBypass: boolean;
|
canBypass: boolean;
|
||||||
policies: TAccessApprovalPolicy[];
|
policies: TAccessApprovalPolicy[];
|
||||||
members: TWorkspaceUser[];
|
members: TWorkspaceUser[];
|
||||||
|
onUpdate: (request: TAccessApprovalRequest) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||||
const [bypassApproval, setBypassApproval] = useState(false);
|
const [bypassApproval, setBypassApproval] = useState(false);
|
||||||
@@ -86,6 +92,8 @@ export const ReviewAccessRequestModal = ({
|
|||||||
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
|
||||||
|
|
||||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||||
|
|
||||||
const accessDetails = {
|
const accessDetails = {
|
||||||
@@ -121,16 +129,9 @@ export const ReviewAccessRequestModal = ({
|
|||||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||||
return "Permanent";
|
return "Permanent";
|
||||||
|
|
||||||
// convert the range to human readable format
|
return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
long: true
|
||||||
|
})} after approval`;
|
||||||
return (
|
|
||||||
<Badge>
|
|
||||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
|
||||||
long: true
|
|
||||||
})} after approval`}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reviewAccessRequest = useReviewAccessRequest();
|
const reviewAccessRequest = useReviewAccessRequest();
|
||||||
@@ -286,7 +287,33 @@ export const ReviewAccessRequestModal = ({
|
|||||||
<GenericFieldLabel truncate label="Secret Path">
|
<GenericFieldLabel truncate label="Secret Path">
|
||||||
{accessDetails.secretPath}
|
{accessDetails.secretPath}
|
||||||
</GenericFieldLabel>
|
</GenericFieldLabel>
|
||||||
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
|
<GenericFieldLabel label="Access Duration">
|
||||||
|
<div className="flex h-min gap-1">
|
||||||
|
{getAccessLabel()}
|
||||||
|
{request.isApprover && request.status === ApprovalStatus.PENDING && (
|
||||||
|
<>
|
||||||
|
<EditAccessRequestModal
|
||||||
|
isOpen={popUp.editRequest.isOpen}
|
||||||
|
onOpenChange={(open) => handlePopUpToggle("editRequest", open)}
|
||||||
|
accessRequest={request}
|
||||||
|
onComplete={onUpdate}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
/>
|
||||||
|
<Tooltip content="Edit Access Duration">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handlePopUpOpen("editRequest")}
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
tabIndex={-1}
|
||||||
|
ariaLabel="Edit access duration"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GenericFieldLabel>
|
||||||
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
|
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
|
||||||
{request.note && (
|
{request.note && (
|
||||||
<GenericFieldLabel className="col-span-full" label="Note">
|
<GenericFieldLabel className="col-span-full" label="Note">
|
||||||
|
@@ -92,7 +92,7 @@ const formSchema = z
|
|||||||
.array()
|
.array()
|
||||||
.default([])
|
.default([])
|
||||||
.optional(),
|
.optional(),
|
||||||
maxTimePeriod: z.string().trim().optional()
|
maxTimePeriod: z.string().trim().nullish()
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.policyType === PolicyType.ChangePolicy) {
|
if (data.policyType === PolicyType.ChangePolicy) {
|
||||||
@@ -454,7 +454,7 @@ const Form = ({
|
|||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
className="flex-shrink"
|
className="flex-shrink"
|
||||||
>
|
>
|
||||||
<Input {...field} value={field.value} placeholder="permanent" />
|
<Input {...field} value={field.value || ""} placeholder="permanent" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@@ -328,12 +328,12 @@ const Page = () => {
|
|||||||
createNotification({
|
createNotification({
|
||||||
text: isProtectedBranch
|
text: isProtectedBranch
|
||||||
? "Requested changes have been sent for review"
|
? "Requested changes have been sent for review"
|
||||||
: "Changes committed successfully",
|
: "Changes saved successfully",
|
||||||
type: "success"
|
type: "success"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
createNotification({
|
createNotification({
|
||||||
text: "Failed to commit changes",
|
text: "Failed to save changes",
|
||||||
type: "error"
|
type: "error"
|
||||||
});
|
});
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@@ -14,6 +14,7 @@ import {
|
|||||||
faFingerprint,
|
faFingerprint,
|
||||||
faFolder,
|
faFolder,
|
||||||
faFolderPlus,
|
faFolderPlus,
|
||||||
|
faInfoCircle,
|
||||||
faKey,
|
faKey,
|
||||||
faLock,
|
faLock,
|
||||||
faPaste,
|
faPaste,
|
||||||
@@ -32,6 +33,7 @@ import { createNotification } from "@app/components/notifications";
|
|||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -97,7 +99,7 @@ import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
|||||||
import { FolderForm } from "./FolderForm";
|
import { FolderForm } from "./FolderForm";
|
||||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||||
|
|
||||||
type TParsedEnv = Record<string, { value: string; comments: string[]; secretPath?: string }>;
|
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
||||||
type TParsedFolderEnv = Record<
|
type TParsedFolderEnv = Record<
|
||||||
string,
|
string,
|
||||||
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
||||||
@@ -405,8 +407,8 @@ export const ActionBar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allUpdateSecrets: TParsedEnv = {};
|
const allUpdateSecrets: TParsedEnv = [];
|
||||||
const allCreateSecrets: TParsedEnv = {};
|
const allCreateSecrets: TParsedEnv = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
||||||
@@ -437,7 +439,7 @@ export const ActionBar = ({
|
|||||||
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingSecretLookup: Record<string, boolean> = {};
|
const existingSecretLookup = new Set<string>();
|
||||||
|
|
||||||
const processBatches = async () => {
|
const processBatches = async () => {
|
||||||
await secretBatches.reduce(async (previous, batch) => {
|
await secretBatches.reduce(async (previous, batch) => {
|
||||||
@@ -451,7 +453,7 @@ export const ActionBar = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
batchSecrets.forEach((secret) => {
|
batchSecrets.forEach((secret) => {
|
||||||
existingSecretLookup[secret.secretKey] = true;
|
existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`);
|
||||||
});
|
});
|
||||||
}, Promise.resolve());
|
}, Promise.resolve());
|
||||||
};
|
};
|
||||||
@@ -465,18 +467,18 @@ export const ActionBar = ({
|
|||||||
// Store the path with the secret for later batch processing
|
// Store the path with the secret for later batch processing
|
||||||
const secretWithPath = {
|
const secretWithPath = {
|
||||||
...secretData,
|
...secretData,
|
||||||
secretPath: normalizedPath
|
secretPath: normalizedPath,
|
||||||
|
secretKey
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingSecretLookup[secretKey]) {
|
if (existingSecretLookup.has(`${normalizedPath}-${secretKey}`)) {
|
||||||
allUpdateSecrets[secretKey] = secretWithPath;
|
allUpdateSecrets.push(secretWithPath);
|
||||||
} else {
|
} else {
|
||||||
allCreateSecrets[secretKey] = secretWithPath;
|
allCreateSecrets.push(secretWithPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
handlePopUpOpen("confirmUpload", {
|
handlePopUpOpen("confirmUpload", {
|
||||||
update: allUpdateSecrets,
|
update: allUpdateSecrets,
|
||||||
create: allCreateSecrets
|
create: allCreateSecrets
|
||||||
@@ -519,7 +521,7 @@ export const ActionBar = ({
|
|||||||
const allPaths = new Set<string>();
|
const allPaths = new Set<string>();
|
||||||
|
|
||||||
// Add paths from create secrets
|
// Add paths from create secrets
|
||||||
Object.values(create || {}).forEach((secData) => {
|
create.forEach((secData) => {
|
||||||
if (secData.secretPath && secData.secretPath !== secretPath) {
|
if (secData.secretPath && secData.secretPath !== secretPath) {
|
||||||
allPaths.add(secData.secretPath);
|
allPaths.add(secData.secretPath);
|
||||||
}
|
}
|
||||||
@@ -575,8 +577,8 @@ export const ActionBar = ({
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, Promise.resolve());
|
}, Promise.resolve());
|
||||||
|
|
||||||
if (Object.keys(create || {}).length > 0) {
|
if (create.length > 0) {
|
||||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
create.forEach((secData) => {
|
||||||
// Use the stored secretPath or fall back to the current secretPath
|
// Use the stored secretPath or fall back to the current secretPath
|
||||||
const path = secData.secretPath || secretPath;
|
const path = secData.secretPath || secretPath;
|
||||||
|
|
||||||
@@ -588,7 +590,7 @@ export const ActionBar = ({
|
|||||||
type: SecretType.Shared,
|
type: SecretType.Shared,
|
||||||
secretComment: secData.comments.join("\n"),
|
secretComment: secData.comments.join("\n"),
|
||||||
secretValue: secData.value,
|
secretValue: secData.value,
|
||||||
secretKey
|
secretKey: secData.secretKey
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -604,8 +606,8 @@ export const ActionBar = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update || {}).length > 0) {
|
if (update.length > 0) {
|
||||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
update.forEach((secData) => {
|
||||||
// Use the stored secretPath or fall back to the current secretPath
|
// Use the stored secretPath or fall back to the current secretPath
|
||||||
const path = secData.secretPath || secretPath;
|
const path = secData.secretPath || secretPath;
|
||||||
|
|
||||||
@@ -617,7 +619,7 @@ export const ActionBar = ({
|
|||||||
type: SecretType.Shared,
|
type: SecretType.Shared,
|
||||||
secretComment: secData.comments.join("\n"),
|
secretComment: secData.comments.join("\n"),
|
||||||
secretValue: secData.value,
|
secretValue: secData.value,
|
||||||
secretKey
|
secretKey: secData.secretKey
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -665,6 +667,8 @@ export const ActionBar = ({
|
|||||||
const isTableFiltered =
|
const isTableFiltered =
|
||||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||||
|
|
||||||
|
const filteredTags = Object.values(filter?.tags ?? {}).filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex items-center space-x-2">
|
<div className="mt-4 flex items-center space-x-2">
|
||||||
@@ -697,7 +701,7 @@ export const ActionBar = ({
|
|||||||
Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="p-0">
|
<DropdownMenuContent align="end" sideOffset={2} className="p-0">
|
||||||
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -776,11 +780,19 @@ export const ActionBar = ({
|
|||||||
iconPos="right"
|
iconPos="right"
|
||||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||||
>
|
>
|
||||||
Tags
|
<div className="flex w-full justify-between">
|
||||||
|
<span>Tags</span>
|
||||||
|
{Boolean(filteredTags) && <Badge>{filteredTags} Applied</Badge>}
|
||||||
|
</div>
|
||||||
</DropdownSubMenuTrigger>
|
</DropdownSubMenuTrigger>
|
||||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||||
Apply Tags to Filter Secrets
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span>Filter by Secret Tags</span>
|
||||||
|
<Tooltip content="Matches secrets with one or more of the applied tags">
|
||||||
|
<FontAwesomeIcon icon={faInfoCircle} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{tags.map(({ id, slug, color }) => (
|
{tags.map(({ id, slug, color }) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -1229,8 +1241,8 @@ export const ActionBar = ({
|
|||||||
<div className="flex flex-col text-gray-300">
|
<div className="flex flex-col text-gray-300">
|
||||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||||
<div className="mt-2 text-sm text-gray-400">
|
<div className="mt-2 text-sm text-gray-400">
|
||||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
{(popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update
|
||||||
?.map((key) => key)
|
?.map((sec) => sec.secretKey)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
@@ -0,0 +1,948 @@
|
|||||||
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
SecretInput,
|
||||||
|
Switch,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||||
|
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||||
|
import { slugSchema } from "@app/lib/schemas";
|
||||||
|
|
||||||
|
// Component for managing scopes and collections within a bucket
|
||||||
|
const BucketScopesConfiguration = ({
|
||||||
|
control,
|
||||||
|
bucketIndex,
|
||||||
|
bucketsValue,
|
||||||
|
setValue,
|
||||||
|
addScope,
|
||||||
|
removeScope,
|
||||||
|
addCollection,
|
||||||
|
removeCollection
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
bucketIndex: number;
|
||||||
|
bucketsValue: any;
|
||||||
|
setValue: any;
|
||||||
|
addScope: (bucketIndex: number) => void;
|
||||||
|
removeScope: (bucketIndex: number, scopeIndex: number) => void;
|
||||||
|
addCollection: (bucketIndex: number, scopeIndex: number) => void;
|
||||||
|
removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void;
|
||||||
|
}) => {
|
||||||
|
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
|
||||||
|
const scopeFields = bucket?.scopes || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-mineshaft-300">Scopes</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addScope(bucketIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Scope
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeFields.map((_scope: any, scopeIndex: number) => (
|
||||||
|
<div
|
||||||
|
key={`scope-${scopeIndex + 1}`}
|
||||||
|
className="space-y-3 rounded border border-mineshaft-600 bg-mineshaft-700 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove scope"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeScope(bucketIndex, scopeIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`provider.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 pl-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-mineshaft-300">Collections</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addCollection(bucketIndex, scopeIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeFields[scopeIndex]?.collections?.map(
|
||||||
|
(collection: string, collectionIndex: number) => (
|
||||||
|
<div
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={collection || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections[
|
||||||
|
collectionIndex
|
||||||
|
] = e.target.value;
|
||||||
|
setValue("provider.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., airport, airline"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove collection"
|
||||||
|
className="mb-4"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!scopeFields[scopeIndex]?.collections ||
|
||||||
|
scopeFields[scopeIndex].collections.length === 0) && (
|
||||||
|
<div className="text-xs italic text-mineshaft-400">
|
||||||
|
No collections specified (access to all collections in scope)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{scopeFields.length === 0 && (
|
||||||
|
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
|
||||||
|
<p className="mb-2 text-xs text-mineshaft-400">
|
||||||
|
No scopes configured (access to all scopes in bucket)
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addScope(bucketIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Scope
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const couchbaseRoles = [
|
||||||
|
{ value: "read", label: "Read", description: "Read-only access to bucket data" },
|
||||||
|
{
|
||||||
|
value: "write",
|
||||||
|
label: "Write",
|
||||||
|
description: "Full write access to bucket data"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const passwordRequirementsSchema = 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");
|
||||||
|
|
||||||
|
const bucketSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Bucket name is required"),
|
||||||
|
scopes: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, "Scope name is required"),
|
||||||
|
collections: z.array(z.string().trim().min(1)).optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
provider: z.object({
|
||||||
|
url: z.string().url().trim().min(1),
|
||||||
|
orgId: z.string().trim().min(1),
|
||||||
|
projectId: z.string().trim().min(1),
|
||||||
|
clusterId: z.string().trim().min(1),
|
||||||
|
roles: z.array(z.string()).min(1, "At least one role must be selected"),
|
||||||
|
buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
|
||||||
|
useAdvancedBuckets: z.boolean().default(false),
|
||||||
|
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||||
|
auth: z.object({
|
||||||
|
apiKey: z.string().trim().min(1)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
name: slugSchema(),
|
||||||
|
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||||
|
usernameTemplate: z.string().nullable().optional()
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onCompleted: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
secretPath: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environments: WorkspaceEnv[];
|
||||||
|
isSingleEnvironmentMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CouchbaseInputForm = ({
|
||||||
|
onCompleted,
|
||||||
|
onCancel,
|
||||||
|
environments,
|
||||||
|
secretPath,
|
||||||
|
projectSlug,
|
||||||
|
isSingleEnvironmentMode
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
watch
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
provider: {
|
||||||
|
url: "https://cloudapi.cloud.couchbase.com",
|
||||||
|
roles: ["read"],
|
||||||
|
buckets: "*",
|
||||||
|
useAdvancedBuckets: false,
|
||||||
|
passwordRequirements: {
|
||||||
|
length: 12,
|
||||||
|
required: {
|
||||||
|
lowercase: 1,
|
||||||
|
uppercase: 1,
|
||||||
|
digits: 1,
|
||||||
|
symbols: 1
|
||||||
|
},
|
||||||
|
allowedSymbols: "!@#$%^()_+-=[]{}:,?/~`"
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
apiKey: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
environment: isSingleEnvironmentMode ? environments[0] : undefined,
|
||||||
|
usernameTemplate: "{{randomUsername}}"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDynamicSecret = useCreateDynamicSecret();
|
||||||
|
|
||||||
|
const isAdvancedMode = watch("provider.useAdvancedBuckets");
|
||||||
|
const bucketsValue = watch("provider.buckets");
|
||||||
|
|
||||||
|
const addBucket = () => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||||
|
setValue("provider.buckets", [...currentBuckets, { name: "", scopes: [] }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBucket = (index: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||||
|
const newBuckets = currentBuckets.filter((_, i) => i !== index);
|
||||||
|
setValue("provider.buckets", newBuckets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addScope = (bucketIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]) {
|
||||||
|
const currentScopes = currentBuckets[bucketIndex].scopes || [];
|
||||||
|
currentBuckets[bucketIndex] = {
|
||||||
|
...currentBuckets[bucketIndex],
|
||||||
|
scopes: [...currentScopes, { name: "", collections: [] }]
|
||||||
|
};
|
||||||
|
setValue("provider.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeScope = (bucketIndex: number, scopeIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes) {
|
||||||
|
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
|
||||||
|
(_, i) => i !== scopeIndex
|
||||||
|
);
|
||||||
|
setValue("provider.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCollection = (bucketIndex: number, scopeIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) {
|
||||||
|
const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || [];
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""];
|
||||||
|
setValue("provider.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
|
||||||
|
bucketIndex
|
||||||
|
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
|
||||||
|
setValue("provider.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateDynamicSecret = async ({
|
||||||
|
name,
|
||||||
|
maxTTL,
|
||||||
|
provider,
|
||||||
|
defaultTTL,
|
||||||
|
environment,
|
||||||
|
usernameTemplate
|
||||||
|
}: TForm) => {
|
||||||
|
if (createDynamicSecret.isPending) return;
|
||||||
|
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
|
||||||
|
|
||||||
|
const transformedProvider = {
|
||||||
|
...provider,
|
||||||
|
buckets: provider.useAdvancedBuckets ? provider.buckets : (provider.buckets as string)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { useAdvancedBuckets, ...finalProvider } = transformedProvider;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createDynamicSecret.mutateAsync({
|
||||||
|
provider: { type: DynamicSecretProviders.Couchbase, inputs: finalProvider },
|
||||||
|
maxTTL,
|
||||||
|
name,
|
||||||
|
path: secretPath,
|
||||||
|
defaultTTL,
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment.slug,
|
||||||
|
usernameTemplate:
|
||||||
|
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
|
||||||
|
});
|
||||||
|
onCompleted();
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Secret Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="dynamic-secret" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTTL"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxTTL"
|
||||||
|
defaultValue="24h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Max TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.url"
|
||||||
|
defaultValue="https://cloudapi.cloud.couchbase.com"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="URL"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.orgId"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Organization ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.projectId"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Project ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.clusterId"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Cluster ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.roles"
|
||||||
|
defaultValue={["read"]}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Roles"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Select one or more roles to assign to the user"
|
||||||
|
>
|
||||||
|
<FilterableSelect
|
||||||
|
isMulti
|
||||||
|
value={couchbaseRoles.filter((role) => value?.includes(role.value))}
|
||||||
|
onChange={(selectedRoles) => {
|
||||||
|
if (Array.isArray(selectedRoles)) {
|
||||||
|
onChange(selectedRoles.map((role: any) => role.value));
|
||||||
|
} else {
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={couchbaseRoles}
|
||||||
|
placeholder="Select roles..."
|
||||||
|
getOptionLabel={(option) => option.label}
|
||||||
|
getOptionValue={(option) => option.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.useAdvancedBuckets"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Advanced Bucket Configuration"
|
||||||
|
helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="advanced-buckets-switch"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onChange(checked);
|
||||||
|
const bucketsController = getValues("provider.buckets");
|
||||||
|
if (checked && typeof bucketsController === "string") {
|
||||||
|
setValue("provider.buckets", []);
|
||||||
|
} else if (!checked && Array.isArray(bucketsController)) {
|
||||||
|
setValue("provider.buckets", "*");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!watch("provider.useAdvancedBuckets") && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.buckets"
|
||||||
|
defaultValue="*"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Bucket Access"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={typeof field.value === "string" ? field.value : "*"}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdvancedMode && Array.isArray(bucketsValue) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-mineshaft-200">
|
||||||
|
Advanced Bucket Configuration
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-mineshaft-400">
|
||||||
|
Configure specific buckets with their scopes and collections. Leave scopes
|
||||||
|
empty for access to all scopes in a bucket.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={addBucket}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.isArray(bucketsValue) &&
|
||||||
|
(bucketsValue as any[]).map((_, bucketIndex) => (
|
||||||
|
<div
|
||||||
|
key={`bucket-${bucketIndex + 1}`}
|
||||||
|
className="space-y-4 rounded border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-mineshaft-200">
|
||||||
|
Bucket {bucketIndex + 1}
|
||||||
|
</h4>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove bucket"
|
||||||
|
onClick={() => removeBucket(bucketIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`provider.buckets.${bucketIndex}.name`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Bucket Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="e.g., travel-sample" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BucketScopesConfiguration
|
||||||
|
control={control}
|
||||||
|
bucketIndex={bucketIndex}
|
||||||
|
bucketsValue={bucketsValue}
|
||||||
|
setValue={setValue}
|
||||||
|
addScope={addScope}
|
||||||
|
removeScope={removeScope}
|
||||||
|
addCollection={addCollection}
|
||||||
|
removeCollection={removeCollection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && (
|
||||||
|
<div className="rounded border border-dashed border-mineshaft-600 p-8 text-center">
|
||||||
|
<p className="mb-2 text-sm text-mineshaft-400">No buckets configured</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={addBucket}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add First Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
name="provider.auth.apiKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
className="w-full"
|
||||||
|
label="API Key"
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||||
|
value={value}
|
||||||
|
valueAlwaysHidden
|
||||||
|
rows={1}
|
||||||
|
wrap="hard"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="usernameTemplate"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Username Template"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value || undefined}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
placeholder="{{randomUsername}}"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||||
|
<AccordionItem value="password-config">
|
||||||
|
<AccordionTrigger>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Password Configuration (optional)</span>
|
||||||
|
<Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | £">
|
||||||
|
<div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="mb-4 text-sm text-mineshaft-300">
|
||||||
|
Set constraints on the generated Couchbase user password (8-128 characters)
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-mineshaft-400">
|
||||||
|
Forbidden characters: < > ; . * & | £
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.length"
|
||||||
|
defaultValue={12}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Password Length"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={128}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const total = Object.values(
|
||||||
|
watch("provider.passwordRequirements.required") || {}
|
||||||
|
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||||
|
const length = watch("provider.passwordRequirements.length") || 0;
|
||||||
|
const isError = total > length;
|
||||||
|
return (
|
||||||
|
<span className={isError ? "text-red-500" : ""}>
|
||||||
|
Total required characters: {total}{" "}
|
||||||
|
{isError ? `(exceeds length of ${length})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.required.lowercase"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Lowercase Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min lowercase letters (required: ≥1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.required.uppercase"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Uppercase Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min uppercase letters (required: ≥1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.required.digits"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Digit Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min digits (required: ≥1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.required.symbols"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Symbol Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min special characters (required: ≥1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.passwordRequirements.allowedSymbols"
|
||||||
|
defaultValue="!@#$%^()_+-=[]{}:,?/~`"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Allowed Symbols"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Cannot contain: < > ; . * & | £"
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="!@#$%^()_+-=[]{}:,?/~`" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
{!isSingleEnvironmentMode && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<FilterableSelect
|
||||||
|
options={environments}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Select the environment to create secret in..."
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
getOptionValue={(option) => option.slug}
|
||||||
|
menuPlacement="top"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { DiRedis } from "react-icons/di";
|
import { DiRedis } from "react-icons/di";
|
||||||
import {
|
import {
|
||||||
SiApachecassandra,
|
SiApachecassandra,
|
||||||
|
SiCouchbase,
|
||||||
SiElasticsearch,
|
SiElasticsearch,
|
||||||
SiFiles,
|
SiFiles,
|
||||||
SiKubernetes,
|
SiKubernetes,
|
||||||
@@ -29,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
|||||||
import { AwsIamInputForm } from "./AwsIamInputForm";
|
import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||||
import { CassandraInputForm } from "./CassandraInputForm";
|
import { CassandraInputForm } from "./CassandraInputForm";
|
||||||
|
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
||||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||||
import { GcpIamInputForm } from "./GcpIamInputForm";
|
import { GcpIamInputForm } from "./GcpIamInputForm";
|
||||||
import { GithubInputForm } from "./GithubInputForm";
|
import { GithubInputForm } from "./GithubInputForm";
|
||||||
@@ -154,6 +156,11 @@ const DYNAMIC_SECRET_LIST = [
|
|||||||
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
|
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
|
||||||
provider: DynamicSecretProviders.Github,
|
provider: DynamicSecretProviders.Github,
|
||||||
title: "GitHub"
|
title: "GitHub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <SiCouchbase size="1.5rem" />,
|
||||||
|
provider: DynamicSecretProviders.Couchbase,
|
||||||
|
title: "Couchbase"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -608,6 +615,25 @@ export const CreateDynamicSecretForm = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{wizardStep === WizardSteps.ProviderInputs &&
|
||||||
|
selectedProvider === DynamicSecretProviders.Couchbase && (
|
||||||
|
<motion.div
|
||||||
|
key="dynamic-couchbase-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<CouchbaseInputForm
|
||||||
|
onCompleted={handleFormReset}
|
||||||
|
onCancel={handleFormReset}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environments={environments}
|
||||||
|
isSingleEnvironmentMode={isSingleEnvironmentMode}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -1,6 +1,12 @@
|
|||||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { faCodeCommit, faEye, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faClipboardCheck,
|
||||||
|
faCodeCommit,
|
||||||
|
faFolder,
|
||||||
|
faKey,
|
||||||
|
faSave
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
|
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
|
||||||
@@ -328,12 +334,12 @@ export const CommitForm: React.FC<CommitFormProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
leftIcon={<FontAwesomeIcon icon={faEye} />}
|
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
isDisabled={totalChangesCount === 0}
|
isDisabled={totalChangesCount === 0}
|
||||||
className="px-6"
|
className="px-6"
|
||||||
>
|
>
|
||||||
Review Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,14 +351,14 @@ export const CommitForm: React.FC<CommitFormProps> = ({
|
|||||||
<ModalContent
|
<ModalContent
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
|
<FontAwesomeIcon icon={faClipboardCheck} className="text-mineshaft-400" />
|
||||||
Commit Changes
|
Review Changes
|
||||||
<Badge variant="primary" className="mt-[0.05rem]">
|
<Badge variant="primary" className="mt-[0.05rem]">
|
||||||
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
subTitle={"Write a commit message and review the changes you're about to commit."}
|
subTitle="Write a commit message and review the changes you're about to save."
|
||||||
className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
|
className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -413,6 +419,7 @@ export const CommitForm: React.FC<CommitFormProps> = ({
|
|||||||
onChange={(e) => setCommitMessage(e.target.value)}
|
onChange={(e) => setCommitMessage(e.target.value)}
|
||||||
placeholder="Describe your changes..."
|
placeholder="Describe your changes..."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +442,7 @@ export const CommitForm: React.FC<CommitFormProps> = ({
|
|||||||
colorSchema="primary"
|
colorSchema="primary"
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
>
|
>
|
||||||
{isCommitting ? "Committing..." : "Commit Changes"}
|
{isCommitting ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -384,6 +384,24 @@ const renderOutputForm = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === DynamicSecretProviders.Couchbase) {
|
||||||
|
const { username, password } = data as {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<OutputDisplay label="Username" value={username} />
|
||||||
|
<OutputDisplay
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -0,0 +1,929 @@
|
|||||||
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
SecretInput,
|
||||||
|
Switch,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||||
|
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
import { slugSchema } from "@app/lib/schemas";
|
||||||
|
|
||||||
|
import { MetadataForm } from "../MetadataForm";
|
||||||
|
|
||||||
|
const BucketScopesConfiguration = ({
|
||||||
|
control,
|
||||||
|
bucketIndex,
|
||||||
|
bucketsValue,
|
||||||
|
setValue,
|
||||||
|
addScope,
|
||||||
|
removeScope,
|
||||||
|
addCollection,
|
||||||
|
removeCollection
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
bucketIndex: number;
|
||||||
|
bucketsValue: any;
|
||||||
|
setValue: any;
|
||||||
|
addScope: (bucketIndex: number) => void;
|
||||||
|
removeScope: (bucketIndex: number, scopeIndex: number) => void;
|
||||||
|
addCollection: (bucketIndex: number, scopeIndex: number) => void;
|
||||||
|
removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void;
|
||||||
|
}) => {
|
||||||
|
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
|
||||||
|
const scopeFields = bucket?.scopes || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-mineshaft-300">Scopes</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addScope(bucketIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Scope
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeFields.map((_scope: any, scopeIndex: number) => (
|
||||||
|
<div
|
||||||
|
key={`scope-${scopeIndex + 1}`}
|
||||||
|
className="space-y-3 rounded border border-mineshaft-600 bg-mineshaft-700 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove scope"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeScope(bucketIndex, scopeIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`inputs.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 pl-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-mineshaft-300">Collections</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addCollection(bucketIndex, scopeIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeFields[scopeIndex]?.collections?.map(
|
||||||
|
(collection: string, collectionIndex: number) => (
|
||||||
|
<div
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={collection || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections[
|
||||||
|
collectionIndex
|
||||||
|
] = e.target.value;
|
||||||
|
setValue("inputs.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., airport, airline"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove collection"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="mb-4 text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!scopeFields[scopeIndex]?.collections ||
|
||||||
|
scopeFields[scopeIndex].collections.length === 0) && (
|
||||||
|
<div className="text-xs italic text-mineshaft-400">
|
||||||
|
No collections specified (access to all collections in scope)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{scopeFields.length === 0 && (
|
||||||
|
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
|
||||||
|
<p className="mb-2 text-xs text-mineshaft-400">
|
||||||
|
No scopes configured (access to all scopes in bucket)
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addScope(bucketIndex)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Scope
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const couchbaseRoles = [
|
||||||
|
{ value: "read", label: "Read", description: "Read-only access to bucket data" },
|
||||||
|
{
|
||||||
|
value: "write",
|
||||||
|
label: "Write",
|
||||||
|
description: "Full write access to bucket data"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const passwordRequirementsSchema = 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 = ["<", ">", ";", ".", "*", "&", "|", "<22>"];
|
||||||
|
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");
|
||||||
|
|
||||||
|
const bucketSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Bucket name is required"),
|
||||||
|
scopes: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, "Scope name is required"),
|
||||||
|
collections: z.array(z.string().trim().min(1)).optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
inputs: z
|
||||||
|
.object({
|
||||||
|
url: z.string().url().min(1),
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
clusterId: z.string().min(1),
|
||||||
|
roles: z.array(z.string()).min(1),
|
||||||
|
buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]),
|
||||||
|
useAdvancedBuckets: z.boolean().default(false),
|
||||||
|
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||||
|
auth: z.object({
|
||||||
|
apiKey: z.string().min(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
newName: slugSchema().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
key: z.string().trim().min(1),
|
||||||
|
value: z.string().trim().default("")
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.optional(),
|
||||||
|
usernameTemplate: z.string().trim().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||||
|
secretPath: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDynamicSecretCouchbaseForm = ({
|
||||||
|
onClose,
|
||||||
|
dynamicSecret,
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
defaultTTL: dynamicSecret.defaultTTL,
|
||||||
|
maxTTL: dynamicSecret.maxTTL || undefined,
|
||||||
|
newName: dynamicSecret.name,
|
||||||
|
metadata: dynamicSecret.metadata,
|
||||||
|
usernameTemplate: dynamicSecret.usernameTemplate,
|
||||||
|
inputs: {
|
||||||
|
...(dynamicSecret.inputs as any),
|
||||||
|
useAdvancedBuckets: Array.isArray((dynamicSecret.inputs as any)?.buckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||||
|
|
||||||
|
const isAdvancedMode = watch("inputs.useAdvancedBuckets");
|
||||||
|
const bucketsValue = watch("inputs.buckets");
|
||||||
|
|
||||||
|
const addBucket = () => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||||
|
setValue("inputs.buckets", [...currentBuckets, { name: "", scopes: [] }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBucket = (index: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : [];
|
||||||
|
const newBuckets = currentBuckets.filter((_, i) => i !== index);
|
||||||
|
setValue("inputs.buckets", newBuckets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addScope = (bucketIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]) {
|
||||||
|
const currentScopes = currentBuckets[bucketIndex].scopes || [];
|
||||||
|
currentBuckets[bucketIndex] = {
|
||||||
|
...currentBuckets[bucketIndex],
|
||||||
|
scopes: [...currentScopes, { name: "", collections: [] }]
|
||||||
|
};
|
||||||
|
setValue("inputs.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeScope = (bucketIndex: number, scopeIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes) {
|
||||||
|
currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter(
|
||||||
|
(_, i) => i !== scopeIndex
|
||||||
|
);
|
||||||
|
setValue("inputs.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCollection = (bucketIndex: number, scopeIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) {
|
||||||
|
const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || [];
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""];
|
||||||
|
setValue("inputs.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => {
|
||||||
|
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
|
||||||
|
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
|
||||||
|
currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[
|
||||||
|
bucketIndex
|
||||||
|
].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex);
|
||||||
|
setValue("inputs.buckets", currentBuckets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateDynamicSecret = async ({
|
||||||
|
inputs,
|
||||||
|
newName,
|
||||||
|
defaultTTL,
|
||||||
|
maxTTL,
|
||||||
|
metadata,
|
||||||
|
usernameTemplate
|
||||||
|
}: TForm) => {
|
||||||
|
if (updateDynamicSecret.isPending) return;
|
||||||
|
|
||||||
|
const transformedInputs = inputs
|
||||||
|
? {
|
||||||
|
...inputs,
|
||||||
|
buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string)
|
||||||
|
}
|
||||||
|
: inputs;
|
||||||
|
|
||||||
|
const finalInputs = transformedInputs
|
||||||
|
? (() => {
|
||||||
|
const { useAdvancedBuckets, ...rest } = transformedInputs;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: transformedInputs;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDynamicSecret.mutateAsync({
|
||||||
|
name: dynamicSecret.name,
|
||||||
|
path: secretPath,
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
data: {
|
||||||
|
defaultTTL,
|
||||||
|
maxTTL: maxTTL || undefined,
|
||||||
|
newName: newName === dynamicSecret.name ? undefined : newName,
|
||||||
|
metadata,
|
||||||
|
usernameTemplate:
|
||||||
|
!usernameTemplate || usernameTemplate === "{{randomUsername}}"
|
||||||
|
? undefined
|
||||||
|
: usernameTemplate,
|
||||||
|
inputs: finalInputs
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully updated dynamic secret"
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: `Failed to update dynamic secret: ${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="newName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Secret Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="dynamic-secret" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTTL"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxTTL"
|
||||||
|
defaultValue="24h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Max TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MetadataForm control={control} />
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.url"
|
||||||
|
defaultValue="https://cloudapi.cloud.couchbase.com"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="URL"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input placeholder="https://cloudapi.cloud.couchbase.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.orgId"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Organization ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.projectId"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Project ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.clusterId"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Cluster ID"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.roles"
|
||||||
|
defaultValue={["read"]}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Roles"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Select one or more roles to assign to the user"
|
||||||
|
>
|
||||||
|
<FilterableSelect
|
||||||
|
isMulti
|
||||||
|
value={couchbaseRoles.filter((role) => value?.includes(role.value))}
|
||||||
|
onChange={(selectedRoles) => {
|
||||||
|
if (Array.isArray(selectedRoles)) {
|
||||||
|
onChange(selectedRoles.map((role: any) => role.value));
|
||||||
|
} else {
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={couchbaseRoles}
|
||||||
|
placeholder="Select roles..."
|
||||||
|
getOptionLabel={(option) => option.label}
|
||||||
|
getOptionValue={(option) => option.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.useAdvancedBuckets"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Advanced Bucket Configuration"
|
||||||
|
helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="advanced-buckets-switch"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onChange(checked);
|
||||||
|
const bucketsController = watch("inputs.buckets");
|
||||||
|
if (checked && typeof bucketsController === "string") {
|
||||||
|
setValue("inputs.buckets", []);
|
||||||
|
} else if (!checked && Array.isArray(bucketsController)) {
|
||||||
|
setValue("inputs.buckets", "*");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!watch("inputs.useAdvancedBuckets") && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.buckets"
|
||||||
|
defaultValue="*"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Bucket Access"
|
||||||
|
className="w-full"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={typeof field.value === "string" ? field.value : "*"}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdvancedMode && Array.isArray(bucketsValue) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-mineshaft-200">
|
||||||
|
Advanced Bucket Configuration
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-mineshaft-400">
|
||||||
|
Configure specific buckets with their scopes and collections. Leave scopes
|
||||||
|
empty for access to all scopes in a bucket.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={addBucket}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.isArray(bucketsValue) &&
|
||||||
|
(bucketsValue as any[]).map((_, bucketIndex) => (
|
||||||
|
<div
|
||||||
|
key={`bucket-${bucketIndex + 1}`}
|
||||||
|
className="space-y-4 rounded border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-mineshaft-200">
|
||||||
|
Bucket {bucketIndex + 1}
|
||||||
|
</h4>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove bucket"
|
||||||
|
onClick={() => removeBucket(bucketIndex)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`inputs.buckets.${bucketIndex}.name`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Bucket Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="e.g., travel-sample" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BucketScopesConfiguration
|
||||||
|
control={control}
|
||||||
|
bucketIndex={bucketIndex}
|
||||||
|
bucketsValue={bucketsValue}
|
||||||
|
setValue={setValue}
|
||||||
|
addScope={addScope}
|
||||||
|
removeScope={removeScope}
|
||||||
|
addCollection={addCollection}
|
||||||
|
removeCollection={removeCollection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && (
|
||||||
|
<div className="rounded border border-dashed border-mineshaft-600 p-8 text-center">
|
||||||
|
<p className="mb-2 text-sm text-mineshaft-400">No buckets configured</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={addBucket}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add First Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
name="inputs.auth.apiKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
className="w-full"
|
||||||
|
label="API Key"
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||||
|
value={value}
|
||||||
|
valueAlwaysHidden
|
||||||
|
rows={1}
|
||||||
|
wrap="hard"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="usernameTemplate"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Username Template"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
placeholder="{{randomUsername}}"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||||
|
<AccordionItem value="password-config">
|
||||||
|
<AccordionTrigger>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Password Configuration (optional)</span>
|
||||||
|
<Tooltip content="Couchbase password requirements: minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character. Cannot contain: < > ; . * & | <20>">
|
||||||
|
<div className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-mineshaft-600 text-xs text-mineshaft-300">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="mb-4 text-sm text-mineshaft-300">
|
||||||
|
Set constraints on the generated Couchbase user password (8-128 characters)
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-mineshaft-400">
|
||||||
|
Forbidden characters: < > ; . * & | <EFBFBD>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.length"
|
||||||
|
defaultValue={12}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Password Length"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={128}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const total = Object.values(
|
||||||
|
watch("inputs.passwordRequirements.required") || {}
|
||||||
|
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||||
|
const length = watch("inputs.passwordRequirements.length") || 0;
|
||||||
|
const isError = total > length;
|
||||||
|
return (
|
||||||
|
<span className={isError ? "text-red-500" : ""}>
|
||||||
|
Total required characters: {total}{" "}
|
||||||
|
{isError ? `(exceeds length of ${length})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.required.lowercase"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Lowercase Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min lowercase letters (required: e1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.required.uppercase"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Uppercase Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min uppercase letters (required: e1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.required.digits"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Digit Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min digits (required: e1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.required.symbols"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Symbol Count"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Min special characters (required: e1)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.passwordRequirements.allowedSymbols"
|
||||||
|
defaultValue="!@#$%^()_+-=[]{}:,?/~`"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Allowed Symbols"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Cannot contain: < > ; . * & | <20>"
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="!@#$%^()_+-=[]{}:,?/~`" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -8,6 +8,7 @@ import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecret
|
|||||||
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||||
|
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
||||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||||
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
|
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
|
||||||
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
|
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
|
||||||
@@ -384,6 +385,23 @@ export const EditDynamicSecretForm = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{dynamicSecretDetails?.type === DynamicSecretProviders.Couchbase && (
|
||||||
|
<motion.div
|
||||||
|
key="couchbase-edit"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<EditDynamicSecretCouchbaseForm
|
||||||
|
onClose={onClose}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
dynamicSecret={dynamicSecretDetails}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -16,7 +16,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
|
|||||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||||
import { ROUTE_PATHS } from "@app/const/routes";
|
import { ROUTE_PATHS } from "@app/const/routes";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||||
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||||
@@ -54,6 +54,7 @@ export const FolderListView = ({
|
|||||||
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
|
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
|
||||||
select: (el) => el.secretPath
|
select: (el) => el.secretPath
|
||||||
});
|
});
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||||
@@ -319,6 +320,11 @@ export const FolderListView = ({
|
|||||||
isOpen={popUp.deleteFolder.isOpen}
|
isOpen={popUp.deleteFolder.isOpen}
|
||||||
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
|
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
|
||||||
title="Do you want to delete this folder?"
|
title="Do you want to delete this folder?"
|
||||||
|
subTitle={`This folder and all its contents will be removed. ${
|
||||||
|
subscription?.pitRecovery
|
||||||
|
? "You can reverse this action by rolling back to a previous commit."
|
||||||
|
: "Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature."
|
||||||
|
}`}
|
||||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||||
onDeleteApproved={handleFolderDelete}
|
onDeleteApproved={handleFolderDelete}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,7 +1,14 @@
|
|||||||
import { ChangeEvent, DragEvent } from "react";
|
import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faArrowRight,
|
||||||
|
faAsterisk,
|
||||||
|
faComment,
|
||||||
|
faKey,
|
||||||
|
faPlus,
|
||||||
|
faUpload
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
||||||
import { parseDotEnv, parseJson, parseYaml } from "@app/components/utilities/parseSecrets";
|
import {
|
||||||
import { Button, Lottie, Modal, ModalContent } from "@app/components/v2";
|
parseCsvToMatrix,
|
||||||
|
parseDotEnv,
|
||||||
|
parseJson,
|
||||||
|
parseYaml
|
||||||
|
} from "@app/components/utilities/parseSecrets";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
FormLabel,
|
||||||
|
Lottie,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { usePopUp, useToggle } from "@app/hooks";
|
import { usePopUp, useToggle } from "@app/hooks";
|
||||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||||
@@ -38,6 +59,84 @@ type Props = {
|
|||||||
isProtectedBranch?: boolean;
|
isProtectedBranch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SecretMatrixMap = {
|
||||||
|
key: number;
|
||||||
|
value: number | null;
|
||||||
|
comment: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const;
|
||||||
|
|
||||||
|
const MatrixImportModalTableRow = ({
|
||||||
|
importSecretMatrixMap,
|
||||||
|
setImportSecretMatrixMap,
|
||||||
|
headers,
|
||||||
|
mapKey
|
||||||
|
}: {
|
||||||
|
importSecretMatrixMap: SecretMatrixMap;
|
||||||
|
setImportSecretMatrixMap: Dispatch<SetStateAction<SecretMatrixMap>>;
|
||||||
|
headers: string[];
|
||||||
|
mapKey: keyof SecretMatrixMap;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="w-full">
|
||||||
|
<Select
|
||||||
|
value={importSecretMatrixMap[mapKey]?.toString() || (null as unknown as string)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setImportSecretMatrixMap((ism) => ({
|
||||||
|
...ism,
|
||||||
|
[mapKey]: v ? parseInt(v, 10) : null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
position="popper"
|
||||||
|
placeholder="Select an option..."
|
||||||
|
dropdownContainerClassName="max-w-none"
|
||||||
|
>
|
||||||
|
{mapKey !== "key" && <SelectItem value={null as unknown as string}>None</SelectItem>}
|
||||||
|
{headers.map((header, col) => {
|
||||||
|
return (
|
||||||
|
<SelectItem value={col.toString()} key={`${mapKey}-${header}`}>
|
||||||
|
{header}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap pl-5 pr-5">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<FontAwesomeIcon className="text-mineshaft-400" icon={faArrowRight} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap">
|
||||||
|
<div className="flex h-full items-start justify-center">
|
||||||
|
<Badge className="pointer-events-none flex h-[36px] w-full items-center justify-center gap-1.5 whitespace-nowrap border border-mineshaft-600 bg-mineshaft-600 text-bunker-200">
|
||||||
|
{mapKey === "key" && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faKey} />
|
||||||
|
<span>Secret Key</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mapKey === "value" && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faAsterisk} />
|
||||||
|
<span>Secret Value</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mapKey === "comment" && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faComment} />
|
||||||
|
<span>Comment</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SecretDropzone = ({
|
export const SecretDropzone = ({
|
||||||
isSmaller,
|
isSmaller,
|
||||||
environments = [],
|
environments = [],
|
||||||
@@ -50,11 +149,14 @@ export const SecretDropzone = ({
|
|||||||
const [isDragActive, setDragActive] = useToggle();
|
const [isDragActive, setDragActive] = useToggle();
|
||||||
const [isLoading, setIsLoading] = useToggle();
|
const [isLoading, setIsLoading] = useToggle();
|
||||||
|
|
||||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
// Maps matrix columns to parts of a secret
|
||||||
"importSecEnv",
|
const [importSecretMatrixMap, setImportSecretMatrixMap] = useState<SecretMatrixMap>({
|
||||||
"confirmUpload",
|
key: 0,
|
||||||
"pasteSecEnv"
|
value: null,
|
||||||
] as const);
|
comment: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { openPopUp } = usePopUpAction();
|
const { openPopUp } = usePopUpAction();
|
||||||
|
|
||||||
@@ -136,10 +238,17 @@ export const SecretDropzone = ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// const fileType = file.name.split('.')[1];
|
|
||||||
setIsLoading.on();
|
setIsLoading.on();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
if (!event?.target?.result) return;
|
if (!event?.target?.result) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Invalid file contents."
|
||||||
|
});
|
||||||
|
setIsLoading.off();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let env: TParsedEnv;
|
let env: TParsedEnv;
|
||||||
|
|
||||||
@@ -154,7 +263,22 @@ export const SecretDropzone = ({
|
|||||||
case "application/yaml":
|
case "application/yaml":
|
||||||
env = parseYaml(src);
|
env = parseYaml(src);
|
||||||
break;
|
break;
|
||||||
|
case "text/csv": {
|
||||||
|
const fullMatrix = parseCsvToMatrix(src);
|
||||||
|
if (!fullMatrix.length) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to find secrets in CSV file. File might be empty."
|
||||||
|
});
|
||||||
|
setIsLoading.off();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = fullMatrix[0];
|
||||||
|
const matrix = fullMatrix.slice(1);
|
||||||
|
handlePopUpOpen("importMatrixMap", { headers, matrix });
|
||||||
|
setIsLoading.off();
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
env = parseDotEnv(src);
|
env = parseDotEnv(src);
|
||||||
break;
|
break;
|
||||||
@@ -171,6 +295,22 @@ export const SecretDropzone = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const finishMappedMatrixImport = (matrix: string[][]) => {
|
||||||
|
const env: TParsedEnv = {};
|
||||||
|
matrix.forEach((row) => {
|
||||||
|
const key = row[importSecretMatrixMap.key];
|
||||||
|
if (key) {
|
||||||
|
env[key] = {
|
||||||
|
value: importSecretMatrixMap.value ? row[importSecretMatrixMap.value] : "",
|
||||||
|
comments: importSecretMatrixMap.comment ? [row[importSecretMatrixMap.comment]] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handlePopUpClose("importMatrixMap");
|
||||||
|
setImportSecretMatrixMap({ key: 0, value: null, comment: null });
|
||||||
|
handleParsedEnv(env);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
const handleDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -293,7 +433,7 @@ export const SecretDropzone = ({
|
|||||||
disabled={!isAllowed}
|
disabled={!isAllowed}
|
||||||
type="file"
|
type="file"
|
||||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||||
accept=".txt,.env,.yml,.yaml,.json"
|
accept=".txt,.env,.yml,.yaml,.json,.csv"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -407,6 +547,75 @@ export const SecretDropzone = ({
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Matrix Import Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.importMatrixMap?.isOpen}
|
||||||
|
onOpenChange={(open) => handlePopUpToggle("importMatrixMap", open)}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Import Column Mapping"
|
||||||
|
subTitle="Map your data columns to different parts of the secret"
|
||||||
|
>
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<table className="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th>
|
||||||
|
<FormLabel tooltipClassName="max-w-sm" label="Import Column" />
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
<th className="whitespace-nowrap">
|
||||||
|
<FormLabel label="Resulting Import" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Key */}
|
||||||
|
<MatrixImportModalTableRow
|
||||||
|
importSecretMatrixMap={importSecretMatrixMap}
|
||||||
|
setImportSecretMatrixMap={setImportSecretMatrixMap}
|
||||||
|
headers={popUp?.importMatrixMap.data?.headers || []}
|
||||||
|
mapKey="key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<MatrixImportModalTableRow
|
||||||
|
importSecretMatrixMap={importSecretMatrixMap}
|
||||||
|
setImportSecretMatrixMap={setImportSecretMatrixMap}
|
||||||
|
headers={popUp?.importMatrixMap.data?.headers || []}
|
||||||
|
mapKey="value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<MatrixImportModalTableRow
|
||||||
|
importSecretMatrixMap={importSecretMatrixMap}
|
||||||
|
setImportSecretMatrixMap={setImportSecretMatrixMap}
|
||||||
|
headers={popUp?.importMatrixMap.data?.headers || []}
|
||||||
|
mapKey="comment"
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
popUp.importMatrixMap.data?.matrix
|
||||||
|
? finishMappedMatrixImport(popUp.importMatrixMap.data?.matrix)
|
||||||
|
: createNotification({
|
||||||
|
text: "Invalid secret matrix.",
|
||||||
|
type: "error"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isFullWidth
|
||||||
|
variant="outline_bg"
|
||||||
|
>
|
||||||
|
Import Secrets
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -28,7 +28,7 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
|||||||
<h3 className="font-semibold text-mineshaft-100">Sync Logs</h3>
|
<h3 className="font-semibold text-mineshaft-100">Sync Logs</h3>
|
||||||
{subscription.auditLogs && (
|
{subscription.auditLogs && (
|
||||||
<p className="text-xs text-bunker-300">
|
<p className="text-xs text-bunker-300">
|
||||||
Displaying audit logs from the last {auditLogsRetentionDays} days
|
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,9 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
|||||||
showFilters={false}
|
showFilters={false}
|
||||||
presets={{
|
presets={{
|
||||||
eventMetadata: { syncId: secretSync.id },
|
eventMetadata: { syncId: secretSync.id },
|
||||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
startDate: new Date(
|
||||||
|
new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60))
|
||||||
|
),
|
||||||
eventType: INTEGRATION_EVENTS
|
eventType: INTEGRATION_EVENTS
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -12,9 +12,15 @@ import {
|
|||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
|
Tooltip,
|
||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useSubscription,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
import { useUpdateWsEnvironment } from "@app/hooks/api";
|
import { useUpdateWsEnvironment } from "@app/hooks/api";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ type Props = {
|
|||||||
|
|
||||||
export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const updateEnvironment = useUpdateWsEnvironment();
|
const updateEnvironment = useUpdateWsEnvironment();
|
||||||
|
|
||||||
@@ -61,6 +68,16 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMoreEnvironmentsAllowed =
|
||||||
|
subscription?.environmentLimit && currentWorkspace?.environments
|
||||||
|
? currentWorkspace.environments.length <= subscription.environmentLimit
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const environmentsOverPlanLimit =
|
||||||
|
subscription?.environmentLimit && currentWorkspace?.environments
|
||||||
|
? Math.max(0, currentWorkspace.environments.length - subscription.environmentLimit)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -122,18 +139,26 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
a={ProjectPermissionSub.Environments}
|
a={ProjectPermissionSub.Environments}
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<IconButton
|
<Tooltip
|
||||||
className="mr-3 py-2"
|
content={
|
||||||
onClick={() => {
|
isMoreEnvironmentsAllowed
|
||||||
handlePopUpOpen("updateEnv", { name, slug, id });
|
? ""
|
||||||
}}
|
: `You have exceeded the number of environments allowed by your plan. To edit an existing environment, either upgrade your plan or remove at least ${environmentsOverPlanLimit} environment${environmentsOverPlanLimit === 1 ? "" : "s"}.`
|
||||||
isDisabled={!isAllowed}
|
}
|
||||||
colorSchema="primary"
|
|
||||||
variant="plain"
|
|
||||||
ariaLabel="update"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPencil} />
|
<IconButton
|
||||||
</IconButton>
|
className="mr-3 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("updateEnv", { name, slug, id });
|
||||||
|
}}
|
||||||
|
isDisabled={!isAllowed || !isMoreEnvironmentsAllowed}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPencil} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
|
Reference in New Issue
Block a user