Merge branch 'main' of github.com:Infisical/infisical into sid/k8s-operator

This commit is contained in:
sidwebworks
2025-08-14 15:48:20 +05:30
76 changed files with 4300 additions and 541 deletions
@@ -0,0 +1,38 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (!hasEditNoteCol || !hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (!hasEditedByUserId) {
t.uuid("editedByUserId").nullable();
t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
}
if (!hasEditNoteCol) {
t.string("editNote").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
if (hasEditNoteCol || hasEditedByUserId) {
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
if (hasEditedByUserId) {
t.dropColumn("editedByUserId");
}
if (hasEditNoteCol) {
t.dropColumn("editNote");
}
});
}
}
@@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({
requestedByUserId: z.string().uuid(), requestedByUserId: z.string().uuid(),
note: z.string().nullable().optional(), note: z.string().nullable().optional(),
privilegeDeletedAt: z.date().nullable().optional(), privilegeDeletedAt: z.date().nullable().optional(),
status: z.string().default("pending") status: z.string().default("pending"),
editedByUserId: z.string().uuid().nullable().optional(),
editNote: z.string().nullable().optional()
}); });
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>; export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
body: z.object({ body: z.object({
permissions: z.any().array(), permissions: z.any().array(),
isTemporary: z.boolean(), isTemporary: z.boolean(),
temporaryRange: z.string().optional(), temporaryRange: z
.string()
.optional()
.transform((val, ctx) => {
if (!val || val === "permanent") return undefined;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
note: z.string().max(255).optional() note: z.string().max(255).optional()
}), }),
querystring: z.object({ querystring: z.object({
@@ -190,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
return { review }; return { review };
} }
}); });
server.route({
url: "/:requestId",
method: "PATCH",
schema: {
params: z.object({
requestId: z.string().trim()
}),
body: z.object({
temporaryRange: z.string().transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
editNote: z.string().max(255)
}),
response: {
200: z.object({
approval: AccessApprovalRequestsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
temporaryRange: req.body.temporaryRange,
editNote: req.body.editNote,
requestId: req.params.requestId
});
return { approval: request };
}
});
}; };
@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">; accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
accessApprovalRequestReviewerDAL: Pick< accessApprovalRequestReviewerDAL: Pick<
TAccessApprovalRequestReviewerDALFactory, TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction" "create" | "find" | "findOne" | "transaction" | "delete"
>; >;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">; groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
@@ -301,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
return { request: approval }; return { request: approval };
}; };
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
temporaryRange,
actorId,
actor,
actorOrgId,
actorAuthMethod,
editNote,
requestId
}) => {
const cfg = getConfig();
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
if (!accessApprovalRequest) {
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
}
const { policy, requestedByUser } = accessApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this access request has been deleted."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: accessApprovalRequest.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
if (!membership) {
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
}
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
}
const project = await projectDAL.findById(accessApprovalRequest.projectId);
if (!project) {
throw new NotFoundError({
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
});
}
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
throw new BadRequestError({ message: "The request has been closed" });
}
const editedByUser = await userDAL.findById(actorId);
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
}
}
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
permissions: accessApprovalRequest.permissions
});
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
const approvalRequest = await accessApprovalRequestDAL.updateById(
requestId,
{
temporaryRange,
isTemporary: true,
editNote,
editedByUserId: actorId
},
tx
);
// reset review progress
await accessApprovalRequestReviewerDAL.delete(
{
requestId
},
tx
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
await triggerWorkflowIntegrationNotification({
input: {
notification: {
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
payload: {
projectName: project.name,
requesterFullName,
isTemporary: true,
requesterEmail: requestedByUser.email as string,
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
}
},
projectId: project.id
},
dependencies: {
projectDAL,
projectSlackConfigDAL,
kmsService,
microsoftTeamsService,
projectMicrosoftTeamsConfigDAL
}
});
await smtpService.sendMail({
recipients: policy.approvers
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
.map((approver) => approver.email!),
subjectLine: "Access Approval Request Updated",
substitutions: {
projectName: project.name,
requesterFullName,
requesterEmail: requestedByUser.email,
isTemporary: true,
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorFullName,
editorEmail: editedByUser.email
},
template: SmtpTemplates.AccessApprovalRequestUpdated
});
return approvalRequest;
});
return { request: approval };
};
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug, projectSlug,
authorUserId, authorUserId,
@@ -650,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
return { return {
createAccessApprovalRequest, createAccessApprovalRequest,
updateAccessApprovalRequest,
listApprovalRequests, listApprovalRequests,
reviewAccessRequest, reviewAccessRequest,
getCount getCount
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
note?: string; note?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalRequestDTO = {
requestId: string;
temporaryRange: string;
editNote: string;
} & Omit<TProjectPermission, "projectId">;
export type TListApprovalRequestsDTO = { export type TListApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
authorUserId?: string; authorUserId?: string;
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
privilegeDeletedAt?: Date | null | undefined; privilegeDeletedAt?: Date | null | undefined;
}; };
}>; }>;
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
request: {
status: string;
id: string;
createdAt: Date;
updatedAt: Date;
policyId: string;
isTemporary: boolean;
requestedByUserId: string;
privilegeId?: string | null | undefined;
requestedBy?: string | null | undefined;
temporaryRange?: string | null | undefined;
permissions?: unknown;
note?: string | null | undefined;
privilegeDeletedAt?: Date | null | undefined;
};
}>;
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{ listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
requests: { requests: {
policy: { policy: {
@@ -1,8 +1,6 @@
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError, RawAxiosRequestHeaders } from "axios";
import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; import { SecretKeyEncoding } from "@app/db/schemas";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { TopicName, toPublishableEvent } from "@app/ee/services/event/types";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = {
queueService: TQueueServiceFactory; queueService: TQueueServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
eventBusService: TEventBusService;
}; };
export type TAuditLogQueueServiceFactory = { export type TAuditLogQueueServiceFactory = {
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL, auditLogStreamDAL
eventBusService
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => { }: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
const pushToLog = async (data: TCreateAuditLogDTO) => { const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, { await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
@@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({
) )
); );
} }
const publishable = toPublishableEvent(event);
if (publishable) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: publishable.data
});
}
}); });
return { return {
@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import axios from "axios";
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
type TCreateCouchbaseUser = {
name: string;
password: string;
access: {
privileges: string[];
resources: {
buckets: {
name: string;
scopes?: {
name: string;
collections?: string[];
}[];
}[];
};
}[];
};
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12);
}
return sanitized;
};
/**
* Normalizes bucket configuration to handle wildcard (*) access consistently.
*
* Key behaviors:
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
*
* @param buckets - Either a string or array of bucket configurations
* @returns Normalized bucket resources for Couchbase API
*/
const normalizeBucketConfiguration = (
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>
) => {
if (typeof buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names
const bucketNames = buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
if (bucketNames.includes("*") || buckets === "*") {
return [{ name: "*" }];
}
return bucketNames.map((bucketName) => ({ name: bucketName }));
}
// Array of bucket objects with scopes and collections
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
if (hasWildcardBucket) {
return [{ name: "*" }];
}
return buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
return sanitizeCouchbaseUsername(compiledUsername);
};
const generatePassword = (requirements?: PasswordRequirements): string => {
const {
length = 12,
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
} = requirements || {};
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const symbols = allowedSymbols;
let password = "";
let remaining = length;
// Add required characters
for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)];
remaining -= 1;
}
for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)];
remaining -= 1;
}
// Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)];
}
// Shuffle the password
return password
.split("")
.sort(() => crypto.randomInt(3) - 1)
.join("");
};
const couchbaseApiRequest = async (
method: string,
url: string,
apiKey: string,
data?: unknown
): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url);
try {
const response = await axios({
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: data || undefined,
timeout: 30000
});
return response.data as CouchbaseUserResponse;
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [apiKey]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
}
};
export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs;
};
const validateConnection = async (inputs: unknown): Promise<boolean> => {
try {
const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true;
} catch (error) {
throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const create = async ({
inputs,
usernameTemplate,
identity
}: {
inputs: unknown;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
const userData: TCreateCouchbaseUser = {
name: username,
password,
access: [
{
privileges: providerInputs.roles,
resources: {
buckets: bucketResources
}
}
]
};
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username;
return {
entityId: userUuid,
data: {
username,
password
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId };
};
const renew = async (_inputs: unknown, entityId: string) => {
// Couchbase Cloud API doesn't support renewing user credentials
// The user remains valid until explicitly deleted
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};
@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam"; import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id"; import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra"; import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search"; import { ElasticSearchProvider } from "./elastic-search";
import { GcpIamProvider } from "./gcp-iam"; import { GcpIamProvider } from "./gcp-iam";
import { GithubProvider } from "./github"; import { GithubProvider } from "./github";
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
[DynamicSecretProviders.GcpIam]: GcpIamProvider(), [DynamicSecretProviders.GcpIam]: GcpIamProvider(),
[DynamicSecretProviders.Github]: GithubProvider() [DynamicSecretProviders.Github]: GithubProvider(),
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
}); });
@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
.describe("The private key generated for your GitHub App.") .describe("The private key generated for your GitHub App.")
}); });
export const DynamicSecretCouchbaseSchema = z.object({
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
orgId: z.string().trim().min(1).describe("Organization ID"),
projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster ID"),
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z
.union([
z
.string()
.trim()
.min(1)
.default("*")
.refine((val) => {
if (val.includes(",")) {
const buckets = val
.split(",")
.map((b) => b.trim())
.filter((b) => b.length > 0);
if (buckets.includes("*") && buckets.length > 1) {
return false;
}
}
return true;
}, "Cannot combine '*' with other bucket names"),
z
.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"),
scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
})
)
.optional()
.describe("Scopes within the bucket")
})
)
.refine((buckets) => {
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
if (hasWildcard && buckets.length > 1) {
return false;
}
return true;
}, "Cannot combine '*' bucket with other buckets")
])
.default("*")
.describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: z
.object({
length: z.number().min(8, "Password must be at least 8 characters").max(128),
required: z
.object({
lowercase: z.number().min(1, "At least 1 lowercase character required"),
uppercase: z.number().min(1, "At least 1 uppercase character required"),
digits: z.number().min(1, "At least 1 digit required"),
symbols: z.number().min(1, "At least 1 special character required")
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128;
}, "Sum of required characters cannot exceed 128"),
allowedSymbols: z
.string()
.refine((symbols) => {
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements for Couchbase"),
auth: z.object({
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
})
});
export enum DynamicSecretProviders { export enum DynamicSecretProviders {
SqlDatabase = "sql-database", SqlDatabase = "sql-database",
Cassandra = "cassandra", Cassandra = "cassandra",
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes", Kubernetes = "kubernetes",
Vertica = "vertica", Vertica = "vertica",
GcpIam = "gcp-iam", GcpIam = "gcp-iam",
Github = "github" Github = "github",
Couchbase = "couchbase"
} }
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }), z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }) z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
]); ]);
export type TDynamicProviderFns = { export type TDynamicProviderFns = {
@@ -3,7 +3,7 @@ import { z } from "zod";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { EventSchema, TopicName } from "./types"; import { BusEventSchema, TopicName } from "./types";
export const eventBusFactory = (redis: Redis) => { export const eventBusFactory = (redis: Redis) => {
const publisher = redis.duplicate(); const publisher = redis.duplicate();
@@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => {
* @param topic - The topic to publish the event to. * @param topic - The topic to publish the event to.
* @param event - The event data to publish. * @param event - The event data to publish.
*/ */
const publish = async <T extends z.input<typeof EventSchema>>(topic: TopicName, event: T) => { const publish = async <T extends z.input<typeof BusEventSchema>>(topic: TopicName, event: T) => {
const json = JSON.stringify(event); const json = JSON.stringify(event);
return publisher.publish(topic, json, (err) => { return publisher.publish(topic, json, (err) => {
@@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => {
* @template T - The type of the event data, which should match the schema defined in EventSchema. * @template T - The type of the event data, which should match the schema defined in EventSchema.
* @returns A function that can be called to unsubscribe from the event bus. * @returns A function that can be called to unsubscribe from the event bus.
*/ */
const subscribe = <T extends z.infer<typeof EventSchema>>(fn: (data: T) => Promise<void> | void) => { const subscribe = <T extends z.infer<typeof BusEventSchema>>(fn: (data: T) => Promise<void> | void) => {
// Not using async await cause redis client's `on` method does not expect async listeners. // Not using async await cause redis client's `on` method does not expect async listeners.
const listener = (channel: string, message: string) => { const listener = (channel: string, message: string) => {
try { try {
@@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger";
import { TEventBusService } from "./event-bus-service"; import { TEventBusService } from "./event-bus-service";
import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream";
import { EventData, RegisteredEvent, toBusEventName } from "./types"; import { BusEvent, RegisteredEvent } from "./types";
const AUTH_REFRESH_INTERVAL = 60 * 1000; const AUTH_REFRESH_INTERVAL = 60 * 1000;
const HEART_BEAT_INTERVAL = 15 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000;
@@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => {
} }
}; };
function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) {
const eventType = toBusEventName(event.data.eventType); const eventType = event.data.event;
const match = registered.find((r) => r.event === eventType); const match = registered.find((r) => r.event === eventType);
if (!match) return; if (!match) return;
@@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore";
import { conditionsMatcher } from "@app/lib/casl"; import { conditionsMatcher } from "@app/lib/casl";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { EventData, RegisteredEvent } from "./types"; import { BusEvent, RegisteredEvent } from "./types";
export const getServerSentEventsHeaders = () => export const getServerSentEventsHeaders = () =>
({ ({
@@ -55,7 +55,7 @@ export type EventStreamClient = {
id: string; id: string;
stream: Readable; stream: Readable;
open: () => Promise<void>; open: () => Promise<void>;
send: (data: EventMessage | EventData) => void; send: (data: EventMessage | BusEvent) => void;
ping: () => Promise<void>; ping: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
close: () => void; close: () => void;
@@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
return { return {
subject: options.type, subject: options.type,
action: "subscribe", action: "subscribe",
conditions: { conditions: hasConditions
eventType: r.event, ? {
...(hasConditions environment: r.conditions?.environmentSlug ?? "",
? { secretPath: { $glob: secretPath }
environment: r.conditions?.environmentSlug ?? "", }
secretPath: { $glob: secretPath } : undefined
}
: {})
}
}; };
}); });
@@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
// We will manually push data to the stream // We will manually push data to the stream
stream._read = () => {}; stream._read = () => {};
const send = (data: EventMessage | EventData) => { const send = (data: EventMessage | BusEvent) => {
const chunk = serializeSseEvent(data); const chunk = serializeSseEvent(data);
if (!stream.push(chunk)) { if (!stream.push(chunk)) {
logger.debug("Backpressure detected: dropped manual event"); logger.debug("Backpressure detected: dropped manual event");
+45 -71
View File
@@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ProjectPermissionSecretEventActions } from "../permission/project-permission";
export enum TopicName { export enum TopicName {
CoreServers = "infisical::core-servers" CoreServers = "infisical::core-servers"
@@ -10,84 +11,44 @@ export enum TopicName {
export enum BusEventName { export enum BusEventName {
CreateSecret = "secret:create", CreateSecret = "secret:create",
UpdateSecret = "secret:update", UpdateSecret = "secret:update",
DeleteSecret = "secret:delete" DeleteSecret = "secret:delete",
ImportMutation = "secret:import-mutation"
} }
type PublisableEventTypes = export const Mappings = {
| EventType.CREATE_SECRET BusEventToAction(input: BusEventName) {
| EventType.CREATE_SECRETS switch (input) {
| EventType.DELETE_SECRET case BusEventName.CreateSecret:
| EventType.DELETE_SECRETS return ProjectPermissionSecretEventActions.SubscribeCreated;
| EventType.UPDATE_SECRETS case BusEventName.DeleteSecret:
| EventType.UPDATE_SECRET; return ProjectPermissionSecretEventActions.SubscribeDeleted;
case BusEventName.ImportMutation:
export function toBusEventName(input: EventType) { return ProjectPermissionSecretEventActions.SubscribeImportMutations;
switch (input) { case BusEventName.UpdateSecret:
case EventType.CREATE_SECRET: return ProjectPermissionSecretEventActions.SubscribeUpdated;
case EventType.CREATE_SECRETS: default:
return BusEventName.CreateSecret; throw new Error("Unknown bus event name");
case EventType.UPDATE_SECRET:
case EventType.UPDATE_SECRETS:
return BusEventName.UpdateSecret;
case EventType.DELETE_SECRET:
case EventType.DELETE_SECRETS:
return BusEventName.DeleteSecret;
default:
return null;
}
}
const isBulkEvent = (event: Event): event is Extract<Event, { metadata: { secrets: Array<unknown> } }> => {
return event.type.endsWith("-secrets"); // Feels so wrong
};
export const toPublishableEvent = (event: Event) => {
const name = toBusEventName(event.type);
if (!name) return null;
const e = event as Extract<Event, { type: PublisableEventTypes }>;
if (isBulkEvent(e)) {
return {
name,
isBulk: true,
data: {
eventType: e.type,
payload: e.metadata.secrets.map((s) => ({
environment: e.metadata.environment,
secretPath: e.metadata.secretPath,
...s
}))
}
} as const;
}
return {
name,
isBulk: false,
data: {
eventType: e.type,
payload: {
...e.metadata,
environment: e.metadata.environment
}
} }
} as const; }
}; };
export const EventName = z.nativeEnum(BusEventName); export const EventName = z.nativeEnum(BusEventName);
const EventSecretPayload = z.object({ const EventSecretPayload = z.object({
secretPath: z.string().optional(),
secretId: z.string(), secretId: z.string(),
secretPath: z.string().optional(),
secretKey: z.string(), secretKey: z.string(),
environment: z.string() environment: z.string()
}); });
const EventImportMutationPayload = z.object({
secretPath: z.string(),
environment: z.string()
});
export type EventSecret = z.infer<typeof EventSecretPayload>; export type EventSecret = z.infer<typeof EventSecretPayload>;
export const EventSchema = z.object({ export const BusEventSchema = z.object({
datacontenttype: z.literal("application/json").optional().default("application/json"), datacontenttype: z.literal("application/json").optional().default("application/json"),
type: z.nativeEnum(ProjectType), type: z.nativeEnum(ProjectType),
source: z.string(), source: z.string(),
@@ -95,25 +56,38 @@ export const EventSchema = z.object({
.string() .string()
.optional() .optional()
.default(() => new Date().toISOString()), .default(() => new Date().toISOString()),
data: z.discriminatedUnion("eventType", [ data: z.discriminatedUnion("event", [
z.object({ z.object({
specversion: z.number().optional().default(1), specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]),
payload: EventSecretPayload payload: z.union([EventSecretPayload, EventSecretPayload.array()])
}), }),
z.object({ z.object({
specversion: z.number().optional().default(1), specversion: z.number().optional().default(1),
eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), event: z.enum([BusEventName.ImportMutation]),
payload: EventSecretPayload.array() payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()])
}) })
// Add more event types as needed // Add more event types as needed
]) ])
}); });
export type EventData = z.infer<typeof EventSchema>; export type BusEvent = z.infer<typeof BusEventSchema>;
type PublishableEventPayload = z.input<typeof BusEventSchema>["data"];
type PublishableSecretEvent = Extract<
PublishableEventPayload,
{ event: Exclude<BusEventName, BusEventName.ImportMutation> }
>["payload"];
export type PublishableEvent = {
created?: PublishableSecretEvent;
updated?: PublishableSecretEvent;
deleted?: PublishableSecretEvent;
importMutation?: Extract<PublishableEventPayload, { event: BusEventName.ImportMutation }>["payload"];
};
export const EventRegisterSchema = z.object({ export const EventRegisterSchema = z.object({
event: EventName, event: z.nativeEnum(BusEventName),
conditions: z conditions: z
.object({ .object({
secretPath: z.string().optional().default("/"), secretPath: z.string().optional().default("/"),
@@ -161,8 +161,7 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete, ProjectPermissionSecretActions.Delete
ProjectPermissionSecretActions.Subscribe
], ],
ProjectPermissionSub.Secrets ProjectPermissionSub.Secrets
); );
@@ -266,8 +265,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Delete, ProjectPermissionSecretActions.Delete
ProjectPermissionSecretActions.Subscribe
], ],
ProjectPermissionSub.Secrets ProjectPermissionSub.Secrets
); );
@@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions {
ReadValue = "readValue", ReadValue = "readValue",
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete"
Subscribe = "subscribe"
} }
export enum ProjectPermissionCmekActions { export enum ProjectPermissionCmekActions {
@@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
Update = "update-configs" Update = "update-configs"
} }
export enum ProjectPermissionSecretEventActions {
SubscribeCreated = "subscribe-on-created",
SubscribeUpdated = "subscribe-on-updated",
SubscribeDeleted = "subscribe-on-deleted",
SubscribeImportMutations = "subscribe-on-import-mutations"
}
export enum ProjectPermissionSub { export enum ProjectPermissionSub {
Role = "role", Role = "role",
Member = "member", Member = "member",
@@ -197,7 +203,8 @@ export enum ProjectPermissionSub {
Kmip = "kmip", Kmip = "kmip",
SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningDataSources = "secret-scanning-data-sources",
SecretScanningFindings = "secret-scanning-findings", SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs" SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events"
} }
export type SecretSubjectFields = { export type SecretSubjectFields = {
@@ -205,7 +212,13 @@ export type SecretSubjectFields = {
secretPath: string; secretPath: string;
secretName?: string; secretName?: string;
secretTags?: string[]; secretTags?: string[];
eventType?: string; };
export type SecretEventSubjectFields = {
environment: string;
secretPath: string;
secretName?: string;
secretTags?: string[];
}; };
export type SecretFolderSubjectFields = { export type SecretFolderSubjectFields = {
@@ -344,7 +357,11 @@ export type ProjectPermissionSet =
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
| [
ProjectPermissionSecretEventActions,
ProjectPermissionSub.SecretEvents | (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource." "When specified, only matching conditions will be allowed to access given resource."
).optional() ).optional()
}), }),
z.object({
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe(
"Describe what action an entity can take."
),
conditions: SecretSyncConditionV2Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema ...GeneralPermissionSchema
]); ]);
@@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({
if (!folder) { if (!folder) {
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
} }
const { secrets } = mergeStatus;
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
secretPath: folder.path, secretPath: folder.path,
environmentSlug: folder.environmentSlug, environmentSlug: folder.environmentSlug,
actorId, actorId,
actor actor,
event: {
created: secrets.created.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
updated: secrets.updated.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
})),
deleted: secrets.deleted.map((el) => ({
environment: folder.environmentSlug,
secretPath: folder.path,
secretId: el.id,
// @ts-expect-error - not present on V1 secrets
secretKey: el.key as string
}))
}
}); });
if (isSoftEnforcement) { if (isSoftEnforcement) {
@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
if (slackConfig) { if (slackConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) { if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
await sendSlackNotification({ await sendSlackNotification({
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
} }
if (microsoftTeamsConfig) { if (microsoftTeamsConfig) {
if (notification.type === TriggerFeature.ACCESS_REQUEST) { if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
microsoftTeamsConfig.accessRequestChannels microsoftTeamsConfig.accessRequestChannels
+18 -1
View File
@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
export enum TriggerFeature { export enum TriggerFeature {
SECRET_APPROVAL = "secret-approval", SECRET_APPROVAL = "secret-approval",
ACCESS_REQUEST = "access-request" ACCESS_REQUEST = "access-request",
ACCESS_REQUEST_UPDATED = "access-request-updated"
} }
export type TNotification = export type TNotification =
@@ -34,6 +35,22 @@ export type TNotification =
approvalUrl: string; approvalUrl: string;
note?: string; note?: string;
}; };
}
| {
type: TriggerFeature.ACCESS_REQUEST_UPDATED;
payload: {
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
projectName: string;
permissions: string[];
approvalUrl: string;
editNote?: string;
editorFullName?: string;
editorEmail?: string;
};
}; };
export type TTriggerWorkflowNotificationDTO = { export type TTriggerWorkflowNotificationDTO = {
+4 -3
View File
@@ -560,8 +560,7 @@ export const registerRoutes = async (
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL, auditLogStreamDAL
eventBusService
}); });
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
@@ -1121,7 +1120,9 @@ export const registerRoutes = async (
resourceMetadataDAL, resourceMetadataDAL,
folderCommitService, folderCommitService,
secretSyncQueue, secretSyncQueue,
reminderService reminderService,
eventBusService,
licenseService
}); });
const projectService = projectServiceFactory({ const projectService = projectServiceFactory({
+1 -10
View File
@@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
email: z.string().email().trim(), email: z.string().email().trim(),
password: z.string().trim(), password: z.string().trim(),
firstName: z.string().trim(), firstName: z.string().trim(),
lastName: z.string().trim().optional(), lastName: z.string().trim().optional()
protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(),
publicKey: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
encryptedPrivateKeyIV: z.string().trim(),
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim()
}), }),
response: { response: {
200: z.object({ 200: z.object({
+8 -10
View File
@@ -5,8 +5,8 @@ import { z } from "zod";
import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { ActionProjectType, ProjectType } from "@app/db/schemas";
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
import { EventRegisterSchema } from "@app/ee/services/event/types"; import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs"; import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
@@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => {
req.body.register.forEach((r) => { req.body.register.forEach((r) => {
const fields = { const fields = {
environment: r.conditions?.environmentSlug ?? "", environment: r.conditions?.environmentSlug ?? "",
secretPath: r.conditions?.secretPath ?? "/", secretPath: r.conditions?.secretPath ?? "/"
eventType: r.event
}; };
const allowed = info.permission.can( const action = Mappings.BusEventToAction(r.event);
ProjectPermissionSecretActions.Subscribe,
subject(ProjectPermissionSub.Secrets, fields) const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields));
);
if (!allowed) { if (!allowed) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
name: "PermissionDenied", name: "PermissionDenied",
message: `You are not allowed to subscribe on secrets`, message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`,
details: { details: {
event: fields.eventType, action,
environmentSlug: fields.environment, environmentSlug: fields.environment,
secretPath: fields.secretPath secretPath: fields.secretPath
} }
@@ -283,6 +283,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit rateLimit: readLimit
}, },
schema: { schema: {
hide: false,
tags: [ApiDocsTags.Projects],
description: "Get project details by slug",
security: [
{
bearerAuth: []
}
],
params: z.object({ params: z.object({
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
}), }),
@@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
return token; return token;
}; };
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
if (!linkHeader) return {};
const links: Record<string, string> = {};
const segments = linkHeader.split(",");
const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/);
for (const segment of segments) {
const match = re.exec(segment.trim());
if (match) {
const url = match[1];
const rel = match[2];
links[rel] = url;
}
}
return links;
};
function extractNextPageUrl(linkHeader: string | undefined): string | null { function extractNextPageUrl(linkHeader: string | undefined): string | null {
if (!linkHeader) return null; const links = parseGitHubLinkHeader(linkHeader);
return links.next || null;
const links = linkHeader.split(",");
const nextLink = links.find((link) => link.includes('rel="next"'));
if (!nextLink) return null;
const match = new RE2(/<([^>]+)>/).exec(nextLink);
return match ? match[1] : null;
} }
export const makePaginatedGitHubRequest = async <T, R = T[]>( export const makePaginatedGitHubRequest = async <T, R = T[]>(
@@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
const token = const token =
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl);
initialUrlObj.searchParams.set("per_page", "100");
let results: T[] = []; let results: T[] = [];
let i = 0; const maxIterations = 1000;
while (url && i < 1000) { // Make initial request to get link header
// eslint-disable-next-line no-await-in-loop const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, { url: initialUrlObj.toString(),
url, method: "GET",
method: "GET", headers: {
headers: { Accept: "application/vnd.github+json",
Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28"
"X-GitHub-Api-Version": "2022-11-28" }
} });
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
results = results.concat(items); results = results.concat(firstPageItems);
url = extractNextPageUrl(response.headers.link as string | undefined); const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
i += 1; const lastPageUrl = linkHeader.last;
// If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last
if (lastPageUrl) {
const lastPageParam = new URL(lastPageUrl).searchParams.get("page");
const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1;
const pageRequests: Promise<AxiosResponse<R>>[] = [];
for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) {
const pageUrlObj = new URL(initialUrlObj.toString());
pageUrlObj.searchParams.set("page", pageNum.toString());
pageRequests.push(
requestWithGitHubGateway<R>(appConnection, gatewayService, {
url: pageUrlObj.toString(),
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
})
);
}
const responses = await Promise.all(pageRequests);
for (const response of responses) {
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
}
} else {
// Fallback in case last link isn't present
let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined);
let i = 1;
while (url && i < maxIterations) {
// eslint-disable-next-line no-await-in-loop
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
url,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
results = results.concat(items);
url = extractNextPageUrl(response.headers.link as string | undefined);
i += 1;
}
} }
return results; return results;
@@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => {
}; };
} }
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const adaptiveCard = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Updated access approval request pending for review",
weight: "Bolder",
size: "Large"
},
{
type: "TextBlock",
text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`,
wrap: true
},
{
type: "TextBlock",
text: `The following permissions are requested: ${payload.permissions.join(", ")}`,
wrap: true
},
payload.editNote
? {
type: "TextBlock",
text: `**Editor Note**: ${payload.editNote}`,
wrap: true
}
: null
].filter(Boolean),
actions: [
{
type: "Action.OpenUrl",
title: "View request in Infisical",
url: payload.approvalUrl
}
]
};
return {
adaptiveCard
};
}
default: { default: {
throw new BadRequestError({ throw new BadRequestError({
message: "Teams notification type not supported." message: "Teams notification type not supported."
@@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({
projectId, projectId,
environmentSlug: environment, environmentSlug: environment,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath,
environment
}
}
}); });
} }
@@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({
projectId, projectId,
environmentSlug: environment, environmentSlug: environment,
actor, actor,
actorId actorId,
event: {
importMutation: {
secretPath,
environment
}
}
}); });
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
@@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({
actorId, actorId,
actor, actor,
projectId, projectId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
created: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
}); });
} }
@@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({
actor, actor,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
updated: {
secretId: secret.id,
environment: folder.environment.slug,
secretKey: secret.key,
secretPath
}
}
}); });
} }
@@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({
actor, actor,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
deleted: {
secretId: secretToDelete.id,
environment: folder.environment.slug,
secretKey: secretToDelete.key,
secretPath
}
}
}); });
} }
@@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
created: newSecrets.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
}); });
return newSecrets.map((el) => { return newSecrets.map((el) => {
@@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath: el.path, secretPath: el.path,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: environment environmentSlug: environment,
event: {
updated: updatedSecrets.map((sec) => ({
secretId: sec.id,
secretKey: sec.key,
secretPath: sec.secretPath,
environment
}))
}
}) })
: undefined : undefined
) )
@@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
orgId: actorOrgId, orgId: actorOrgId,
environmentSlug: folder.environment.slug environmentSlug: folder.environment.slug,
event: {
deleted: secretsDeleted.map((el) => ({
secretId: el.id,
secretKey: el.key,
secretPath,
environment: folder.environment.slug
}))
}
}); });
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: destinationFolder.path, secretPath: destinationFolder.path,
environmentSlug: destinationFolder.environment.slug, environmentSlug: destinationFolder.environment.slug,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
}); });
} }
@@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({
secretPath: sourceFolder.path, secretPath: sourceFolder.path,
environmentSlug: sourceFolder.environment.slug, environmentSlug: sourceFolder.environment.slug,
actorId, actorId,
actor actor,
event: {
importMutation: {
secretPath: sourceFolder.path,
environment: sourceFolder.environment.slug
}
}
}); });
} }
+79 -5
View File
@@ -5,6 +5,7 @@ import { Knex } from "knex";
import { import {
ProjectMembershipRole, ProjectMembershipRole,
ProjectType,
ProjectUpgradeStatus, ProjectUpgradeStatus,
ProjectVersion, ProjectVersion,
SecretType, SecretType,
@@ -12,6 +13,9 @@ import {
TSecretVersionsV2 TSecretVersionsV2
} from "@app/db/schemas"; } from "@app/db/schemas";
import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
@@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = {
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">; folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">; secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">; reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
eventBusService: TEventBusService;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
export type TGetSecrets = { export type TGetSecrets = {
@@ -172,7 +178,9 @@ export const secretQueueFactory = ({
resourceMetadataDAL, resourceMetadataDAL,
secretSyncQueue, secretSyncQueue,
folderCommitService, folderCommitService,
reminderService reminderService,
eventBusService,
licenseService
}: TSecretQueueFactoryDep) => { }: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@@ -534,17 +542,70 @@ export const secretQueueFactory = ({
}); });
}; };
const publishEvents = async (event: PublishableEvent) => {
if (event.created) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.CreateSecret,
payload: event.created
}
});
}
if (event.updated) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.UpdateSecret,
payload: event.updated
}
});
}
if (event.deleted) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.DeleteSecret,
payload: event.deleted
}
});
}
if (event.importMutation) {
await eventBusService.publish(TopicName.CoreServers, {
type: ProjectType.SecretManager,
source: "infiscal",
data: {
event: BusEventName.ImportMutation,
payload: event.importMutation
}
});
}
};
const syncSecrets = async <T extends boolean = false>({ const syncSecrets = async <T extends boolean = false>({
// seperate de-dupe queue for integration sync and replication sync // seperate de-dupe queue for integration sync and replication sync
_deDupeQueue: deDupeQueue = {}, _deDupeQueue: deDupeQueue = {},
_depth: depth = 0, _depth: depth = 0,
_deDupeReplicationQueue: deDupeReplicationQueue = {}, _deDupeReplicationQueue: deDupeReplicationQueue = {},
event,
...dto ...dto
}: TSyncSecretsDTO<T>) => { }: TSyncSecretsDTO<T> & { event?: PublishableEvent }) => {
logger.info( logger.info(
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
); );
const plan = await licenseService.getPlan(dto.orgId);
if (event && plan.eventSubscriptions) {
await publishEvents(event);
}
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
if ( if (
!dto.excludeReplication !dto.excludeReplication
@@ -565,7 +626,7 @@ export const secretQueueFactory = ({
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_deDupeReplicationQueue: deDupeReplicationQueue, _deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth _depth: depth
} as TSyncSecretsDTO, } as unknown as TSyncSecretsDTO,
{ {
removeOnFail: true, removeOnFail: true,
removeOnComplete: true, removeOnComplete: true,
@@ -689,6 +750,7 @@ export const secretQueueFactory = ({
isManual, isManual,
projectId, projectId,
secretPath, secretPath,
depth = 1, depth = 1,
deDupeQueue = {} deDupeQueue = {}
} = job.data as TIntegrationSyncPayload; } = job.data as TIntegrationSyncPayload;
@@ -738,7 +800,13 @@ export const secretQueueFactory = ({
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_depth: depth + 1, _depth: depth + 1,
excludeReplication: true excludeReplication: true,
event: {
importMutation: {
secretPath: foldersGroupedById[folderId][0]?.path as string,
environment: foldersGroupedById[folderId][0]?.environmentSlug as string
}
}
}) })
) )
); );
@@ -791,7 +859,13 @@ export const secretQueueFactory = ({
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue, _deDupeQueue: deDupeQueue,
_depth: depth + 1, _depth: depth + 1,
excludeReplication: true excludeReplication: true,
event: {
importMutation: {
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string
}
}
}) })
) )
); );
+38
View File
@@ -115,6 +115,44 @@ User Note: ${payload.note}`
payloadBlocks payloadBlocks
}; };
} }
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
The following permissions are requested: ${payload.permissions.join(", ")}
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.editNote
? `
Editor Note: ${payload.editNote}`
: ""
}`;
const payloadBlocks = [
{
type: "header",
text: {
type: "plain_text",
text: "Updated access approval request pending for review",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
}
}
];
return {
payloadMessage: messageBody,
payloadBlocks
};
}
default: { default: {
throw new BadRequestError({ throw new BadRequestError({
message: "Slack notification type not supported." message: "Slack notification type not supported."
@@ -0,0 +1,95 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface AccessApprovalRequestUpdatedTemplateProps
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
projectName: string;
requesterFullName: string;
requesterEmail: string;
isTemporary: boolean;
secretPath: string;
environment: string;
expiresIn: string;
permissions: string[];
editNote: string;
editorFullName: string;
editorEmail: string;
approvalUrl: string;
}
export const AccessApprovalRequestUpdatedTemplate = ({
projectName,
siteUrl,
requesterFullName,
requesterEmail,
isTemporary,
secretPath,
environment,
expiresIn,
permissions,
editNote,
editorEmail,
editorFullName,
approvalUrl
}: AccessApprovalRequestUpdatedTemplateProps) => {
return (
<BaseEmailWrapper
title="Access Approval Request Update"
preview="An access approval request was updated and requires your review."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
An access approval request was updated and is pending your review for the project <strong>{projectName}</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
<strong>{editorFullName}</strong> (<BaseLink href={`mailto:${editorEmail}`}>{editorEmail}</BaseLink>) has
updated the access request submitted by <strong>{requesterFullName}</strong> (
<BaseLink href={`mailto:${requesterEmail}`}>{requesterEmail}</BaseLink>) for <strong>{secretPath}</strong> in
the <strong>{environment}</strong> environment.
</Text>
{isTemporary && (
<Text className="text-[14px] text-red-600 leading-[24px]">
<strong>This access will expire {expiresIn} after approval.</strong>
</Text>
)}
<Text className="text-[14px] leading-[24px] mb-[4px]">
<strong>The following permissions are requested:</strong>
</Text>
{permissions.map((permission) => (
<Text key={permission} className="text-[14px] my-[2px] leading-[24px]">
- {permission}
</Text>
))}
<Text className="text-[14px] text-slate-700 leading-[24px]">
<strong className="text-black">Editor Note:</strong> "{editNote}"
</Text>
</Section>
<Section className="text-center">
<BaseButton href={approvalUrl}>Review Request</BaseButton>
</Section>
</BaseEmailWrapper>
);
};
export default AccessApprovalRequestUpdatedTemplate;
AccessApprovalRequestUpdatedTemplate.PreviewProps = {
requesterFullName: "Abigail Williams",
requesterEmail: "[email protected]",
isTemporary: true,
secretPath: "/api/secrets",
environment: "Production",
siteUrl: "https://infisical.com",
projectName: "Example Project",
expiresIn: "1 day",
permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"],
editNote: "Too permissive, they only need 3 days",
editorEmail: "[email protected]",
editorFullName: "John Smith"
} as AccessApprovalRequestUpdatedTemplateProps;
@@ -1,4 +1,5 @@
export * from "./AccessApprovalRequestTemplate"; export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./EmailMfaTemplate"; export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate"; export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate"; export * from "./ExternalImportFailedTemplate";
@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
import { import {
AccessApprovalRequestTemplate, AccessApprovalRequestTemplate,
AccessApprovalRequestUpdatedTemplate,
EmailMfaTemplate, EmailMfaTemplate,
EmailVerificationTemplate, EmailVerificationTemplate,
ExternalImportFailedTemplate, ExternalImportFailedTemplate,
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
EmailMfa = "emailMfa", EmailMfa = "emailMfa",
UnlockAccount = "unlockAccount", UnlockAccount = "unlockAccount",
AccessApprovalRequest = "accessApprovalRequest", AccessApprovalRequest = "accessApprovalRequest",
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
AccessSecretRequestBypassed = "accessSecretRequestBypassed", AccessSecretRequestBypassed = "accessSecretRequestBypassed",
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate,
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate, [SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
@@ -11,7 +11,6 @@ import {
validateOverrides validateOverrides
} from "@app/lib/config/env"; } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
@@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({
return updatedServerCfg; return updatedServerCfg;
}; };
const adminSignUp = async ({ const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => {
lastName,
firstName,
email,
salt,
password,
verifier,
publicKey,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
ip,
userAgent
}: TAdminSignUpDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const sanitizedEmail = email.trim().toLowerCase(); const sanitizedEmail = email.trim().toLowerCase();
const existingUser = await userDAL.findOne({ username: sanitizedEmail }); const existingUser = await userDAL.findOne({ username: sanitizedEmail });
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
const privateKey = await getUserPrivateKey(password, {
encryptionVersion: 2,
salt,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
});
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
const userInfo = await userDAL.transaction(async (tx) => { const userInfo = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create( const newUser = await userDAL.create(
{ {
@@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({
); );
const userEnc = await userDAL.createUserEncryption( const userEnc = await userDAL.createUserEncryption(
{ {
salt,
encryptionVersion: 2, encryptionVersion: 2,
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
verifier,
userId: newUser.id, userId: newUser.id,
hashedPassword, hashedPassword
serverEncryptedPrivateKey: ciphertext,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyEncoding: encoding
}, },
tx tx
); );
return { user: newUser, enc: userEnc }; return { user: newUser, enc: userEnc };
}); });
@@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({
}, },
tx tx
); );
const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password);
const encKeys = await generateUserSrpKeys(sanitizedEmail, password); const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
const userEnc = await userDAL.createUserEncryption( const userEnc = await userDAL.createUserEncryption(
{ {
userId: newUser.id, userId: newUser.id,
encryptionVersion: 2, encryptionVersion: 2,
protectedKey: encKeys.protectedKey, hashedPassword
protectedKeyIV: encKeys.protectedKeyIV,
protectedKeyTag: encKeys.protectedKeyTag,
publicKey: encKeys.publicKey,
encryptedPrivateKey: encKeys.encryptedPrivateKey,
iv: encKeys.encryptedPrivateKeyIV,
tag: encKeys.encryptedPrivateKeyTag,
salt: encKeys.salt,
verifier: encKeys.verifier,
serverEncryptedPrivateKeyEncoding: encoding,
serverEncryptedPrivateKeyTag: tag,
serverEncryptedPrivateKeyIV: iv,
serverEncryptedPrivateKey: ciphertext
}, },
tx tx
); );
@@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env";
export type TAdminSignUpDTO = { export type TAdminSignUpDTO = {
email: string; email: string;
password: string; password: string;
publicKey: string;
salt: string;
lastName?: string; lastName?: string;
verifier: string;
firstName: string; firstName: string;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
ip: string; ip: string;
userAgent: string; userAgent: string;
}; };
@@ -0,0 +1,4 @@
---
title: "Get Project By Slug"
openapi: "GET /api/v2/workspace/{slug}"
---
+2
View File
@@ -439,6 +439,7 @@
"documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/aws-iam",
"documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/azure-entra-id",
"documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/cassandra",
"documentation/platform/dynamic-secrets/couchbase",
"documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/elastic-search",
"documentation/platform/dynamic-secrets/gcp-iam", "documentation/platform/dynamic-secrets/gcp-iam",
"documentation/platform/dynamic-secrets/github", "documentation/platform/dynamic-secrets/github",
@@ -995,6 +996,7 @@
{ {
"group": "Projects", "group": "Projects",
"pages": [ "pages": [
"api-reference/endpoints/workspaces/get-workspace-by-slug",
"api-reference/endpoints/workspaces/create-workspace", "api-reference/endpoints/workspaces/create-workspace",
"api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/delete-workspace",
"api-reference/endpoints/workspaces/get-workspace", "api-reference/endpoints/workspaces/get-workspace",
@@ -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
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

@@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical."
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions - Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
<Note>
For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting.
</Note>
<Tabs> <Tabs>
<Tab title="Infisical UI"> <Tab title="Infisical UI">
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
@@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their
Supports conditions and permission inversion Supports conditions and permission inversion
| Action | Description | Notes | | Action | Description | Notes |
| -------- | ------------------------------- | ----- | | -------- | ------------------------------- | ----- |
| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | | `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. |
| `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | | `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. |
| `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | | `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. |
| `create` | Add new secrets to the project | | | `create` | Add new secrets to the project | |
| `edit` | Modify existing secret values | | | `edit` | Modify existing secret values | |
| `delete` | Remove secrets from the project | | | `delete` | Remove secrets from the project | |
#### Subject: `secret-folders` #### Subject: `secret-folders`
@@ -169,6 +169,15 @@ Supports conditions and permission inversion
| `edit` | Modify secret imports | | `edit` | Modify secret imports |
| `delete` | Remove secret imports | | `delete` | Remove secret imports |
#### Subject: `secret-events`
| Action | Description |
| ------------------------------- | ------------------------------------------------------------- |
| `subscribe-on-created` | Subscribe to events when secrets are created |
| `subscribe-on-updated` | Subscribe to events when secrets are updated |
| `subscribe-on-deleted` | Subscribe to events when secrets are deleted |
| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports |
#### Subject: `secret-rollback` #### Subject: `secret-rollback`
| Action | Description | | Action | Description |
@@ -178,10 +187,10 @@ Supports conditions and permission inversion
#### Subject: `commits` #### Subject: `commits`
| Action | Description | | Action | Description |
| -------- | ---------------------------------- | | ------------------ | --------------------------------------------------------------- |
| `read` | View commits and changes across folders | | `read` | View commits and changes across folders |
| `perform-rollback` | Roll back commits changes and restore folders to previous state| | `perform-rollback` | Roll back commits changes and restore folders to previous state |
#### Subject: `secret-approval` #### Subject: `secret-approval`
@@ -197,14 +206,14 @@ Supports conditions and permission inversion
#### Subject: `secret-rotation` #### Subject: `secret-rotation`
Supports conditions and permission inversion Supports conditions and permission inversion
| Action | Description | | Action | Description |
| ------------------------------ | ---------------------------------------------- | | ------------------------------ | ---------------------------------------------- |
| `read` | View secret rotation configurations | | `read` | View secret rotation configurations |
| `read-generated-credentials` | View the generated credentials of a rotation | | `read-generated-credentials` | View the generated credentials of a rotation |
| `create` | Set up secret rotation configurations | | `create` | Set up secret rotation configurations |
| `edit` | Modify secret rotation configurations | | `edit` | Modify secret rotation configurations |
| `rotate-secrets` | Rotate the generated credentials of a rotation | | `rotate-secrets` | Rotate the generated credentials of a rotation |
| `delete` | Remove secret rotation configurations | | `delete` | Remove secret rotation configurations |
#### Subject: `secret-syncs` #### Subject: `secret-syncs`
@@ -263,12 +272,12 @@ Supports conditions and permission inversion
#### Subject: `certificates` #### Subject: `certificates`
| Action | Description | | Action | Description |
| -------------------- | ----------------------------- | | ------------------ | ----------------------------- |
| `read` | View certificates | | `read` | View certificates |
| `read-private-key` | Read certificate private key | | `read-private-key` | Read certificate private key |
| `create` | Issue new certificates | | `create` | Issue new certificates |
| `delete` | Revoke or remove certificates | | `delete` | Revoke or remove certificates |
#### Subject: `certificate-templates` #### Subject: `certificate-templates`
@@ -330,8 +339,8 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-data-sources` #### Subject: `secret-scanning-data-sources`
| Action | Description | | Action | Description |
| -------- | ---------------------------------------------------- | | ---------------------------- | -------------------------------- |
| `read-data-sources` | View Data Sources | | `read-data-sources` | View Data Sources |
| `create-data-sources` | Create new Data Sources | | `create-data-sources` | Create new Data Sources |
| `edit-data-sources` | Modify Data Sources | | `edit-data-sources` | Modify Data Sources |
@@ -342,15 +351,14 @@ Supports conditions and permission inversion
#### Subject: `secret-scanning-findings` #### Subject: `secret-scanning-findings`
| Action | Description | | Action | Description |
| -------- | --------------------------------- | | ----------------- | ------------------------------- |
| `read-findings` | View Secret Scanning Findings | | `read-findings` | View Secret Scanning Findings |
| `update-findings` | Update Secret Scanning Findings | | `update-findings` | Update Secret Scanning Findings |
#### Subject: `secret-scanning-configs` #### Subject: `secret-scanning-configs`
| Action | Description | | Action | Description |
| ---------------- | ------------------------------------------------ | | ---------------- | -------------------------------------------- |
| `read-configs` | View Secret Scanning Project Configuration | | `read-configs` | View Secret Scanning Project Configuration |
| `update-configs` | Update Secret Scanning Project Configuration | | `update-configs` | Update Secret Scanning Project Configuration |
+2 -3
View File
@@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag
## Cross-Region Replication for Disaster Recovery (Infisical Cloud) ## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed. To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed.
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down. - ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage. - RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues.
With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages.
## Penetration testing ## Penetration testing
+2 -2
View File
@@ -53,8 +53,8 @@
"project-id": "Project ID", "project-id": "Project ID",
"save-changes": "Save Changes", "save-changes": "Save Changes",
"saved": "Saved", "saved": "Saved",
"drop-zone": "Drag and drop a .env, .json, or .yml file here.", "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.",
"drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.",
"role": "Role", "role": "Role",
"role_admin": "admin", "role_admin": "admin",
"display-name": "Display Name", "display-name": "Display Name",
@@ -1,4 +1,4 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2"; import { FormLabel, Tooltip } from "../v2";
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
label={label} label={label}
icon={ icon={
<Tooltip <Tooltip
className="max-w-lg"
content={ content={
<span> <span>
Examples: 30m, 1h, 3d, etc.{" "}
<a <a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples" href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary-700" className="text-primary-500 hover:text-mineshaft-100"
> >
More See More Examples{" "}
<FontAwesomeIcon size="xs" className="mt-0.5" icon={faArrowUpRightFromSquare} />
</a> </a>
</span> </span>
} }
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
<FontAwesomeIcon <FontAwesomeIcon
icon={faQuestionCircle} icon={faQuestionCircle}
size="sm" size="sm"
className="relative bottom-px right-1" className="relative right-1 mt-0.5 text-mineshaft-300"
/> />
</Tooltip> </Tooltip>
} }
@@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) {
return result; return result;
} }
function detectSeparator(csvContent: string): string {
const firstLine = csvContent.split("\n")[0];
const separators = [",", ";", "\t", "|"];
const counts = separators.map((sep) => ({
separator: sep,
count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length
}));
const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max));
return detected.count > 0 ? detected.separator : ",";
}
export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] {
let csvContent: string;
if (typeof src === "string") {
csvContent = src;
} else {
csvContent = new TextDecoder("utf-8").decode(src);
}
const separator = detectSeparator(csvContent);
const lines = csvContent.replace(/\r\n?/g, "\n").split("\n");
const matrix: string[][] = [];
lines.forEach((line) => {
if (line.trim() !== "") {
const cells: string[] = [];
let currentCell = "";
let inQuote = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuote && nextChar === '"') {
currentCell += '"';
i += 1;
} else {
inQuote = !inQuote;
}
} else if (char === separator && !inQuote) {
cells.push(currentCell.trim());
currentCell = "";
} else {
currentCell += char;
}
}
cells.push(currentCell.trim());
matrix.push(cells);
}
});
return matrix;
}
@@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
Update = "update-configs" Update = "update-configs"
} }
export enum ProjectPermissionSecretEventActions {
SubscribeCreated = "subscribe-on-created",
SubscribeUpdated = "subscribe-on-updated",
SubscribeDeleted = "subscribe-on-deleted",
SubscribeImportMutations = "subscribe-on-import-mutations"
}
export enum PermissionConditionOperators { export enum PermissionConditionOperators {
$IN = "$in", $IN = "$in",
$ALL = "$all", $ALL = "$all",
@@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject =
| ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation; | ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.SecretEvents;
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to", [PermissionConditionOperators.$EQ]: "equal to",
@@ -250,7 +258,8 @@ export enum ProjectPermissionSub {
Commits = "commits", Commits = "commits",
SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningDataSources = "secret-scanning-data-sources",
SecretScanningFindings = "secret-scanning-findings", SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs" SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events"
} }
export type SecretSubjectFields = { export type SecretSubjectFields = {
@@ -260,6 +269,14 @@ export type SecretSubjectFields = {
secretTags: string[]; secretTags: string[];
}; };
export type SecretEventSubjectFields = {
environment: string;
secretPath: string;
secretName: string;
secretTags: string[];
action: string;
};
export type SecretFolderSubjectFields = { export type SecretFolderSubjectFields = {
environment: string; environment: string;
secretPath: string; secretPath: string;
@@ -403,6 +420,13 @@ export type ProjectPermissionSet =
ProjectPermissionSub.SecretScanningDataSources ProjectPermissionSub.SecretScanningDataSources
] ]
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
| [
ProjectPermissionSecretEventActions,
(
| ProjectPermissionSub.SecretEvents
| (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
)
];
export type TProjectPermission = MongoAbility<ProjectPermissionSet>; export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
-84
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 };
@@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request";
import { accessApprovalKeys } from "./queries"; import { accessApprovalKeys } from "./queries";
import { import {
TAccessApproval, TAccessApproval,
TAccessApprovalRequest,
TCreateAccessPolicyDTO, TCreateAccessPolicyDTO,
TCreateAccessRequestDTO, TCreateAccessRequestDTO,
TDeleteSecretPolicyDTO, TDeleteSecretPolicyDTO,
TUpdateAccessPolicyDTO TUpdateAccessPolicyDTO,
TUpdateAccessRequestDTO
} from "./types"; } from "./types";
export const useCreateAccessApprovalPolicy = () => { export const useCreateAccessApprovalPolicy = () => {
@@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => {
}); });
}; };
export const useUpdateAccessRequest = () => {
const queryClient = useQueryClient();
return useMutation<TAccessApprovalRequest, object, TUpdateAccessRequestDTO>({
mutationFn: async ({ requestId, ...payload }) => {
const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>(
`/api/v1/access-approvals/requests/${requestId}`,
payload
);
return data.approval;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug)
});
}
});
};
export const useReviewAccessRequest = () => { export const useReviewAccessRequest = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation< return useMutation<
@@ -24,7 +24,7 @@ export const accessApprovalKeys = {
envSlug?: string, envSlug?: string,
requestedBy?: string, requestedBy?: string,
bypassReason?: string bypassReason?: string
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, ) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const,
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) => getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const [{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
}; };
@@ -103,6 +103,8 @@ export type TAccessApprovalRequest = {
}[]; }[];
note?: string; note?: string;
editNote?: string;
editedByUserId?: string;
}; };
export type TAccessApproval = { export type TAccessApproval = {
@@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = {
note?: string; note?: string;
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">; } & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
export type TUpdateAccessRequestDTO = {
requestId: string;
editNote: string;
temporaryRange: string;
projectSlug: string;
};
export type TGetAccessApprovalRequestsDTO = { export type TGetAccessApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
policyId?: string; policyId?: string;
-9
View File
@@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = {
password: string; password: string;
firstName: string; firstName: string;
lastName?: string; lastName?: string;
protectedKey: string;
protectedKeyTag: string;
protectedKeyIV: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
publicKey: string;
verifier: string;
salt: string;
}; };
export type AdminGetOrganizationsFilters = { export type AdminGetOrganizationsFilters = {
@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
); );
return data.dynamicSecret; return data.dynamicSecret;
}, },
onSuccess: (_, { path, environmentSlug, projectSlug }) => { onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
// TODO: optimize but currently don't pass projectId // TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug }) queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
}); });
queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
});
} }
}); });
}; };
+34 -1
View File
@@ -37,7 +37,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes", Kubernetes = "kubernetes",
Vertica = "vertica", Vertica = "vertica",
GcpIam = "gcp-iam", GcpIam = "gcp-iam",
Github = "github" Github = "github",
Couchbase = "couchbase"
} }
export enum KubernetesDynamicSecretCredentialType { export enum KubernetesDynamicSecretCredentialType {
@@ -353,6 +354,38 @@ export type TDynamicSecretProvider =
installationId: number; installationId: number;
privateKey: string; privateKey: string;
}; };
}
| {
type: DynamicSecretProviders.Couchbase;
inputs: {
url: string;
orgId: string;
projectId: string;
clusterId: string;
roles: string[];
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>;
passwordRequirements?: {
length: number;
required: {
lowercase: number;
uppercase: number;
digits: number;
symbols: number;
};
allowedSymbols?: string;
};
auth: {
apiKey: string;
};
};
}; };
export type TCreateDynamicSecretDTO = { export type TCreateDynamicSecretDTO = {
-77
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
};
};
@@ -12,7 +12,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context"; import { useServerConfig } from "@app/context";
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api"; import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
import { generateUserPassKey } from "@app/lib/crypto";
const formSchema = z const formSchema = z
.object({ .object({
@@ -48,17 +47,11 @@ export const SignUpPage = () => {
// avoid multi submission // avoid multi submission
if (isSubmitting) return; if (isSubmitting) return;
try { try {
const { privateKey, ...userPass } = await generateUserPassKey(
email,
password,
config.fipsEnabled
);
const res = await createAdminUser({ const res = await createAdminUser({
email, email,
password, password,
firstName, firstName,
lastName, lastName
...userPass
}); });
SecurityClient.setToken(res.token); SecurityClient.setToken(res.token);
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { SingleValue } from "react-select"; import { SingleValue } from "react-select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
enabled: caType === CaType.ACME enabled: caType === CaType.ACME
}); });
const availableConnections: TAvailableAppConnection[] = [ const availableConnections: TAvailableAppConnection[] = useMemo(
...(availableRoute53Connections || []), () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])],
...(availableCloudflareConnections || []) [availableRoute53Connections, availableCloudflareConnections]
]; );
const isPending = isRoute53Pending || isCloudflarePending; const isPending = isRoute53Pending || isCloudflarePending;
@@ -21,6 +21,7 @@ import {
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
ProjectPermissionSecretRotationActions, ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningConfigActions,
ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSecretScanningDataSourceActions,
@@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional() [ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
}); });
const SecretEventsPolicyActionSchema = z.object({
[ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(),
[ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(),
[ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(),
[ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional()
});
const SecretRollbackPolicyActionSchema = z.object({ const SecretRollbackPolicyActionSchema = z.object({
read: z.boolean().optional(), read: z.boolean().optional(),
create: z.boolean().optional() create: z.boolean().optional()
@@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.SecretScanningFindings]: [ProjectPermissionSub.SecretScanningFindings]:
SecretScanningFindingPolicyActionSchema.array().default([]), SecretScanningFindingPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretScanningConfigs]: [ProjectPermissionSub.SecretScanningConfigs]:
SecretScanningConfigPolicyActionSchema.array().default([]) SecretScanningConfigPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({
conditions: ConditionSchema
})
.array()
.default([])
}) })
.partial() .partial()
.optional() .optional()
@@ -374,7 +387,8 @@ type TConditionalFields =
| ProjectPermissionSub.SshHosts | ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation | ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity | ProjectPermissionSub.Identity
| ProjectPermissionSub.SecretSyncs; | ProjectPermissionSub.SecretSyncs
| ProjectPermissionSub.SecretEvents;
export const isConditionalSubjects = ( export const isConditionalSubjects = (
subject: ProjectPermissionSub subject: ProjectPermissionSub
@@ -388,7 +402,8 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers || subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates || subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.SecretSyncs; subject === ProjectPermissionSub.SecretSyncs ||
subject === ProjectPermissionSub.SecretEvents;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = []; const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshHostGroups, ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.SecretSyncs ProjectPermissionSub.SecretSyncs,
ProjectPermissionSub.SecretEvents
].includes(subject) ].includes(subject)
) { ) {
// from above statement we are sure it won't be undefined // from above statement we are sure it won't be undefined
@@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return; return;
} }
if (subject === ProjectPermissionSub.SecretEvents) {
const canSubscribeCreate = action.includes(
ProjectPermissionSecretEventActions.SubscribeCreated
);
const canSubscribeUpdate = action.includes(
ProjectPermissionSecretEventActions.SubscribeUpdated
);
const canSubscribeDelete = action.includes(
ProjectPermissionSecretEventActions.SubscribeDeleted
);
const canSubscribeImportMutations = action.includes(
ProjectPermissionSecretEventActions.SubscribeImportMutations
);
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
"subscribe-on-created": canSubscribeCreate,
"subscribe-on-deleted": canSubscribeDelete,
"subscribe-on-updated": canSubscribeUpdate,
"subscribe-on-import-mutations": canSubscribeImportMutations,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : []
});
return;
}
// for other subjects // for other subjects
const canRead = action.includes(ProjectPermissionActions.Read); const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit); const canEdit = action.includes(ProjectPermissionActions.Edit);
@@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue }, { label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
{ label: "Modify", value: ProjectPermissionSecretActions.Edit }, { label: "Modify", value: ProjectPermissionSecretActions.Edit },
{ label: "Remove", value: ProjectPermissionSecretActions.Delete }, { label: "Remove", value: ProjectPermissionSecretActions.Delete },
{ label: "Create", value: ProjectPermissionSecretActions.Create }, { label: "Create", value: ProjectPermissionSecretActions.Create }
{ label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe }
] ]
}, },
[ProjectPermissionSub.SecretFolders]: { [ProjectPermissionSub.SecretFolders]: {
@@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
value: ProjectPermissionSecretScanningConfigActions.Update value: ProjectPermissionSecretScanningConfigActions.Update
} }
] ]
},
[ProjectPermissionSub.SecretEvents]: {
title: "Secret Events",
actions: [
{
label: "Subscribe on Created",
value: ProjectPermissionSecretEventActions.SubscribeCreated
},
{
label: "Subscribe on Deleted",
value: ProjectPermissionSecretEventActions.SubscribeDeleted
},
{
label: "Subscribe on Updated",
value: ProjectPermissionSecretEventActions.SubscribeUpdated
},
{
label: "Subscribe on Import Mutations",
value: ProjectPermissionSecretEventActions.SubscribeImportMutations
}
]
} }
}; };
@@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({
[ProjectPermissionSub.SecretRollback]: enabled, [ProjectPermissionSub.SecretRollback]: enabled,
[ProjectPermissionSub.SecretRotation]: enabled, [ProjectPermissionSub.SecretRotation]: enabled,
[ProjectPermissionSub.ServiceTokens]: enabled, [ProjectPermissionSub.ServiceTokens]: enabled,
[ProjectPermissionSub.Commits]: enabled [ProjectPermissionSub.Commits]: enabled,
[ProjectPermissionSub.SecretEvents]: enabled
}); });
const KmsPermissionSubjects = (enabled = false) => ({ const KmsPermissionSubjects = (enabled = false) => ({
@@ -32,6 +32,7 @@ import {
rolePermission2Form, rolePermission2Form,
TFormSchema TFormSchema
} from "./ProjectRoleModifySection.utils"; } from "./ProjectRoleModifySection.utils";
import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions";
import { SecretPermissionConditions } from "./SecretPermissionConditions"; import { SecretPermissionConditions } from "./SecretPermissionConditions";
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
@@ -72,6 +73,10 @@ export const renderConditionalComponents = (
return <SecretSyncPermissionConditions isDisabled={isDisabled} />; return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
} }
if (subject === ProjectPermissionSub.SecretEvents) {
return <SecretEventPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />; return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
} }
@@ -0,0 +1,22 @@
import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types";
import { ConditionsFields } from "./ConditionsFields";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => {
return (
<ConditionsFields
isDisabled={isDisabled}
subject={ProjectPermissionSub.SecretEvents}
position={position}
selectOptions={[
{ value: "environment", label: "Environment Slug" },
{ value: "secretPath", label: "Secret Path" }
]}
/>
);
};
@@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
{ value: "environment", label: "Environment Slug" }, { value: "environment", label: "Environment Slug" },
{ value: "secretPath", label: "Secret Path" }, { value: "secretPath", label: "Secret Path" },
{ value: "secretName", label: "Secret Name" }, { value: "secretName", label: "Secret Name" },
{ value: "secretTags", label: "Secret Tags" }, { value: "secretTags", label: "Secret Tags" }
{ value: "eventType", label: "Event Type" }
]} ]}
/> />
); );
@@ -10,6 +10,7 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useSubscription,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
@@ -51,6 +52,7 @@ export const SelectionPanel = ({
usedBySecretSyncs = [] usedBySecretSyncs = []
}: Props) => { }: Props) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"bulkDeleteEntries", "bulkDeleteEntries",
@@ -101,6 +103,16 @@ export const SelectionPanel = ({
return "Do you want to delete the selected folders across environments?"; return "Do you want to delete the selected folders across environments?";
}; };
const getDeleteModalSubTitle = () => {
if (selectedFolderCount > 0) {
if (subscription?.pitRecovery) {
return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit.";
}
return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature.";
}
return undefined;
};
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
let processedEntries = 0; let processedEntries = 0;
@@ -279,6 +291,7 @@ export const SelectionPanel = ({
isOpen={popUp.bulkDeleteEntries.isOpen} isOpen={popUp.bulkDeleteEntries.isOpen}
deleteKey="delete" deleteKey="delete"
title={getDeleteModalTitle()} title={getDeleteModalTitle()}
subTitle={getDeleteModalSubTitle()}
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete} onDeleteApproved={handleBulkDelete}
formContent={ formContent={
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
setSelectedRequest(null); setSelectedRequest(null);
refetchRequests(); refetchRequests();
}} }}
onUpdate={(request) => {
// scott: this isn't ideal but our current use of state makes this complicated...
// we shouldn't be using state like this...
handleSelectRequest({
...selectedRequest,
isTemporary: request.isTemporary,
temporaryRange: request.temporaryRange,
reviewers: []
});
}}
canBypass={generateRequestDetails(selectedRequest).canBypass} canBypass={generateRequestDetails(selectedRequest).canBypass}
/> />
)} )}
@@ -0,0 +1,185 @@
import { Controller, useForm } from "react-hook-form";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalClose,
ModalContent,
TextArea
} from "@app/components/v2";
import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation";
import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
type ContentProps = {
accessRequest: TAccessApprovalRequest;
onComplete: (request: TAccessApprovalRequest) => void;
projectSlug: string;
};
const EditSchema = z.object({
temporaryRange: z
.string()
.nonempty("Required")
.transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
editNote: z.string().nonempty("Required")
});
type FormData = z.infer<typeof EditSchema>;
const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => {
const update = useUpdateAccessRequest();
const queryClient = useQueryClient();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({
resolver: zodResolver(EditSchema),
defaultValues: {
temporaryRange: accessRequest.temporaryRange ?? "1h",
editNote: ""
}
});
const onSubmit = async (form: FormData) => {
try {
const request = await update.mutateAsync({
requestId: accessRequest.id,
projectSlug,
...form
});
await queryClient.refetchQueries({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
});
createNotification({
type: "success",
text: "Access request updated successfully."
});
onComplete(request);
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update access request"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex w-full items-start rounded-md border border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
<FontAwesomeIcon icon={faWarning} className="mr-2.5 mt-1 text-base text-yellow" />
Updating this access request will restart the review process and require all approvers to
re-approve it.
</div>
<Controller
control={control}
defaultValue="1h"
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Access Duration" />}
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="editNote"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Reason for Editing"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
className="!resize-none"
rows={4}
{...field}
placeholder="Provide a reason for updating this request..."
/>
</FormControl>
)}
/>
<div className="mt-4 flex gap-x-2">
<Button
type="submit"
variant="outline_bg"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Update Request
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
accessRequest?: TAccessApprovalRequest;
onComplete: (request: TAccessApprovalRequest) => void;
projectSlug: string;
};
export const EditAccessRequestModal = ({
isOpen,
onOpenChange,
accessRequest,
onComplete,
projectSlug
}: Props) => {
if (!accessRequest) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Edit Access Request"
subTitle="Modify this access request for re-approval."
>
<Content
projectSlug={projectSlug}
accessRequest={accessRequest}
onComplete={(request) => {
onComplete(request);
onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
import { import {
faBan, faBan,
faCheck, faCheck,
faEdit,
faHourglass, faHourglass,
faTriangleExclamation faTriangleExclamation
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -15,6 +16,7 @@ import {
Checkbox, Checkbox,
FormControl, FormControl,
GenericFieldLabel, GenericFieldLabel,
IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
@@ -22,6 +24,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context"; import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api"; import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
import { import {
Approver, Approver,
@@ -32,6 +35,7 @@ import {
import { EnforcementLevel } from "@app/hooks/api/policies/enums"; import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { groupBy } from "@app/lib/fn/array"; import { groupBy } from "@app/lib/fn/array";
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => { const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED) if (status === ApprovalStatus.APPROVED)
@@ -62,7 +66,8 @@ export const ReviewAccessRequestModal = ({
selectedEnvSlug, selectedEnvSlug,
canBypass, canBypass,
policies = [], policies = [],
members = [] members = [],
onUpdate
}: { }: {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
@@ -78,6 +83,7 @@ export const ReviewAccessRequestModal = ({
canBypass: boolean; canBypass: boolean;
policies: TAccessApprovalPolicy[]; policies: TAccessApprovalPolicy[];
members: TWorkspaceUser[]; members: TWorkspaceUser[];
onUpdate: (request: TAccessApprovalRequest) => void;
}) => { }) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false); const [bypassApproval, setBypassApproval] = useState(false);
@@ -86,6 +92,8 @@ export const ReviewAccessRequestModal = ({
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || ""); const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { user } = useUser(); const { user } = useUser();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const accessDetails = { const accessDetails = {
@@ -121,16 +129,9 @@ export const ReviewAccessRequestModal = ({
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
return "Permanent"; return "Permanent";
// convert the range to human readable format return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); long: true
})} after approval`;
return (
<Badge>
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
long: true
})} after approval`}
</Badge>
);
}; };
const reviewAccessRequest = useReviewAccessRequest(); const reviewAccessRequest = useReviewAccessRequest();
@@ -286,7 +287,33 @@ export const ReviewAccessRequestModal = ({
<GenericFieldLabel truncate label="Secret Path"> <GenericFieldLabel truncate label="Secret Path">
{accessDetails.secretPath} {accessDetails.secretPath}
</GenericFieldLabel> </GenericFieldLabel>
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel> <GenericFieldLabel label="Access Duration">
<div className="flex h-min gap-1">
{getAccessLabel()}
{request.isApprover && request.status === ApprovalStatus.PENDING && (
<>
<EditAccessRequestModal
isOpen={popUp.editRequest.isOpen}
onOpenChange={(open) => handlePopUpToggle("editRequest", open)}
accessRequest={request}
onComplete={onUpdate}
projectSlug={projectSlug}
/>
<Tooltip content="Edit Access Duration">
<IconButton
onClick={() => handlePopUpOpen("editRequest")}
variant="plain"
size="xs"
tabIndex={-1}
ariaLabel="Edit access duration"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</>
)}
</div>
</GenericFieldLabel>
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel> <GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
{request.note && ( {request.note && (
<GenericFieldLabel className="col-span-full" label="Note"> <GenericFieldLabel className="col-span-full" label="Note">
@@ -92,7 +92,7 @@ const formSchema = z
.array() .array()
.default([]) .default([])
.optional(), .optional(),
maxTimePeriod: z.string().trim().optional() maxTimePeriod: z.string().trim().nullish()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.policyType === PolicyType.ChangePolicy) { if (data.policyType === PolicyType.ChangePolicy) {
@@ -454,7 +454,7 @@ const Form = ({
errorText={error?.message} errorText={error?.message}
className="flex-shrink" className="flex-shrink"
> >
<Input {...field} value={field.value} placeholder="permanent" /> <Input {...field} value={field.value || ""} placeholder="permanent" />
</FormControl> </FormControl>
)} )}
/> />
@@ -328,12 +328,12 @@ const Page = () => {
createNotification({ createNotification({
text: isProtectedBranch text: isProtectedBranch
? "Requested changes have been sent for review" ? "Requested changes have been sent for review"
: "Changes committed successfully", : "Changes saved successfully",
type: "success" type: "success"
}); });
} catch (error) { } catch (error) {
createNotification({ createNotification({
text: "Failed to commit changes", text: "Failed to save changes",
type: "error" type: "error"
}); });
console.error(error); console.error(error);
@@ -14,6 +14,7 @@ import {
faFingerprint, faFingerprint,
faFolder, faFolder,
faFolderPlus, faFolderPlus,
faInfoCircle,
faKey, faKey,
faLock, faLock,
faPaste, faPaste,
@@ -32,6 +33,7 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2"; import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
import { import {
Badge,
Button, Button,
DeleteActionModal, DeleteActionModal,
DropdownMenu, DropdownMenu,
@@ -70,10 +72,7 @@ import {
useMoveSecrets, useMoveSecrets,
useUpdateSecretBatch useUpdateSecretBatch
} from "@app/hooks/api"; } from "@app/hooks/api";
import { import { dashboardKeys, fetchDashboardProjectSecretsByKeys } from "@app/hooks/api/dashboard/queries";
dashboardKeys,
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { PendingAction } from "@app/hooks/api/secretFolders/types";
@@ -665,6 +664,8 @@ export const ActionBar = ({
const isTableFiltered = const isTableFiltered =
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean); Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
const filteredTags = Object.values(filter?.tags ?? {}).filter(Boolean).length;
return ( return (
<> <>
<div className="mt-4 flex items-center space-x-2"> <div className="mt-4 flex items-center space-x-2">
@@ -697,7 +698,7 @@ export const ActionBar = ({
Filters Filters
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0"> <DropdownMenuContent align="end" sideOffset={2} className="p-0">
<DropdownMenuGroup>Filter By</DropdownMenuGroup> <DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -776,11 +777,19 @@ export const ActionBar = ({
iconPos="right" iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />} icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
> >
Tags <div className="flex w-full justify-between">
<span>Tags</span>
{Boolean(filteredTags) && <Badge>{filteredTags} Applied</Badge>}
</div>
</DropdownSubMenuTrigger> </DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none"> <DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900"> <DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Tags to Filter Secrets <div className="flex w-full items-center justify-between">
<span>Filter by Secret Tags</span>
<Tooltip content="Matches secrets with one or more of the applied tags">
<FontAwesomeIcon icon={faInfoCircle} />
</Tooltip>
</div>
</DropdownMenuLabel> </DropdownMenuLabel>
{tags.map(({ id, slug, color }) => ( {tags.map(({ id, slug, color }) => (
<DropdownMenuItem <DropdownMenuItem
@@ -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>
);
};
@@ -2,6 +2,7 @@ import { useState } from "react";
import { DiRedis } from "react-icons/di"; import { DiRedis } from "react-icons/di";
import { import {
SiApachecassandra, SiApachecassandra,
SiCouchbase,
SiElasticsearch, SiElasticsearch,
SiFiles, SiFiles,
SiKubernetes, SiKubernetes,
@@ -29,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm"; import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm"; import { CassandraInputForm } from "./CassandraInputForm";
import { CouchbaseInputForm } from "./CouchbaseInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { GcpIamInputForm } from "./GcpIamInputForm"; import { GcpIamInputForm } from "./GcpIamInputForm";
import { GithubInputForm } from "./GithubInputForm"; import { GithubInputForm } from "./GithubInputForm";
@@ -154,6 +156,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <FontAwesomeIcon icon={faGithub} size="lg" />, icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
provider: DynamicSecretProviders.Github, provider: DynamicSecretProviders.Github,
title: "GitHub" title: "GitHub"
},
{
icon: <SiCouchbase size="1.5rem" />,
provider: DynamicSecretProviders.Couchbase,
title: "Couchbase"
} }
]; ];
@@ -608,6 +615,25 @@ export const CreateDynamicSecretForm = ({
/> />
</motion.div> </motion.div>
)} )}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Couchbase && (
<motion.div
key="dynamic-couchbase-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<CouchbaseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,6 +1,12 @@
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { faCodeCommit, faEye, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; import {
faClipboardCheck,
faCodeCommit,
faFolder,
faKey,
faSave
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2"; import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
@@ -328,12 +334,12 @@ export const CommitForm: React.FC<CommitFormProps> = ({
</Button> </Button>
<Button <Button
variant="solid" variant="solid"
leftIcon={<FontAwesomeIcon icon={faEye} />} leftIcon={<FontAwesomeIcon icon={faSave} />}
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
isDisabled={totalChangesCount === 0} isDisabled={totalChangesCount === 0}
className="px-6" className="px-6"
> >
Review Changes Save Changes
</Button> </Button>
</div> </div>
</div> </div>
@@ -345,14 +351,14 @@ export const CommitForm: React.FC<CommitFormProps> = ({
<ModalContent <ModalContent
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" /> <FontAwesomeIcon icon={faClipboardCheck} className="text-mineshaft-400" />
Commit Changes Review Changes
<Badge variant="primary" className="mt-[0.05rem]"> <Badge variant="primary" className="mt-[0.05rem]">
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
</Badge> </Badge>
</div> </div>
} }
subTitle={"Write a commit message and review the changes you&apos;re about to commit."} subTitle="Write a commit message and review the changes you're about to save."
className="max-h-[90vh] max-w-[95%] md:max-w-7xl" className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
> >
<div className="space-y-6"> <div className="space-y-6">
@@ -413,6 +419,7 @@ export const CommitForm: React.FC<CommitFormProps> = ({
onChange={(e) => setCommitMessage(e.target.value)} onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Describe your changes..." placeholder="Describe your changes..."
className="w-full" className="w-full"
autoFocus
required required
/> />
</div> </div>
@@ -435,7 +442,7 @@ export const CommitForm: React.FC<CommitFormProps> = ({
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
> >
{isCommitting ? "Committing..." : "Commit Changes"} {isCommitting ? "Saving..." : "Save Changes"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -384,6 +384,24 @@ const renderOutputForm = (
); );
} }
if (provider === DynamicSecretProviders.Couchbase) {
const { username, password } = data as {
username: string;
password: string;
};
return (
<div>
<OutputDisplay label="Username" value={username} />
<OutputDisplay
label="Password"
value={password}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null; return null;
}; };
@@ -0,0 +1,929 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
IconButton,
Input,
SecretInput,
Switch,
Tooltip
} from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { slugSchema } from "@app/lib/schemas";
import { MetadataForm } from "../MetadataForm";
const BucketScopesConfiguration = ({
control,
bucketIndex,
bucketsValue,
setValue,
addScope,
removeScope,
addCollection,
removeCollection
}: {
control: any;
bucketIndex: number;
bucketsValue: any;
setValue: any;
addScope: (bucketIndex: number) => void;
removeScope: (bucketIndex: number, scopeIndex: number) => void;
addCollection: (bucketIndex: number, scopeIndex: number) => void;
removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void;
}) => {
const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null;
const scopeFields = bucket?.scopes || [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-mineshaft-300">Scopes</label>
<Button
type="button"
variant="outline_bg"
size="xs"
onClick={() => addScope(bucketIndex)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Scope
</Button>
</div>
{scopeFields.map((_scope: any, scopeIndex: number) => (
<div
key={`scope-${scopeIndex + 1}`}
className="space-y-3 rounded border border-mineshaft-600 bg-mineshaft-700 p-3"
>
<div className="flex items-center justify-between">
<h5 className="text-xs font-medium text-mineshaft-200">Scope {scopeIndex + 1}</h5>
<IconButton
type="button"
variant="plain"
ariaLabel="Remove scope"
size="sm"
onClick={() => removeScope(bucketIndex, scopeIndex)}
>
<FontAwesomeIcon icon={faTrash} className="text-red-400" />
</IconButton>
</div>
<Controller
control={control}
name={`inputs.buckets.${bucketIndex}.scopes.${scopeIndex}.name`}
render={({ field, fieldState: { error } }) => (
<FormControl label="Scope Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="e.g., inventory, _default" className="text-sm" />
</FormControl>
)}
/>
<div className="space-y-2 pl-4">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-mineshaft-300">Collections</label>
<Button
type="button"
variant="outline_bg"
size="xs"
onClick={() => addCollection(bucketIndex, scopeIndex)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Collection
</Button>
</div>
{scopeFields[scopeIndex]?.collections?.map(
(collection: string, collectionIndex: number) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
className="flex items-center space-x-2"
>
<FormControl className="flex-1">
<Input
value={collection || ""}
onChange={(e) => {
const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : [];
if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) {
currentBuckets[bucketIndex].scopes[scopeIndex].collections[
collectionIndex
] = e.target.value;
setValue("inputs.buckets", currentBuckets);
}
}}
placeholder="e.g., airport, airline"
className="text-sm"
/>
</FormControl>
<IconButton
type="button"
variant="plain"
ariaLabel="Remove collection"
size="sm"
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
>
<FontAwesomeIcon icon={faTrash} className="mb-4 text-red-400" />
</IconButton>
</div>
)
)}
{(!scopeFields[scopeIndex]?.collections ||
scopeFields[scopeIndex].collections.length === 0) && (
<div className="text-xs italic text-mineshaft-400">
No collections specified (access to all collections in scope)
</div>
)}
</div>
</div>
))}
{scopeFields.length === 0 && (
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-700 p-4 text-center">
<p className="mb-2 text-xs text-mineshaft-400">
No scopes configured (access to all scopes in bucket)
</p>
<Button
type="button"
variant="outline_bg"
size="xs"
onClick={() => addScope(bucketIndex)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Scope
</Button>
</div>
)}
</div>
);
};
const couchbaseRoles = [
{ value: "read", label: "Read", description: "Read-only access to bucket data" },
{
value: "write",
label: "Write",
description: "Full write access to bucket data"
}
];
const passwordRequirementsSchema = z
.object({
length: z.number().min(8, "Password must be at least 8 characters").max(128),
required: z
.object({
lowercase: z.number().min(1, "At least 1 lowercase character required"),
uppercase: z.number().min(1, "At least 1 uppercase character required"),
digits: z.number().min(1, "At least 1 digit required"),
symbols: z.number().min(1, "At least 1 special character required")
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128;
}, "Sum of required characters cannot exceed 128"),
allowedSymbols: z
.string()
.refine((symbols) => {
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "�"];
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: < > ; . * & | �">
<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="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: < > ; . * & | �"
>
<Input {...field} placeholder="!@#$%^()_+-=[]{}:,?/~`" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -8,6 +8,7 @@ import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecret
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm"; import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm"; import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm"; import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm"; import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
@@ -384,6 +385,23 @@ export const EditDynamicSecretForm = ({
/> />
</motion.div> </motion.div>
)} )}
{dynamicSecretDetails?.type === DynamicSecretProviders.Couchbase && (
<motion.div
key="couchbase-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretCouchbaseForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
); );
}; };
@@ -16,7 +16,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2"; import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip"; import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types"; import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
@@ -54,6 +54,7 @@ export const FolderListView = ({
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id, from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
select: (el) => el.secretPath select: (el) => el.secretPath
}); });
const { subscription } = useSubscription();
const { mutateAsync: updateFolder } = useUpdateFolder(); const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder(); const { mutateAsync: deleteFolder } = useDeleteFolder();
@@ -319,6 +320,11 @@ export const FolderListView = ({
isOpen={popUp.deleteFolder.isOpen} isOpen={popUp.deleteFolder.isOpen}
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name} deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
title="Do you want to delete this folder?" title="Do you want to delete this folder?"
subTitle={`This folder and all its contents will be removed. ${
subscription?.pitRecovery
? "You can reverse this action by rolling back to a previous commit."
: "Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature."
}`}
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)} onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
onDeleteApproved={handleFolderDelete} onDeleteApproved={handleFolderDelete}
/> />
@@ -1,7 +1,14 @@
import { ChangeEvent, DragEvent } from "react"; import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons"; import {
faArrowRight,
faAsterisk,
faComment,
faKey,
faPlus,
faUpload
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality // TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
import { parseDotEnv, parseJson, parseYaml } from "@app/components/utilities/parseSecrets"; import {
import { Button, Lottie, Modal, ModalContent } from "@app/components/v2"; parseCsvToMatrix,
parseDotEnv,
parseJson,
parseYaml
} from "@app/components/utilities/parseSecrets";
import {
Badge,
Button,
FormLabel,
Lottie,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
@@ -38,6 +59,84 @@ type Props = {
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
}; };
type SecretMatrixMap = {
key: number;
value: number | null;
comment: number | null;
};
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const;
const MatrixImportModalTableRow = ({
importSecretMatrixMap,
setImportSecretMatrixMap,
headers,
mapKey
}: {
importSecretMatrixMap: SecretMatrixMap;
setImportSecretMatrixMap: Dispatch<SetStateAction<SecretMatrixMap>>;
headers: string[];
mapKey: keyof SecretMatrixMap;
}) => {
return (
<tr>
<td className="w-full">
<Select
value={importSecretMatrixMap[mapKey]?.toString() || (null as unknown as string)}
onValueChange={(v) =>
setImportSecretMatrixMap((ism) => ({
...ism,
[mapKey]: v ? parseInt(v, 10) : null
}))
}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{mapKey !== "key" && <SelectItem value={null as unknown as string}>None</SelectItem>}
{headers.map((header, col) => {
return (
<SelectItem value={col.toString()} key={`${mapKey}-${header}`}>
{header}
</SelectItem>
);
})}
</Select>
</td>
<td className="whitespace-nowrap pl-5 pr-5">
<div className="flex items-center justify-center">
<FontAwesomeIcon className="text-mineshaft-400" icon={faArrowRight} />
</div>
</td>
<td className="whitespace-nowrap">
<div className="flex h-full items-start justify-center">
<Badge className="pointer-events-none flex h-[36px] w-full items-center justify-center gap-1.5 whitespace-nowrap border border-mineshaft-600 bg-mineshaft-600 text-bunker-200">
{mapKey === "key" && (
<>
<FontAwesomeIcon icon={faKey} />
<span>Secret Key</span>
</>
)}
{mapKey === "value" && (
<>
<FontAwesomeIcon icon={faAsterisk} />
<span>Secret Value</span>
</>
)}
{mapKey === "comment" && (
<>
<FontAwesomeIcon icon={faComment} />
<span>Comment</span>
</>
)}
</Badge>
</div>
</td>
</tr>
);
};
export const SecretDropzone = ({ export const SecretDropzone = ({
isSmaller, isSmaller,
environments = [], environments = [],
@@ -50,11 +149,14 @@ export const SecretDropzone = ({
const [isDragActive, setDragActive] = useToggle(); const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle(); const [isLoading, setIsLoading] = useToggle();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ // Maps matrix columns to parts of a secret
"importSecEnv", const [importSecretMatrixMap, setImportSecretMatrixMap] = useState<SecretMatrixMap>({
"confirmUpload", key: 0,
"pasteSecEnv" value: null,
] as const); comment: null
});
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openPopUp } = usePopUpAction(); const { openPopUp } = usePopUpAction();
@@ -136,10 +238,17 @@ export const SecretDropzone = ({
}); });
return; return;
} }
// const fileType = file.name.split('.')[1];
setIsLoading.on(); setIsLoading.on();
reader.onload = (event) => { reader.onload = (event) => {
if (!event?.target?.result) return; if (!event?.target?.result) {
createNotification({
type: "error",
text: "Invalid file contents."
});
setIsLoading.off();
return;
}
let env: TParsedEnv; let env: TParsedEnv;
@@ -154,7 +263,22 @@ export const SecretDropzone = ({
case "application/yaml": case "application/yaml":
env = parseYaml(src); env = parseYaml(src);
break; break;
case "text/csv": {
const fullMatrix = parseCsvToMatrix(src);
if (!fullMatrix.length) {
createNotification({
type: "error",
text: "Failed to find secrets in CSV file. File might be empty."
});
setIsLoading.off();
return;
}
const headers = fullMatrix[0];
const matrix = fullMatrix.slice(1);
handlePopUpOpen("importMatrixMap", { headers, matrix });
setIsLoading.off();
return;
}
default: default:
env = parseDotEnv(src); env = parseDotEnv(src);
break; break;
@@ -171,6 +295,22 @@ export const SecretDropzone = ({
} }
}; };
const finishMappedMatrixImport = (matrix: string[][]) => {
const env: TParsedEnv = {};
matrix.forEach((row) => {
const key = row[importSecretMatrixMap.key];
if (key) {
env[key] = {
value: importSecretMatrixMap.value ? row[importSecretMatrixMap.value] : "",
comments: importSecretMatrixMap.comment ? [row[importSecretMatrixMap.comment]] : []
};
}
});
handlePopUpClose("importMatrixMap");
setImportSecretMatrixMap({ key: 0, value: null, comment: null });
handleParsedEnv(env);
};
const handleDrop = (e: DragEvent) => { const handleDrop = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -293,7 +433,7 @@ export const SecretDropzone = ({
disabled={!isAllowed} disabled={!isAllowed}
type="file" type="file"
className="absolute h-full w-full cursor-pointer opacity-0" className="absolute h-full w-full cursor-pointer opacity-0"
accept=".txt,.env,.yml,.yaml,.json" accept=".txt,.env,.yml,.yaml,.json,.csv"
onChange={handleFileUpload} onChange={handleFileUpload}
/> />
)} )}
@@ -407,6 +547,75 @@ export const SecretDropzone = ({
)} )}
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Matrix Import Modal */}
<Modal
isOpen={popUp?.importMatrixMap?.isOpen}
onOpenChange={(open) => handlePopUpToggle("importMatrixMap", open)}
>
<ModalContent
title="Import Column Mapping"
subTitle="Map your data columns to different parts of the secret"
>
<div className="w-full overflow-hidden">
<table className="w-full table-auto">
<thead>
<tr className="text-left">
<th>
<FormLabel tooltipClassName="max-w-sm" label="Import Column" />
</th>
<th />
<th className="whitespace-nowrap">
<FormLabel label="Resulting Import" />
</th>
</tr>
</thead>
<tbody>
{/* Key */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="key"
/>
{/* Value */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="value"
/>
{/* Comment */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="comment"
/>
</tbody>
</table>
</div>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
onClick={() =>
popUp.importMatrixMap.data?.matrix
? finishMappedMatrixImport(popUp.importMatrixMap.data?.matrix)
: createNotification({
text: "Invalid secret matrix.",
type: "error"
})
}
isFullWidth
variant="outline_bg"
>
Import Secrets
</Button>
</div>
</ModalContent>
</Modal>
</div> </div>
); );
}; };