mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-19 21:17:10 +00:00
Compare commits
48 Commits
ENG-3506
...
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 | ||
|
b8fa4d5255 | ||
|
0d3cb2d41a | ||
|
18881749fd | ||
|
55607a4886 | ||
|
385c75c543 | ||
|
352ef050c3 | ||
|
b6b9fb6ef5 |
@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
|
||||
t.index("expiresAt");
|
||||
t.index("orgId");
|
||||
t.index("projectId");
|
||||
t.index("eventType");
|
||||
t.index("userAgentType");
|
||||
t.index("actor");
|
||||
});
|
||||
|
||||
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);
|
||||
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
|
||||
|
||||
// create partitions 4 years ahead
|
||||
const partitionMonths = 4 * 12;
|
||||
// create partitions 20 years ahead
|
||||
const partitionMonths = 20 * 12;
|
||||
const partitionPromises: Promise<void>[] = [];
|
||||
for (let x = 1; x <= partitionMonths; x += 1) {
|
||||
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(),
|
||||
note: z.string().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>;
|
||||
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
|
||||
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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
body: z.object({
|
||||
permissions: z.any().array(),
|
||||
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()
|
||||
}),
|
||||
querystring: z.object({
|
||||
@@ -190,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
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">;
|
||||
accessApprovalRequestReviewerDAL: Pick<
|
||||
TAccessApprovalRequestReviewerDALFactory,
|
||||
"create" | "find" | "findOne" | "transaction"
|
||||
"create" | "find" | "findOne" | "transaction" | "delete"
|
||||
>;
|
||||
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
||||
@@ -301,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
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 ({
|
||||
projectSlug,
|
||||
authorUserId,
|
||||
@@ -650,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
return {
|
||||
createAccessApprovalRequest,
|
||||
updateAccessApprovalRequest,
|
||||
listApprovalRequests,
|
||||
reviewAccessRequest,
|
||||
getCount
|
||||
|
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
|
||||
note?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateAccessApprovalRequestDTO = {
|
||||
requestId: string;
|
||||
temporaryRange: string;
|
||||
editNote: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TListApprovalRequestsDTO = {
|
||||
projectSlug: string;
|
||||
authorUserId?: string;
|
||||
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
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<{
|
||||
requests: {
|
||||
policy: {
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||
|
||||
import { ProjectType, 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 { SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { logger } from "@app/lib/logger";
|
||||
@@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
eventBusService: TEventBusService;
|
||||
};
|
||||
|
||||
export type TAuditLogQueueServiceFactory = {
|
||||
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
|
||||
queueService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
auditLogStreamDAL,
|
||||
eventBusService
|
||||
auditLogStreamDAL
|
||||
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
|
||||
const pushToLog = async (data: TCreateAuditLogDTO) => {
|
||||
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 {
|
||||
|
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 { AzureEntraIDProvider } from "./azure-entra-id";
|
||||
import { CassandraProvider } from "./cassandra";
|
||||
import { CouchbaseProvider } from "./couchbase";
|
||||
import { ElasticSearchProvider } from "./elastic-search";
|
||||
import { GcpIamProvider } from "./gcp-iam";
|
||||
import { GithubProvider } from "./github";
|
||||
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
|
||||
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
|
||||
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
|
||||
[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.")
|
||||
});
|
||||
|
||||
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 {
|
||||
SqlDatabase = "sql-database",
|
||||
Cassandra = "cassandra",
|
||||
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
|
||||
Kubernetes = "kubernetes",
|
||||
Vertica = "vertica",
|
||||
GcpIam = "gcp-iam",
|
||||
Github = "github"
|
||||
Github = "github",
|
||||
Couchbase = "couchbase"
|
||||
}
|
||||
|
||||
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.Vertica), inputs: DynamicSecretVerticaSchema }),
|
||||
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 = {
|
||||
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { EventSchema, TopicName } from "./types";
|
||||
import { BusEventSchema, TopicName } from "./types";
|
||||
|
||||
export const eventBusFactory = (redis: Redis) => {
|
||||
const publisher = redis.duplicate();
|
||||
@@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => {
|
||||
* @param topic - The topic to publish the event to.
|
||||
* @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);
|
||||
|
||||
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.
|
||||
* @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.
|
||||
const listener = (channel: string, message: string) => {
|
||||
try {
|
||||
|
@@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TEventBusService } from "./event-bus-service";
|
||||
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 HEART_BEAT_INTERVAL = 15 * 1000;
|
||||
@@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => {
|
||||
}
|
||||
};
|
||||
|
||||
function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) {
|
||||
const eventType = toBusEventName(event.data.eventType);
|
||||
function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) {
|
||||
const eventType = event.data.event;
|
||||
const match = registered.find((r) => r.event === eventType);
|
||||
if (!match) return;
|
||||
|
||||
|
@@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore";
|
||||
import { conditionsMatcher } from "@app/lib/casl";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { EventData, RegisteredEvent } from "./types";
|
||||
import { BusEvent, RegisteredEvent } from "./types";
|
||||
|
||||
export const getServerSentEventsHeaders = () =>
|
||||
({
|
||||
@@ -55,7 +55,7 @@ export type EventStreamClient = {
|
||||
id: string;
|
||||
stream: Readable;
|
||||
open: () => Promise<void>;
|
||||
send: (data: EventMessage | EventData) => void;
|
||||
send: (data: EventMessage | BusEvent) => void;
|
||||
ping: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
close: () => void;
|
||||
@@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
|
||||
return {
|
||||
subject: options.type,
|
||||
action: "subscribe",
|
||||
conditions: {
|
||||
eventType: r.event,
|
||||
...(hasConditions
|
||||
? {
|
||||
environment: r.conditions?.environmentSlug ?? "",
|
||||
secretPath: { $glob: secretPath }
|
||||
}
|
||||
: {})
|
||||
}
|
||||
conditions: hasConditions
|
||||
? {
|
||||
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
|
||||
stream._read = () => {};
|
||||
|
||||
const send = (data: EventMessage | EventData) => {
|
||||
const send = (data: EventMessage | BusEvent) => {
|
||||
const chunk = serializeSseEvent(data);
|
||||
if (!stream.push(chunk)) {
|
||||
logger.debug("Backpressure detected: dropped manual event");
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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 {
|
||||
CoreServers = "infisical::core-servers"
|
||||
@@ -10,84 +11,44 @@ export enum TopicName {
|
||||
export enum BusEventName {
|
||||
CreateSecret = "secret:create",
|
||||
UpdateSecret = "secret:update",
|
||||
DeleteSecret = "secret:delete"
|
||||
DeleteSecret = "secret:delete",
|
||||
ImportMutation = "secret:import-mutation"
|
||||
}
|
||||
|
||||
type PublisableEventTypes =
|
||||
| EventType.CREATE_SECRET
|
||||
| EventType.CREATE_SECRETS
|
||||
| EventType.DELETE_SECRET
|
||||
| EventType.DELETE_SECRETS
|
||||
| EventType.UPDATE_SECRETS
|
||||
| EventType.UPDATE_SECRET;
|
||||
|
||||
export function toBusEventName(input: EventType) {
|
||||
switch (input) {
|
||||
case EventType.CREATE_SECRET:
|
||||
case EventType.CREATE_SECRETS:
|
||||
return BusEventName.CreateSecret;
|
||||
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
|
||||
}
|
||||
export const Mappings = {
|
||||
BusEventToAction(input: BusEventName) {
|
||||
switch (input) {
|
||||
case BusEventName.CreateSecret:
|
||||
return ProjectPermissionSecretEventActions.SubscribeCreated;
|
||||
case BusEventName.DeleteSecret:
|
||||
return ProjectPermissionSecretEventActions.SubscribeDeleted;
|
||||
case BusEventName.ImportMutation:
|
||||
return ProjectPermissionSecretEventActions.SubscribeImportMutations;
|
||||
case BusEventName.UpdateSecret:
|
||||
return ProjectPermissionSecretEventActions.SubscribeUpdated;
|
||||
default:
|
||||
throw new Error("Unknown bus event name");
|
||||
}
|
||||
} as const;
|
||||
}
|
||||
};
|
||||
|
||||
export const EventName = z.nativeEnum(BusEventName);
|
||||
|
||||
const EventSecretPayload = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
secretId: z.string(),
|
||||
secretPath: z.string().optional(),
|
||||
secretKey: z.string(),
|
||||
environment: z.string()
|
||||
});
|
||||
|
||||
const EventImportMutationPayload = z.object({
|
||||
secretPath: z.string(),
|
||||
environment: z.string()
|
||||
});
|
||||
|
||||
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"),
|
||||
type: z.nativeEnum(ProjectType),
|
||||
source: z.string(),
|
||||
@@ -95,25 +56,38 @@ export const EventSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.default(() => new Date().toISOString()),
|
||||
data: z.discriminatedUnion("eventType", [
|
||||
data: z.discriminatedUnion("event", [
|
||||
z.object({
|
||||
specversion: z.number().optional().default(1),
|
||||
eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]),
|
||||
payload: EventSecretPayload
|
||||
event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]),
|
||||
payload: z.union([EventSecretPayload, EventSecretPayload.array()])
|
||||
}),
|
||||
z.object({
|
||||
specversion: z.number().optional().default(1),
|
||||
eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]),
|
||||
payload: EventSecretPayload.array()
|
||||
event: z.enum([BusEventName.ImportMutation]),
|
||||
payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()])
|
||||
})
|
||||
// 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({
|
||||
event: EventName,
|
||||
event: z.nativeEnum(BusEventName),
|
||||
conditions: z
|
||||
.object({
|
||||
secretPath: z.string().optional().default("/"),
|
||||
|
@@ -161,8 +161,7 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
ProjectPermissionSecretActions.Subscribe
|
||||
ProjectPermissionSecretActions.Delete
|
||||
],
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
@@ -266,8 +265,7 @@ const buildMemberPermissionRules = () => {
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
ProjectPermissionSecretActions.Subscribe
|
||||
ProjectPermissionSecretActions.Delete
|
||||
],
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
|
@@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions {
|
||||
ReadValue = "readValue",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
Subscribe = "subscribe"
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCmekActions {
|
||||
@@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
|
||||
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 {
|
||||
Role = "role",
|
||||
Member = "member",
|
||||
@@ -197,7 +203,8 @@ export enum ProjectPermissionSub {
|
||||
Kmip = "kmip",
|
||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs"
|
||||
SecretScanningConfigs = "secret-scanning-configs",
|
||||
SecretEvents = "secret-events"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -205,7 +212,13 @@ export type SecretSubjectFields = {
|
||||
secretPath: string;
|
||||
secretName?: string;
|
||||
secretTags?: string[];
|
||||
eventType?: string;
|
||||
};
|
||||
|
||||
export type SecretEventSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretName?: string;
|
||||
secretTags?: string[];
|
||||
};
|
||||
|
||||
export type SecretFolderSubjectFields = {
|
||||
@@ -344,7 +357,11 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
|
||||
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
|
||||
| [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_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."
|
||||
).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
|
||||
]);
|
||||
|
||||
|
@@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
if (!folder) {
|
||||
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
|
||||
}
|
||||
|
||||
const { secrets } = mergeStatus;
|
||||
|
||||
await secretQueueService.syncSecrets({
|
||||
projectId,
|
||||
orgId: actorOrgId,
|
||||
secretPath: folder.path,
|
||||
environmentSlug: folder.environmentSlug,
|
||||
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) {
|
||||
|
@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
||||
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
||||
|
||||
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(", ") || [];
|
||||
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
||||
await sendSlackNotification({
|
||||
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
||||
}
|
||||
|
||||
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) {
|
||||
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
||||
microsoftTeamsConfig.accessRequestChannels
|
||||
|
@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
|
||||
|
||||
export enum TriggerFeature {
|
||||
SECRET_APPROVAL = "secret-approval",
|
||||
ACCESS_REQUEST = "access-request"
|
||||
ACCESS_REQUEST = "access-request",
|
||||
ACCESS_REQUEST_UPDATED = "access-request-updated"
|
||||
}
|
||||
|
||||
export type TNotification =
|
||||
@@ -34,6 +35,22 @@ export type TNotification =
|
||||
approvalUrl: 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 = {
|
||||
|
@@ -560,8 +560,7 @@ export const registerRoutes = async (
|
||||
queueService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
auditLogStreamDAL,
|
||||
eventBusService
|
||||
auditLogStreamDAL
|
||||
});
|
||||
|
||||
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
|
||||
@@ -1121,7 +1120,9 @@ export const registerRoutes = async (
|
||||
resourceMetadataDAL,
|
||||
folderCommitService,
|
||||
secretSyncQueue,
|
||||
reminderService
|
||||
reminderService,
|
||||
eventBusService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const projectService = projectServiceFactory({
|
||||
|
@@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
email: z.string().email().trim(),
|
||||
password: z.string().trim(),
|
||||
firstName: z.string().trim(),
|
||||
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()
|
||||
lastName: z.string().trim().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
|
||||
import { ActionProjectType, ProjectType } from "@app/db/schemas";
|
||||
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
|
||||
import { EventRegisterSchema } from "@app/ee/services/event/types";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types";
|
||||
import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
|
||||
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
@@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => {
|
||||
req.body.register.forEach((r) => {
|
||||
const fields = {
|
||||
environment: r.conditions?.environmentSlug ?? "",
|
||||
secretPath: r.conditions?.secretPath ?? "/",
|
||||
eventType: r.event
|
||||
secretPath: r.conditions?.secretPath ?? "/"
|
||||
};
|
||||
|
||||
const allowed = info.permission.can(
|
||||
ProjectPermissionSecretActions.Subscribe,
|
||||
subject(ProjectPermissionSub.Secrets, fields)
|
||||
);
|
||||
const action = Mappings.BusEventToAction(r.event);
|
||||
|
||||
const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields));
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionDenied",
|
||||
message: `You are not allowed to subscribe on secrets`,
|
||||
message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`,
|
||||
details: {
|
||||
event: fields.eventType,
|
||||
action,
|
||||
environmentSlug: fields.environment,
|
||||
secretPath: fields.secretPath
|
||||
}
|
||||
|
@@ -478,4 +478,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
||||
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 } };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
|
||||
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 {
|
||||
if (!linkHeader) return 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;
|
||||
const links = parseGitHubLinkHeader(linkHeader);
|
||||
return links.next || null;
|
||||
}
|
||||
|
||||
export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
||||
@@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
||||
|
||||
const token =
|
||||
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 i = 0;
|
||||
const maxIterations = 1000;
|
||||
|
||||
while (url && i < 1000) {
|
||||
// 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"
|
||||
}
|
||||
});
|
||||
// Make initial request to get link header
|
||||
const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||
url: initialUrlObj.toString(),
|
||||
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);
|
||||
const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
|
||||
results = results.concat(firstPageItems);
|
||||
|
||||
url = extractNextPageUrl(response.headers.link as string | undefined);
|
||||
i += 1;
|
||||
const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
|
||||
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;
|
||||
|
@@ -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: {
|
||||
throw new BadRequestError({
|
||||
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, {
|
||||
...orgOrm,
|
||||
findOrgByProjectId,
|
||||
@@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
updateMembershipById,
|
||||
deleteMembershipById,
|
||||
deleteMembershipsById,
|
||||
updateMembership
|
||||
updateMembership,
|
||||
findIdentityOrganization
|
||||
});
|
||||
};
|
||||
|
@@ -198,6 +198,15 @@ export const orgServiceFactory = ({
|
||||
// Filter out orgs where the membership object is an invitation
|
||||
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
|
||||
* */
|
||||
@@ -1403,6 +1412,7 @@ export const orgServiceFactory = ({
|
||||
findOrganizationById,
|
||||
findAllOrgMembers,
|
||||
findAllOrganizationOfUser,
|
||||
findIdentityOrganization,
|
||||
inviteUserToOrganization,
|
||||
verifyUserToOrg,
|
||||
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) => {
|
||||
if (position) {
|
||||
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);
|
||||
|
@@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({
|
||||
projectId,
|
||||
environmentSlug: environment,
|
||||
actorId,
|
||||
actor
|
||||
actor,
|
||||
event: {
|
||||
importMutation: {
|
||||
secretPath,
|
||||
environment
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({
|
||||
projectId,
|
||||
environmentSlug: environment,
|
||||
actor,
|
||||
actorId
|
||||
actorId,
|
||||
event: {
|
||||
importMutation: {
|
||||
secretPath,
|
||||
environment
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import AWS, { AWSError } from "aws-sdk";
|
||||
import handlebars from "handlebars";
|
||||
|
||||
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
@@ -34,18 +35,51 @@ const sleep = async () =>
|
||||
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 = {};
|
||||
let hasNext = true;
|
||||
let nextToken: string | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
const fullPath = getFullPath({ path, keySchema, environment });
|
||||
|
||||
while (hasNext) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const parameters = await ssm
|
||||
.getParametersByPath({
|
||||
Path: path,
|
||||
Path: fullPath,
|
||||
Recursive: false,
|
||||
WithDecryption: true,
|
||||
MaxResults: BATCH_SIZE,
|
||||
@@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
||||
parameters.Parameters.forEach((parameter) => {
|
||||
if (parameter.Name) {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
||||
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 = {};
|
||||
let hasNext = true;
|
||||
let nextToken: string | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
const fullPath = getFullPath({ path, keySchema, environment });
|
||||
|
||||
while (hasNext) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "OneLevel",
|
||||
Values: [path]
|
||||
Values: [fullPath]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -112,7 +153,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
||||
parameters.Parameters.forEach((parameter) => {
|
||||
if (parameter.Name) {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = {
|
||||
|
||||
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(
|
||||
ssm,
|
||||
@@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = {
|
||||
await deleteParametersBatch(ssm, parametersToDelete);
|
||||
},
|
||||
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
|
||||
const { destinationConfig } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = 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(
|
||||
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
|
||||
);
|
||||
},
|
||||
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const { destinationConfig } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = 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[] = [];
|
||||
|
||||
|
@@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorId,
|
||||
actor,
|
||||
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,
|
||||
projectId,
|
||||
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,
|
||||
projectId,
|
||||
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,
|
||||
projectId,
|
||||
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) => {
|
||||
@@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretPath: el.path,
|
||||
projectId,
|
||||
orgId: actorOrgId,
|
||||
environmentSlug: environment
|
||||
environmentSlug: environment,
|
||||
event: {
|
||||
updated: updatedSecrets.map((sec) => ({
|
||||
secretId: sec.id,
|
||||
secretKey: sec.key,
|
||||
secretPath: sec.secretPath,
|
||||
environment
|
||||
}))
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
)
|
||||
@@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretPath,
|
||||
projectId,
|
||||
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({
|
||||
@@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretPath: destinationFolder.path,
|
||||
environmentSlug: destinationFolder.environment.slug,
|
||||
actorId,
|
||||
actor
|
||||
actor,
|
||||
event: {
|
||||
importMutation: {
|
||||
secretPath: sourceFolder.path,
|
||||
environment: sourceFolder.environment.slug
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretPath: sourceFolder.path,
|
||||
environmentSlug: sourceFolder.environment.slug,
|
||||
actorId,
|
||||
actor
|
||||
actor,
|
||||
event: {
|
||||
importMutation: {
|
||||
secretPath: sourceFolder.path,
|
||||
environment: sourceFolder.environment.slug
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -5,6 +5,7 @@ import { Knex } from "knex";
|
||||
|
||||
import {
|
||||
ProjectMembershipRole,
|
||||
ProjectType,
|
||||
ProjectUpgradeStatus,
|
||||
ProjectVersion,
|
||||
SecretType,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
TSecretVersionsV2
|
||||
} from "@app/db/schemas";
|
||||
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 { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
|
||||
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
||||
@@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = {
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
||||
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
|
||||
eventBusService: TEventBusService;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TGetSecrets = {
|
||||
@@ -172,7 +178,9 @@ export const secretQueueFactory = ({
|
||||
resourceMetadataDAL,
|
||||
secretSyncQueue,
|
||||
folderCommitService,
|
||||
reminderService
|
||||
reminderService,
|
||||
eventBusService,
|
||||
licenseService
|
||||
}: TSecretQueueFactoryDep) => {
|
||||
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
||||
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>({
|
||||
// seperate de-dupe queue for integration sync and replication sync
|
||||
_deDupeQueue: deDupeQueue = {},
|
||||
_depth: depth = 0,
|
||||
_deDupeReplicationQueue: deDupeReplicationQueue = {},
|
||||
event,
|
||||
...dto
|
||||
}: TSyncSecretsDTO<T>) => {
|
||||
}: TSyncSecretsDTO<T> & { event?: PublishableEvent }) => {
|
||||
logger.info(
|
||||
`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);
|
||||
if (
|
||||
!dto.excludeReplication
|
||||
@@ -565,7 +626,7 @@ export const secretQueueFactory = ({
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_deDupeReplicationQueue: deDupeReplicationQueue,
|
||||
_depth: depth
|
||||
} as TSyncSecretsDTO,
|
||||
} as unknown as TSyncSecretsDTO,
|
||||
{
|
||||
removeOnFail: true,
|
||||
removeOnComplete: true,
|
||||
@@ -689,6 +750,7 @@ export const secretQueueFactory = ({
|
||||
isManual,
|
||||
projectId,
|
||||
secretPath,
|
||||
|
||||
depth = 1,
|
||||
deDupeQueue = {}
|
||||
} = job.data as TIntegrationSyncPayload;
|
||||
@@ -738,7 +800,13 @@ export const secretQueueFactory = ({
|
||||
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_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,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_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
|
||||
};
|
||||
}
|
||||
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: {
|
||||
throw new BadRequestError({
|
||||
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 "./AccessApprovalRequestUpdatedTemplate";
|
||||
export * from "./EmailMfaTemplate";
|
||||
export * from "./EmailVerificationTemplate";
|
||||
export * from "./ExternalImportFailedTemplate";
|
||||
|
@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
|
||||
|
||||
import {
|
||||
AccessApprovalRequestTemplate,
|
||||
AccessApprovalRequestUpdatedTemplate,
|
||||
EmailMfaTemplate,
|
||||
EmailVerificationTemplate,
|
||||
ExternalImportFailedTemplate,
|
||||
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
|
||||
EmailMfa = "emailMfa",
|
||||
UnlockAccount = "unlockAccount",
|
||||
AccessApprovalRequest = "accessApprovalRequest",
|
||||
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
|
||||
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
|
||||
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
|
||||
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
||||
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
||||
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
||||
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
||||
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
|
||||
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
|
||||
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
|
||||
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
|
||||
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
|
||||
|
@@ -11,7 +11,6 @@ import {
|
||||
validateOverrides
|
||||
} from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||
@@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({
|
||||
return updatedServerCfg;
|
||||
};
|
||||
|
||||
const adminSignUp = async ({
|
||||
lastName,
|
||||
firstName,
|
||||
email,
|
||||
salt,
|
||||
password,
|
||||
verifier,
|
||||
publicKey,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
ip,
|
||||
userAgent
|
||||
}: TAdminSignUpDTO) => {
|
||||
const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
|
||||
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 { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
|
||||
const userInfo = await userDAL.transaction(async (tx) => {
|
||||
const newUser = await userDAL.create(
|
||||
{
|
||||
@@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({
|
||||
);
|
||||
const userEnc = await userDAL.createUserEncryption(
|
||||
{
|
||||
salt,
|
||||
encryptionVersion: 2,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
publicKey,
|
||||
encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag,
|
||||
verifier,
|
||||
userId: newUser.id,
|
||||
hashedPassword,
|
||||
serverEncryptedPrivateKey: ciphertext,
|
||||
serverEncryptedPrivateKeyIV: iv,
|
||||
serverEncryptedPrivateKeyTag: tag,
|
||||
serverEncryptedPrivateKeyEncoding: encoding
|
||||
hashedPassword
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return { user: newUser, enc: userEnc };
|
||||
});
|
||||
|
||||
@@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({
|
||||
},
|
||||
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(
|
||||
{
|
||||
userId: newUser.id,
|
||||
encryptionVersion: 2,
|
||||
protectedKey: encKeys.protectedKey,
|
||||
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
|
||||
hashedPassword
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
@@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env";
|
||||
export type TAdminSignUpDTO = {
|
||||
email: string;
|
||||
password: string;
|
||||
publicKey: string;
|
||||
salt: string;
|
||||
lastName?: string;
|
||||
verifier: string;
|
||||
firstName: string;
|
||||
protectedKey: string;
|
||||
protectedKeyIV: string;
|
||||
protectedKeyTag: string;
|
||||
encryptedPrivateKey: string;
|
||||
encryptedPrivateKeyIV: string;
|
||||
encryptedPrivateKeyTag: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
@@ -416,6 +416,9 @@
|
||||
"pages": [
|
||||
"documentation/platform/secrets-mgmt/project",
|
||||
"documentation/platform/folder",
|
||||
"documentation/platform/secret-versioning",
|
||||
"documentation/platform/pit-recovery",
|
||||
"documentation/platform/secret-reference",
|
||||
{
|
||||
"group": "Secret Rotation",
|
||||
"pages": [
|
||||
@@ -439,6 +442,7 @@
|
||||
"documentation/platform/dynamic-secrets/aws-iam",
|
||||
"documentation/platform/dynamic-secrets/azure-entra-id",
|
||||
"documentation/platform/dynamic-secrets/cassandra",
|
||||
"documentation/platform/dynamic-secrets/couchbase",
|
||||
"documentation/platform/dynamic-secrets/elastic-search",
|
||||
"documentation/platform/dynamic-secrets/gcp-iam",
|
||||
"documentation/platform/dynamic-secrets/github",
|
||||
@@ -458,7 +462,8 @@
|
||||
"documentation/platform/dynamic-secrets/kubernetes",
|
||||
"documentation/platform/dynamic-secrets/vertica"
|
||||
]
|
||||
}
|
||||
},
|
||||
"documentation/platform/webhooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
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"
|
||||
description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them."
|
||||
title: "Fetching Secrets"
|
||||
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.
|
||||
|
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
|
||||
|
||||
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**
|
||||
```bash
|
||||
@@ -229,9 +229,9 @@ The managed secret created by the operator will not be deleted when the operator
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Helm">
|
||||
Install Infisical Helm repository
|
||||
Uninstall Infisical Helm repository
|
||||
```bash
|
||||
helm uninstall <release name>
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
|
@@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their
|
||||
Supports conditions and permission inversion
|
||||
| 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. |
|
||||
| `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 | |
|
||||
| `edit` | Modify existing secret values | |
|
||||
| `delete` | Remove secrets from the project | |
|
||||
| `create` | Add new secrets to the project | |
|
||||
| `edit` | Modify existing secret values | |
|
||||
| `delete` | Remove secrets from the project | |
|
||||
|
||||
#### Subject: `secret-folders`
|
||||
|
||||
@@ -169,6 +169,15 @@ Supports conditions and permission inversion
|
||||
| `edit` | Modify 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`
|
||||
|
||||
| Action | Description |
|
||||
@@ -178,10 +187,10 @@ Supports conditions and permission inversion
|
||||
|
||||
#### Subject: `commits`
|
||||
|
||||
| Action | Description |
|
||||
| -------- | ---------------------------------- |
|
||||
| `read` | View commits and changes across folders |
|
||||
| `perform-rollback` | Roll back commits changes and restore folders to previous state|
|
||||
| Action | Description |
|
||||
| ------------------ | --------------------------------------------------------------- |
|
||||
| `read` | View commits and changes across folders |
|
||||
| `perform-rollback` | Roll back commits changes and restore folders to previous state |
|
||||
|
||||
#### Subject: `secret-approval`
|
||||
|
||||
@@ -197,14 +206,14 @@ Supports conditions and permission inversion
|
||||
#### Subject: `secret-rotation`
|
||||
|
||||
Supports conditions and permission inversion
|
||||
| Action | Description |
|
||||
| Action | Description |
|
||||
| ------------------------------ | ---------------------------------------------- |
|
||||
| `read` | View secret rotation configurations |
|
||||
| `read-generated-credentials` | View the generated credentials of a rotation |
|
||||
| `create` | Set up secret rotation configurations |
|
||||
| `edit` | Modify secret rotation configurations |
|
||||
| `rotate-secrets` | Rotate the generated credentials of a rotation |
|
||||
| `delete` | Remove secret rotation configurations |
|
||||
| `read` | View secret rotation configurations |
|
||||
| `read-generated-credentials` | View the generated credentials of a rotation |
|
||||
| `create` | Set up secret rotation configurations |
|
||||
| `edit` | Modify secret rotation configurations |
|
||||
| `rotate-secrets` | Rotate the generated credentials of a rotation |
|
||||
| `delete` | Remove secret rotation configurations |
|
||||
|
||||
#### Subject: `secret-syncs`
|
||||
|
||||
@@ -263,12 +272,12 @@ Supports conditions and permission inversion
|
||||
|
||||
#### Subject: `certificates`
|
||||
|
||||
| Action | Description |
|
||||
| -------------------- | ----------------------------- |
|
||||
| `read` | View certificates |
|
||||
| `read-private-key` | Read certificate private key |
|
||||
| `create` | Issue new certificates |
|
||||
| `delete` | Revoke or remove certificates |
|
||||
| Action | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| `read` | View certificates |
|
||||
| `read-private-key` | Read certificate private key |
|
||||
| `create` | Issue new certificates |
|
||||
| `delete` | Revoke or remove certificates |
|
||||
|
||||
#### Subject: `certificate-templates`
|
||||
|
||||
@@ -330,8 +339,8 @@ Supports conditions and permission inversion
|
||||
|
||||
#### Subject: `secret-scanning-data-sources`
|
||||
|
||||
| Action | Description |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Action | Description |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
| `read-data-sources` | View Data Sources |
|
||||
| `create-data-sources` | Create new Data Sources |
|
||||
| `edit-data-sources` | Modify Data Sources |
|
||||
@@ -342,15 +351,14 @@ Supports conditions and permission inversion
|
||||
|
||||
#### Subject: `secret-scanning-findings`
|
||||
|
||||
| Action | Description |
|
||||
| -------- | --------------------------------- |
|
||||
| `read-findings` | View Secret Scanning Findings |
|
||||
| `update-findings` | Update Secret Scanning Findings |
|
||||
|
||||
| Action | Description |
|
||||
| ----------------- | ------------------------------- |
|
||||
| `read-findings` | View Secret Scanning Findings |
|
||||
| `update-findings` | Update Secret Scanning Findings |
|
||||
|
||||
#### Subject: `secret-scanning-configs`
|
||||
|
||||
| Action | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| `read-configs` | View Secret Scanning Project Configuration |
|
||||
| `update-configs` | Update Secret Scanning Project Configuration |
|
||||
| Action | Description |
|
||||
| ---------------- | -------------------------------------------- |
|
||||
| `read-configs` | View Secret Scanning Project Configuration |
|
||||
| `update-configs` | Update Secret Scanning Project Configuration |
|
||||
|
@@ -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 { FormLabel, Tooltip } from "../v2";
|
||||
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
||||
label={label}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="max-w-lg"
|
||||
content={
|
||||
<span>
|
||||
Examples: 30m, 1h, 3d, etc.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
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>
|
||||
</span>
|
||||
}
|
||||
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
className="relative bottom-px right-1"
|
||||
className="relative right-1 mt-0.5 text-mineshaft-300"
|
||||
/>
|
||||
</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";
|
@@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
|
||||
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 {
|
||||
$IN = "$in",
|
||||
$ALL = "$all",
|
||||
@@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject =
|
||||
| ProjectPermissionSub.CertificateTemplates
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.SecretRotation;
|
||||
| ProjectPermissionSub.SecretRotation
|
||||
| ProjectPermissionSub.SecretEvents;
|
||||
|
||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||
[PermissionConditionOperators.$EQ]: "equal to",
|
||||
@@ -250,7 +258,8 @@ export enum ProjectPermissionSub {
|
||||
Commits = "commits",
|
||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs"
|
||||
SecretScanningConfigs = "secret-scanning-configs",
|
||||
SecretEvents = "secret-events"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -260,6 +269,14 @@ export type SecretSubjectFields = {
|
||||
secretTags: string[];
|
||||
};
|
||||
|
||||
export type SecretEventSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretName: string;
|
||||
secretTags: string[];
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type SecretFolderSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
@@ -403,6 +420,13 @@ export type ProjectPermissionSet =
|
||||
ProjectPermissionSub.SecretScanningDataSources
|
||||
]
|
||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
|
||||
| [
|
||||
ProjectPermissionSecretEventActions,
|
||||
(
|
||||
| ProjectPermissionSub.SecretEvents
|
||||
| (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
|
||||
)
|
||||
];
|
||||
|
||||
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 {
|
||||
TAccessApproval,
|
||||
TAccessApprovalRequest,
|
||||
TCreateAccessPolicyDTO,
|
||||
TCreateAccessRequestDTO,
|
||||
TDeleteSecretPolicyDTO,
|
||||
TUpdateAccessPolicyDTO
|
||||
TUpdateAccessPolicyDTO,
|
||||
TUpdateAccessRequestDTO
|
||||
} from "./types";
|
||||
|
||||
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 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
|
@@ -24,7 +24,7 @@ export const accessApprovalKeys = {
|
||||
envSlug?: string,
|
||||
requestedBy?: 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) =>
|
||||
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
||||
};
|
||||
|
@@ -103,6 +103,8 @@ export type TAccessApprovalRequest = {
|
||||
}[];
|
||||
|
||||
note?: string;
|
||||
editNote?: string;
|
||||
editedByUserId?: string;
|
||||
};
|
||||
|
||||
export type TAccessApproval = {
|
||||
@@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = {
|
||||
note?: string;
|
||||
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||
|
||||
export type TUpdateAccessRequestDTO = {
|
||||
requestId: string;
|
||||
editNote: string;
|
||||
temporaryRange: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TGetAccessApprovalRequestsDTO = {
|
||||
projectSlug: string;
|
||||
policyId?: string;
|
||||
|
@@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = {
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
protectedKey: string;
|
||||
protectedKeyTag: string;
|
||||
protectedKeyIV: string;
|
||||
encryptedPrivateKey: string;
|
||||
encryptedPrivateKeyIV: string;
|
||||
encryptedPrivateKeyTag: string;
|
||||
publicKey: string;
|
||||
verifier: string;
|
||||
salt: string;
|
||||
};
|
||||
|
||||
export type AdminGetOrganizationsFilters = {
|
||||
|
@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
|
||||
);
|
||||
return data.dynamicSecret;
|
||||
},
|
||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||
onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
|
||||
// TODO: optimize but currently don't pass projectId
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -37,7 +37,8 @@ export enum DynamicSecretProviders {
|
||||
Kubernetes = "kubernetes",
|
||||
Vertica = "vertica",
|
||||
GcpIam = "gcp-iam",
|
||||
Github = "github"
|
||||
Github = "github",
|
||||
Couchbase = "couchbase"
|
||||
}
|
||||
|
||||
export enum KubernetesDynamicSecretCredentialType {
|
||||
@@ -353,6 +354,38 @@ export type TDynamicSecretProvider =
|
||||
installationId: number;
|
||||
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 = {
|
||||
|
@@ -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 { useServerConfig } from "@app/context";
|
||||
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
||||
import { generateUserPassKey } from "@app/lib/crypto";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
@@ -48,17 +47,11 @@ export const SignUpPage = () => {
|
||||
// avoid multi submission
|
||||
if (isSubmitting) return;
|
||||
try {
|
||||
const { privateKey, ...userPass } = await generateUserPassKey(
|
||||
email,
|
||||
password,
|
||||
config.fipsEnabled
|
||||
);
|
||||
const res = await createAdminUser({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
...userPass
|
||||
lastName
|
||||
});
|
||||
|
||||
SecurityClient.setToken(res.token);
|
||||
|
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { RoleOption } from "@app/components/roles";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
@@ -45,7 +46,11 @@ const addMemberFormSchema = z.object({
|
||||
)
|
||||
.default([]),
|
||||
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>;
|
||||
@@ -238,6 +243,7 @@ export const AddOrgMemberModal = ({
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
components={{ Option: RoleOption }}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
@@ -36,7 +36,8 @@ type GithubFormData = BaseFormData &
|
||||
type GithubRadarFormData = BaseFormData &
|
||||
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 &
|
||||
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
||||
@@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => {
|
||||
|
||||
clearState(AppConnection.GitLab);
|
||||
|
||||
const { connectionId, name, description, returnUrl, isUpdate } = formData;
|
||||
const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData;
|
||||
|
||||
try {
|
||||
if (isUpdate && connectionId) {
|
||||
@@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => {
|
||||
app: AppConnection.GitLab,
|
||||
connectionId,
|
||||
credentials: {
|
||||
code: code as string
|
||||
code: code as string,
|
||||
instanceUrl: credentials.instanceUrl as string
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => {
|
||||
description,
|
||||
method: GitLabConnectionMethod.OAuth,
|
||||
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 { createNotification } from "@app/components/notifications";
|
||||
import { RoleOption } from "@app/components/roles";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -320,6 +321,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
components={{ Option: RoleOption }}
|
||||
placeholder="Select roles..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
@@ -21,6 +21,7 @@ import {
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretEventActions,
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSecretScanningConfigActions,
|
||||
ProjectPermissionSecretScanningDataSourceActions,
|
||||
@@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({
|
||||
[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({
|
||||
read: z.boolean().optional(),
|
||||
create: z.boolean().optional()
|
||||
@@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({
|
||||
[ProjectPermissionSub.SecretScanningFindings]:
|
||||
SecretScanningFindingPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretScanningConfigs]:
|
||||
SecretScanningConfigPolicyActionSchema.array().default([])
|
||||
SecretScanningConfigPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
})
|
||||
.partial()
|
||||
.optional()
|
||||
@@ -374,7 +387,8 @@ type TConditionalFields =
|
||||
| ProjectPermissionSub.SshHosts
|
||||
| ProjectPermissionSub.SecretRotation
|
||||
| ProjectPermissionSub.Identity
|
||||
| ProjectPermissionSub.SecretSyncs;
|
||||
| ProjectPermissionSub.SecretSyncs
|
||||
| ProjectPermissionSub.SecretEvents;
|
||||
|
||||
export const isConditionalSubjects = (
|
||||
subject: ProjectPermissionSub
|
||||
@@ -388,7 +402,8 @@ export const isConditionalSubjects = (
|
||||
subject === ProjectPermissionSub.SecretRotation ||
|
||||
subject === ProjectPermissionSub.PkiSubscribers ||
|
||||
subject === ProjectPermissionSub.CertificateTemplates ||
|
||||
subject === ProjectPermissionSub.SecretSyncs;
|
||||
subject === ProjectPermissionSub.SecretSyncs ||
|
||||
subject === ProjectPermissionSub.SecretEvents;
|
||||
|
||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||
const formConditions: z.infer<typeof ConditionSchema> = [];
|
||||
@@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
ProjectPermissionSub.SshCertificates,
|
||||
ProjectPermissionSub.SshHostGroups,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
ProjectPermissionSub.SecretSyncs,
|
||||
ProjectPermissionSub.SecretEvents
|
||||
].includes(subject)
|
||||
) {
|
||||
// from above statement we are sure it won't be undefined
|
||||
@@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
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
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
@@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||
{ label: "Create", value: ProjectPermissionSecretActions.Create },
|
||||
{ label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe }
|
||||
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretFolders]: {
|
||||
@@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
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.SecretRotation]: enabled,
|
||||
[ProjectPermissionSub.ServiceTokens]: enabled,
|
||||
[ProjectPermissionSub.Commits]: enabled
|
||||
[ProjectPermissionSub.Commits]: enabled,
|
||||
[ProjectPermissionSub.SecretEvents]: enabled
|
||||
});
|
||||
|
||||
const KmsPermissionSubjects = (enabled = false) => ({
|
||||
|
@@ -32,6 +32,7 @@ import {
|
||||
rolePermission2Form,
|
||||
TFormSchema
|
||||
} from "./ProjectRoleModifySection.utils";
|
||||
import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions";
|
||||
import { SecretPermissionConditions } from "./SecretPermissionConditions";
|
||||
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
|
||||
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
|
||||
@@ -72,6 +73,10 @@ export const renderConditionalComponents = (
|
||||
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretEvents) {
|
||||
return <SecretEventPermissionConditions isDisabled={isDisabled} />;
|
||||
}
|
||||
|
||||
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: "secretPath", label: "Secret Path" },
|
||||
{ value: "secretName", label: "Secret Name" },
|
||||
{ value: "secretTags", label: "Secret Tags" },
|
||||
{ value: "eventType", label: "Event Type" }
|
||||
{ value: "secretTags", label: "Secret Tags" }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
@@ -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">
|
||||
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
||||
<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>
|
||||
</div>
|
||||
<LogsSection
|
||||
@@ -32,7 +32,9 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||
showFilters={false}
|
||||
presets={{
|
||||
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
|
||||
}}
|
||||
/>
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -51,6 +52,7 @@ export const SelectionPanel = ({
|
||||
usedBySecretSyncs = []
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"bulkDeleteEntries",
|
||||
@@ -101,6 +103,16 @@ export const SelectionPanel = ({
|
||||
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 () => {
|
||||
let processedEntries = 0;
|
||||
|
||||
@@ -279,6 +291,7 @@ export const SelectionPanel = ({
|
||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||
deleteKey="delete"
|
||||
title={getDeleteModalTitle()}
|
||||
subTitle={getDeleteModalSubTitle()}
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||
onDeleteApproved={handleBulkDelete}
|
||||
formContent={
|
||||
|
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
|
||||
setSelectedRequest(null);
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
@@ -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 {
|
||||
faBan,
|
||||
faCheck,
|
||||
faEdit,
|
||||
faHourglass,
|
||||
faTriangleExclamation
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
||||
import {
|
||||
Approver,
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
|
||||
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||
if (status === ApprovalStatus.APPROVED)
|
||||
@@ -62,7 +66,8 @@ export const ReviewAccessRequestModal = ({
|
||||
selectedEnvSlug,
|
||||
canBypass,
|
||||
policies = [],
|
||||
members = []
|
||||
members = [],
|
||||
onUpdate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -78,6 +83,7 @@ export const ReviewAccessRequestModal = ({
|
||||
canBypass: boolean;
|
||||
policies: TAccessApprovalPolicy[];
|
||||
members: TWorkspaceUser[];
|
||||
onUpdate: (request: TAccessApprovalRequest) => void;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||
const [bypassApproval, setBypassApproval] = useState(false);
|
||||
@@ -86,6 +92,8 @@ export const ReviewAccessRequestModal = ({
|
||||
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||
const { user } = useUser();
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
|
||||
|
||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||
|
||||
const accessDetails = {
|
||||
@@ -121,16 +129,9 @@ export const ReviewAccessRequestModal = ({
|
||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||
return "Permanent";
|
||||
|
||||
// convert the range to human readable format
|
||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
||||
|
||||
return (
|
||||
<Badge>
|
||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`}
|
||||
</Badge>
|
||||
);
|
||||
return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`;
|
||||
};
|
||||
|
||||
const reviewAccessRequest = useReviewAccessRequest();
|
||||
@@ -286,7 +287,33 @@ export const ReviewAccessRequestModal = ({
|
||||
<GenericFieldLabel truncate label="Secret Path">
|
||||
{accessDetails.secretPath}
|
||||
</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>
|
||||
{request.note && (
|
||||
<GenericFieldLabel className="col-span-full" label="Note">
|
||||
|
@@ -14,6 +14,7 @@ import {
|
||||
faFingerprint,
|
||||
faFolder,
|
||||
faFolderPlus,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faLock,
|
||||
faPaste,
|
||||
@@ -32,6 +33,7 @@ import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
@@ -97,7 +99,7 @@ import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
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<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
||||
@@ -405,8 +407,8 @@ export const ActionBar = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const allUpdateSecrets: TParsedEnv = {};
|
||||
const allCreateSecrets: TParsedEnv = {};
|
||||
const allUpdateSecrets: TParsedEnv = [];
|
||||
const allCreateSecrets: TParsedEnv = [];
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
||||
@@ -437,7 +439,7 @@ export const ActionBar = ({
|
||||
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
||||
);
|
||||
|
||||
const existingSecretLookup: Record<string, boolean> = {};
|
||||
const existingSecretLookup = new Set<string>();
|
||||
|
||||
const processBatches = async () => {
|
||||
await secretBatches.reduce(async (previous, batch) => {
|
||||
@@ -451,7 +453,7 @@ export const ActionBar = ({
|
||||
});
|
||||
|
||||
batchSecrets.forEach((secret) => {
|
||||
existingSecretLookup[secret.secretKey] = true;
|
||||
existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`);
|
||||
});
|
||||
}, Promise.resolve());
|
||||
};
|
||||
@@ -465,18 +467,18 @@ export const ActionBar = ({
|
||||
// Store the path with the secret for later batch processing
|
||||
const secretWithPath = {
|
||||
...secretData,
|
||||
secretPath: normalizedPath
|
||||
secretPath: normalizedPath,
|
||||
secretKey
|
||||
};
|
||||
|
||||
if (existingSecretLookup[secretKey]) {
|
||||
allUpdateSecrets[secretKey] = secretWithPath;
|
||||
if (existingSecretLookup.has(`${normalizedPath}-${secretKey}`)) {
|
||||
allUpdateSecrets.push(secretWithPath);
|
||||
} else {
|
||||
allCreateSecrets[secretKey] = secretWithPath;
|
||||
allCreateSecrets.push(secretWithPath);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
update: allUpdateSecrets,
|
||||
create: allCreateSecrets
|
||||
@@ -519,7 +521,7 @@ export const ActionBar = ({
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
// Add paths from create secrets
|
||||
Object.values(create || {}).forEach((secData) => {
|
||||
create.forEach((secData) => {
|
||||
if (secData.secretPath && secData.secretPath !== secretPath) {
|
||||
allPaths.add(secData.secretPath);
|
||||
}
|
||||
@@ -575,8 +577,8 @@ export const ActionBar = ({
|
||||
return Promise.resolve();
|
||||
}, Promise.resolve());
|
||||
|
||||
if (Object.keys(create || {}).length > 0) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
if (create.length > 0) {
|
||||
create.forEach((secData) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
@@ -588,7 +590,7 @@ export const ActionBar = ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
secretKey: secData.secretKey
|
||||
});
|
||||
});
|
||||
|
||||
@@ -604,8 +606,8 @@ export const ActionBar = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length > 0) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
if (update.length > 0) {
|
||||
update.forEach((secData) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
@@ -617,7 +619,7 @@ export const ActionBar = ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
secretKey: secData.secretKey
|
||||
});
|
||||
});
|
||||
|
||||
@@ -665,6 +667,8 @@ export const ActionBar = ({
|
||||
const isTableFiltered =
|
||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||
|
||||
const filteredTags = Object.values(filter?.tags ?? {}).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@@ -697,7 +701,7 @@ export const ActionBar = ({
|
||||
Filters
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuContent align="end" sideOffset={2} className="p-0">
|
||||
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -776,11 +780,19 @@ export const ActionBar = ({
|
||||
iconPos="right"
|
||||
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>
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||
<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>
|
||||
{tags.map(({ id, slug, color }) => (
|
||||
<DropdownMenuItem
|
||||
@@ -1229,8 +1241,8 @@ export const ActionBar = ({
|
||||
<div className="flex flex-col text-gray-300">
|
||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
{(popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update
|
||||
?.map((sec) => sec.secretKey)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<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 {
|
||||
SiApachecassandra,
|
||||
SiCouchbase,
|
||||
SiElasticsearch,
|
||||
SiFiles,
|
||||
SiKubernetes,
|
||||
@@ -29,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
||||
import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { GcpIamInputForm } from "./GcpIamInputForm";
|
||||
import { GithubInputForm } from "./GithubInputForm";
|
||||
@@ -154,6 +156,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
|
||||
provider: DynamicSecretProviders.Github,
|
||||
title: "GitHub"
|
||||
},
|
||||
{
|
||||
icon: <SiCouchbase size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.Couchbase,
|
||||
title: "Couchbase"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -608,6 +615,25 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</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>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
@@ -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;
|
||||
};
|
||||
|
||||
|
@@ -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 { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
|
||||
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
|
||||
@@ -384,6 +385,23 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@@ -16,7 +16,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||
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 { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
@@ -54,6 +54,7 @@ export const FolderListView = ({
|
||||
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
|
||||
select: (el) => el.secretPath
|
||||
});
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
@@ -319,6 +320,11 @@ export const FolderListView = ({
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
|
||||
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)}
|
||||
onDeleteApproved={handleFolderDelete}
|
||||
/>
|
||||
|
@@ -28,7 +28,7 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
<h3 className="font-semibold text-mineshaft-100">Sync Logs</h3>
|
||||
{subscription.auditLogs && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -38,7 +38,9 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
showFilters={false}
|
||||
presets={{
|
||||
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
|
||||
}}
|
||||
/>
|
||||
|
@@ -12,9 +12,15 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} 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 { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -35,6 +41,7 @@ type Props = {
|
||||
|
||||
export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
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 (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -122,18 +139,26 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
|
||||
a={ProjectPermissionSub.Environments}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("updateEnv", { name, slug, id });
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
<Tooltip
|
||||
content={
|
||||
isMoreEnvironmentsAllowed
|
||||
? ""
|
||||
: `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"}.`
|
||||
}
|
||||
>
|
||||
<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
|
||||
|
Reference in New Issue
Block a user