Compare commits

...

48 Commits

Author SHA1 Message Date
Sheen Capadngan
28cc919ff7 misc: optimize partition script 2025-08-19 14:27:06 +08:00
Vlad Matsiiako
ef6f79f7a6 Merge pull request #4387 from Infisical/secrets-missing-docs
Bring Back Missing Secrets Documentation
2025-08-16 22:28:39 +08:00
Tuan Dang
43752e1888 bring back missing secrets docs 2025-08-16 17:02:06 +07:00
Scott Wilson
bd72129d8c Merge pull request #4384 from Infisical/aws-parameter-store-key-schema-path-fix
fix(aws-parameter-store-sync): handle keyschema with path segments for aws parameter store
2025-08-14 17:10:24 -07:00
carlosmonastyrski
bf10b2f58a Merge pull request #4385 from Infisical/fix/gitlabSelfHostingOauth
Fix Gitlab OAuth redirection issue with instanceUrls
2025-08-14 17:07:11 -07:00
Scott Wilson
d24f5a57a8 improvement: throw on keyschema leading slash 2025-08-14 16:59:26 -07:00
Carlos Monastyrski
166104e523 Fix Gitlab OAuth redirection issue with instanceUrls 2025-08-14 16:55:47 -07:00
Scott Wilson
a7847f177c improvements: address feedback 2025-08-14 16:25:00 -07:00
Scott Wilson
48e5f550e9 fix: handle keyschema with path segments for aws parameter store 2025-08-14 15:46:41 -07:00
carlosmonastyrski
4a4a7fd325 Merge pull request #4374 from Infisical/ENG-3518
Disable environments edit when a project has more environment than the allowed by the org plan
2025-08-14 12:24:27 -07:00
Carlos Monastyrski
91b8ed8015 Minor improvement on a math calculated field 2025-08-14 10:14:22 -07:00
carlosmonastyrski
6cf978b593 Merge pull request #4359 from Infisical/ENG-3483
Add machine identities to /organization endpoint
2025-08-14 10:10:54 -07:00
Akhil Mohan
68fbb399fc Merge pull request #4383 from Infisical/fix/audit-log-page
feat: added a min for integration audit log
2025-08-14 22:37:18 +05:30
=
97366f6e95 feat: added a min for integration audit log 2025-08-14 22:32:51 +05:30
Akhil Mohan
c83d4af7a3 Merge pull request #4382 from Infisical/fix/replicate-duplicate
fix: resolved replication failing for duplicate
2025-08-14 22:28:07 +05:30
Scott Wilson
c35c937c63 Merge pull request #4380 from Infisical/role-descriptions
improvement(frontend): display role descriptions in invite user/add project membership filter selects
2025-08-14 08:45:03 -07:00
=
b10752acb5 fix: resolved replication failing for duplicate 2025-08-14 20:39:00 +05:30
Maidul Islam
eb9b75d930 Merge pull request #4360 from agabek-ov/patch-1
Fix typos in overview.mdx
2025-08-14 05:59:28 -07:00
Sid
273a7b9657 fix: update event permissions system (#4355)
* fix: update project permission types

* fix: permissions

* fix: PR changes

* chore: update docs

* fix:  greptile changes

* fix: secret imports router changes

* fix: type change

* fix: lint

* fix: type change

* fix: check plan type in publish

* fix: permissions

* fix: import change

* fix: lint fix
2025-08-14 13:47:59 +05:30
x032205
a3b6fa9a53 Merge pull request #4335 from Infisical/ENG-3420
feat(app-connection): Make GitHub paginated requests concurrent
2025-08-13 23:26:24 -07:00
Scott Wilson
f60dd528e8 improvement: display role descriptions in invite user/add project member modals 2025-08-13 21:03:24 -07:00
carlosmonastyrski
8ffef1da8e Merge pull request #4372 from Infisical/ENG-3366
Add Couchbase dynamic secrets feature
2025-08-13 20:36:23 -07:00
carlosmonastyrski
f352f98374 Merge pull request #4375 from Infisical/ENG-3516
Improve delete folder message
2025-08-13 20:16:35 -07:00
Carlos Monastyrski
91a76f50ca Address PR comments 2025-08-13 20:13:34 -07:00
Scott Wilson
ea4bb0a062 Merge pull request #4369 from Infisical/modify-access-requests
feature(access-requests): allow editing of access request by admin reviewers
2025-08-13 20:08:27 -07:00
Scott Wilson
3d6be7b1b2 Merge pull request #4377 from Infisical/tag-filter-ui-improvements
improvement(frontend): adjust environment view dropdown alignment, add applied tags display and info tooltip to clarify behavior
2025-08-13 20:04:43 -07:00
Carlos Monastyrski
12558e8614 Fix wording on message 2025-08-13 18:16:37 -07:00
Scott Wilson
987f87e562 improvement: adjust environment view dropdown alignment, add applied tags display and info tooltip to clarify behavior 2025-08-13 17:14:54 -07:00
Carlos Monastyrski
4d06d5cbb0 Fix wording on message 2025-08-13 17:09:53 -07:00
Carlos Monastyrski
bad934de48 Improve delete folder message 2025-08-13 16:13:38 -07:00
Scott Wilson
90b93fbd15 improvements: address feedback 2025-08-13 16:03:48 -07:00
Carlos Monastyrski
c2db2a0bc7 Endpoint improvements 2025-08-13 14:20:59 -07:00
Daniel Hougaard
b0d24de008 Merge pull request #4373 from Infisical/daniel/remove-srp-from-admin-signup
fix(srp): remove srp flow from admin signup
2025-08-14 00:56:45 +04:00
Carlos Monastyrski
0473fb0ddb Disable environments edit when a project has more environment than the allowed by the org plan 2025-08-13 13:50:29 -07:00
Daniel Hougaard
4ccb5dc9b0 fix bootstrapping 2025-08-14 00:44:49 +04:00
Daniel Hougaard
930425d5dc fix(srp): remove srp flow from admin signup 2025-08-13 23:56:38 +04:00
Carlos Monastyrski
f77a53bd8e Undo license fns changes for testing 2025-08-13 11:48:07 -07:00
Carlos Monastyrski
4bd61e5607 Undo license fns changes for testing 2025-08-13 11:47:41 -07:00
Carlos Monastyrski
aa4dbfa073 Move identity org details to new endpoint 2025-08-13 11:43:28 -07:00
Carlos Monastyrski
b479406ba0 Address greptile suggestions 2025-08-13 11:23:55 -07:00
Carlos Monastyrski
7cf9d933da Add Couchbase dynamic secrets feature 2025-08-13 09:59:22 -07:00
Scott Wilson
b8fa4d5255 improvements: address feedback 2025-08-13 09:13:26 -07:00
Scott Wilson
0d3cb2d41a feature: allow editing of access request by admin reviewers 2025-08-12 23:25:31 -07:00
Carlos Monastyrski
18881749fd Improve error message 2025-08-12 19:10:45 -07:00
Anuar Agabekov
55607a4886 Fix typos in overview.mdx 2025-08-12 23:26:02 +05:00
Carlos Monastyrski
385c75c543 Add machine identities to /organization endpoint 2025-08-11 21:23:05 -07:00
x032205
352ef050c3 Update backend/src/services/app-connection/github/github-connection-fns.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-07 20:26:12 -04:00
x032205
b6b9fb6ef5 feat(app-connection): Make GitHub paginated requests concurrent 2025-08-07 20:20:48 -04:00
81 changed files with 4242 additions and 564 deletions

View File

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

View File

@@ -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");
}
});
}
}

View File

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

View File

@@ -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 };
}
});
};

View File

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

View File

@@ -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: {

View File

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

View 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
};
};

View File

@@ -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()
});

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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");

View File

@@ -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("/"),

View File

@@ -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
);

View File

@@ -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
]);

View File

@@ -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) {

View File

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

View File

@@ -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 = {

View File

@@ -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({

View File

@@ -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({

View File

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

View File

@@ -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 } };
}
});
};

View File

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

View File

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

View File

@@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => {
}
};
const findIdentityOrganization = async (
identityId: string
): Promise<{ id: string; name: string; slug: string; role: string }> => {
try {
const org = await db
.replicaNode()(TableName.IdentityOrgMembership)
.where({ identityId })
.join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`)
.select(db.ref("id").withSchema(TableName.Organization).as("id"))
.select(db.ref("name").withSchema(TableName.Organization).as("name"))
.select(db.ref("slug").withSchema(TableName.Organization).as("slug"))
.select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role"));
return org?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find identity organization" });
}
};
return withTransaction(db, {
...orgOrm,
findOrgByProjectId,
@@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => {
updateMembershipById,
deleteMembershipById,
deleteMembershipsById,
updateMembership
updateMembership,
findIdentityOrganization
});
};

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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[] = [];

View File

@@ -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
}
}
});
}

View File

@@ -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
}
}
})
)
);

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate";

View File

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

View File

@@ -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
);

View File

@@ -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;
};

View File

@@ -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"
]
},
{

View 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">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select Couchbase">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="URL" type="string" required default="https://cloudapi.cloud.couchbase.com">
The Couchbase Cloud API URL
</ParamField>
<ParamField path="Organization ID" type="string" required>
Your Couchbase Cloud organization ID
</ParamField>
<ParamField path="Project ID" type="string" required>
Your Couchbase Cloud project ID
</ParamField>
<ParamField path="Cluster ID" type="string" required>
Your Couchbase Cloud cluster ID where users will be created
</ParamField>
<ParamField path="Roles" type="array" required>
Database credential roles to assign to the generated user. Available options:
- **read**: Read access to bucket data (alias for data_reader)
- **write**: Read and write access to bucket data (alias for data_writer)
</ParamField>
<ParamField path="Bucket Access" type="string" required default="*">
Specify bucket access configuration:
- Use `*` for access to all buckets
- Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets
- Use Advanced Bucket Configuration for granular scope and collection access
</ParamField>
<ParamField path="API Key" type="string" required>
Your Couchbase Cloud API Key for authentication
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png)
</Step>
<Step title="(Optional) Advanced Configuration">
![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png)
<ParamField path="Advanced Bucket Configuration" type="boolean" default="false">
Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections
</ParamField>
When Advanced Bucket Configuration is enabled, you can configure:
<ParamField path="Buckets" type="array">
List of buckets with optional scope and collection specifications:
- **Bucket Name**: Name of the bucket (e.g., travel-sample)
- **Scopes**: Optional array of scopes within the bucket
- **Scope Name**: Name of the scope (e.g., inventory, _default)
- **Collections**: Optional array of collection names within the scope
</ParamField>
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
Specifies a template for generating usernames. This field allows customization of how usernames are automatically created.
Allowed template variables are:
- `{{randomUsername}}`: Random username string
- `{{unixTimestamp}}`: Current Unix timestamp
- `{{identity.name}}`: Name of the identity that is generating the secret
- `{{random N}}`: Random string of N characters
Allowed template functions are:
- `truncate`: Truncates a string to a specified length
- `replace`: Replaces a substring with another value
Examples:
```
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
{{unixTimestamp}} // 17490641580
{{identity.name}} // testuser
{{random 5}} // x9k2m
{{truncate identity.name 4}} // test
{{replace identity.name 'user' 'replace'}} // testreplace
```
</ParamField>
<ParamField path="Password Configuration" type="object">
Optional password generation requirements for Couchbase users:
<ParamField path="Password Length" type="number" default="12" min="8" max="128">
Length of the generated password
</ParamField>
<ParamField path="Character Requirements" type="object">
Minimum required character counts:
- **Lowercase Count**: Minimum lowercase letters (default: 1)
- **Uppercase Count**: Minimum uppercase letters (default: 1)
- **Digit Count**: Minimum digits (default: 1)
- **Symbol Count**: Minimum special characters (default: 1)
</ParamField>
<ParamField path="Allowed Symbols" type="string" default="!@#$%^()_+-=[]{}:,?/~`">
Special characters allowed in passwords. Cannot contain: `< > ; . * & | £`
</ParamField>
<Info>
Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £`
</Info>
</ParamField>
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
<Note>
If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs.
</Note>
![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png)
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png)
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png)
</Step>
</Steps>
## Advanced Bucket Configuration Examples
The advanced bucket configuration allows you to specify granular access control:
### Example 1: Specific Bucket Access
```json
[
{
"name": "travel-sample"
}
]
```
### Example 2: Bucket with Specific Scopes
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory"
},
{
"name": "_default"
}
]
}
]
```
### Example 3: Bucket with Scopes and Collections
```json
[
{
"name": "travel-sample",
"scopes": [
{
"name": "inventory",
"collections": ["airport", "airline"]
},
{
"name": "_default",
"collections": ["users"]
}
]
}
]
```
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you to see the expiration time of the lease or delete a lease before its set time to live.
![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below.
![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>
## Couchbase Roles and Permissions
The Couchbase dynamic secret integration supports the following database credential roles:
- **read**: Provides read-only access to bucket data
- **write**: Provides read and write access to bucket data
<Note>
These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration.
</Note>
## Troubleshooting
### Common Issues
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users
2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key
3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write)
4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./RoleOption";

View File

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

View File

@@ -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 };

View File

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

View File

@@ -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
};

View File

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

View File

@@ -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 = {

View File

@@ -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 })
});
}
});
};

View File

@@ -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 = {

View File

@@ -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
};
};

View File

@@ -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);

View File

@@ -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>
)}

View File

@@ -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
}
});
}

View File

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

View File

@@ -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) => ({

View File

@@ -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} />;
}

View File

@@ -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" }
]}
/>
);
};

View File

@@ -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" }
]}
/>
);

View File

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

View File

@@ -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={

View File

@@ -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}
/>
)}

View File

@@ -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>
);
};

View File

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

View File

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

View File

@@ -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: &lt; &gt; ; . * &amp; | £
</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>
);
};

View File

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

View File

@@ -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;
};

View File

@@ -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: &lt; &gt; ; . * &amp; | <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>
);
};

View File

@@ -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>
);
};

View File

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

View File

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

View File

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