Merge branch 'heads/main' into daniel/scim-deprovisioning-ui

This commit is contained in:
Daniel Hougaard
2025-08-13 02:23:33 +04:00
21 changed files with 487 additions and 48 deletions
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas/models";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod"))) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.string("maxTimePeriod").nullable(); // Ex: 1h - Null is permanent
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod")) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.dropColumn("maxTimePeriod");
});
}
}
@@ -17,7 +17,8 @@ export const AccessApprovalPoliciesSchema = z.object({
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional(), deletedAt: z.date().nullable().optional(),
allowedSelfApprovals: z.boolean().default(true) allowedSelfApprovals: z.boolean().default(true),
maxTimePeriod: z.string().nullable().optional()
}); });
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>; export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
@@ -3,12 +3,32 @@ import { z } from "zod";
import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
import { EnforcementLevel } from "@app/lib/types"; import { EnforcementLevel } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, 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 { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
const maxTimePeriodSchema = z
.string()
.trim()
.nullish()
.transform((val, ctx) => {
if (val === undefined) return undefined;
if (!val || val === "permanent") return null;
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;
});
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
url: "/", url: "/",
@@ -71,7 +91,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.optional(), .optional(),
approvals: z.number().min(1).default(1), approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true) allowedSelfApprovals: z.boolean().default(true),
maxTimePeriod: maxTimePeriodSchema
}) })
.refine( .refine(
(val) => Boolean(val.environment) || Boolean(val.environments), (val) => Boolean(val.environment) || Boolean(val.environments),
@@ -124,7 +145,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.nullable() .nullable()
.optional(), .optional(),
bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array(),
maxTimePeriod: z.string().nullable().optional()
}) })
.array() .array()
.nullable() .nullable()
@@ -233,7 +255,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
stepNumber: z.number().int() stepNumber: z.number().int()
}) })
.array() .array()
.optional() .optional(),
maxTimePeriod: maxTimePeriodSchema
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -314,7 +337,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
}) })
.array() .array()
.nullable() .nullable()
.optional() .optional(),
maxTimePeriod: z.string().nullable().optional()
}) })
}) })
} }
@@ -129,7 +129,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
envId: z.string(), envId: z.string(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish(), deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean() allowedSelfApprovals: z.boolean(),
maxTimePeriod: z.string().nullable().optional()
}), }),
reviewers: z reviewers: z
.object({ .object({
@@ -56,6 +56,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
projectId: string; projectId: string;
bypassers: ( bypassers: (
| { | {
@@ -96,6 +97,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environments: { environments: {
id: string; id: string;
name: string; name: string;
@@ -141,6 +143,7 @@ export interface TAccessApprovalPolicyDALFactory
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
} }
| undefined | undefined
>; >;
@@ -100,7 +100,8 @@ export const accessApprovalPolicyServiceFactory = ({
environments, environments,
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired approvalsRequired,
maxTimePeriod
}) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -219,7 +220,8 @@ export const accessApprovalPolicyServiceFactory = ({
secretPath, secretPath,
name, name,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
maxTimePeriod
}, },
tx tx
); );
@@ -318,7 +320,8 @@ export const accessApprovalPolicyServiceFactory = ({
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired, approvalsRequired,
environments environments,
maxTimePeriod
}: TUpdateAccessApprovalPolicy) => { }: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
@@ -461,7 +464,8 @@ export const accessApprovalPolicyServiceFactory = ({
secretPath, secretPath,
name, name,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
maxTimePeriod
}, },
tx tx
); );
@@ -41,6 +41,7 @@ export type TCreateAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
maxTimePeriod?: string | null;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = {
@@ -60,6 +61,7 @@ export type TUpdateAccessApprovalPolicy = {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
environments?: string[]; environments?: string[];
maxTimePeriod?: string | null;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = { export type TDeleteAccessApprovalPolicy = {
@@ -104,7 +106,8 @@ export interface TAccessApprovalPolicyServiceFactory {
environment, environment,
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired approvalsRequired,
maxTimePeriod
}: TCreateAccessApprovalPolicy) => Promise<{ }: TCreateAccessApprovalPolicy) => Promise<{
environment: { environment: {
name: string; name: string;
@@ -135,6 +138,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
}>; }>;
deleteAccessApprovalPolicy: ({ deleteAccessApprovalPolicy: ({
policyId, policyId,
@@ -159,6 +163,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: { environment: {
id: string; id: string;
name: string; name: string;
@@ -185,7 +190,8 @@ export interface TAccessApprovalPolicyServiceFactory {
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired, approvalsRequired,
environments environments,
maxTimePeriod
}: TUpdateAccessApprovalPolicy) => Promise<{ }: TUpdateAccessApprovalPolicy) => Promise<{
environment: { environment: {
id: string; id: string;
@@ -208,6 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath?: string | null | undefined; secretPath?: string | null | undefined;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
}>; }>;
getAccessApprovalPolicyByProjectSlug: ({ getAccessApprovalPolicyByProjectSlug: ({
actorId, actorId,
@@ -242,6 +249,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: { environment: {
id: string; id: string;
name: string; name: string;
@@ -298,6 +306,7 @@ export interface TAccessApprovalPolicyServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
secretPath: string; secretPath: string;
deletedAt?: Date | null | undefined; deletedAt?: Date | null | undefined;
maxTimePeriod?: string | null;
environment: { environment: {
id: string; id: string;
name: string; name: string;
@@ -64,6 +64,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
enforcementLevel: string; enforcementLevel: string;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
deletedAt: Date | null | undefined; deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
}; };
projectId: string; projectId: string;
environments: string[]; environments: string[];
@@ -164,6 +165,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
envId: string; envId: string;
deletedAt: Date | null | undefined; deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
}; };
projectId: string; projectId: string;
environment: string; environment: string;
@@ -292,7 +294,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
`requestedByUser.id` `requestedByUser.id`
) )
// I added these 3:
.leftJoin<TOrgMemberships>( .leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"), db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.AccessApprovalPolicyApprover}.approverUserId`,
@@ -324,10 +325,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
// Added:
db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive") db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
) )
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence")) .select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
@@ -394,7 +395,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
enforcementLevel: doc.policyEnforcementLevel, enforcementLevel: doc.policyEnforcementLevel,
allowedSelfApprovals: doc.policyAllowedSelfApprovals, allowedSelfApprovals: doc.policyAllowedSelfApprovals,
envId: doc.policyEnvId, envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt deletedAt: doc.policyDeletedAt,
maxTimePeriod: doc.policyMaxTimePeriod
}, },
requestedByUser: { requestedByUser: {
userId: doc.requestedByUserId, userId: doc.requestedByUserId,
@@ -615,7 +617,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
tx.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
); );
const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => { const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => {
@@ -636,7 +639,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
allowedSelfApprovals: el.policyAllowedSelfApprovals, allowedSelfApprovals: el.policyAllowedSelfApprovals,
deletedAt: el.policyDeletedAt deletedAt: el.policyDeletedAt,
maxTimePeriod: el.policyMaxTimePeriod
}, },
requestedByUser: { requestedByUser: {
userId: el.requestedByUserId, userId: el.requestedByUserId,
@@ -156,6 +156,15 @@ export const accessApprovalRequestServiceFactory = ({
throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); throw new BadRequestError({ message: "The policy linked to this request has been deleted" });
} }
// Check if the requested time falls under policy.maxTimePeriod
if (policy.maxTimePeriod) {
if (!temporaryRange || ms(temporaryRange) > ms(policy.maxTimePeriod)) {
throw new BadRequestError({
message: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`
});
}
}
const approverIds: string[] = []; const approverIds: string[] = [];
const approverGroupIds: string[] = []; const approverGroupIds: string[] = [];
@@ -84,6 +84,7 @@ export interface TAccessApprovalRequestServiceFactory {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
envId: string; envId: string;
deletedAt: Date | null | undefined; deletedAt: Date | null | undefined;
maxTimePeriod?: string | null;
}; };
projectId: string; projectId: string;
environment: string; environment: string;
@@ -45,7 +45,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash) .transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.path) .describe(FOLDERS.CREATE.path)
.optional(), .optional(),
// backward compatiability with cli // backward compatibility with cli
directory: z directory: z
.string() .string()
.trim() .trim()
@@ -58,7 +58,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
}), }),
response: { response: {
200: z.object({ 200: z.object({
folder: SecretFoldersSchema folder: SecretFoldersSchema.extend({
path: z.string()
})
}) })
} }
}, },
@@ -130,7 +132,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash) .transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.path) .describe(FOLDERS.UPDATE.path)
.optional(), .optional(),
// backward compatiability with cli // backward compatibility with cli
directory: z directory: z
.string() .string()
.trim() .trim()
@@ -143,7 +145,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
}), }),
response: { response: {
200: z.object({ 200: z.object({
folder: SecretFoldersSchema folder: SecretFoldersSchema.extend({
path: z.string()
})
}) })
} }
}, },
@@ -359,7 +363,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.transform(removeTrailingSlash) .transform(removeTrailingSlash)
.describe(FOLDERS.LIST.path) .describe(FOLDERS.LIST.path)
.optional(), .optional(),
// backward compatiability with cli // backward compatibility with cli
directory: z directory: z
.string() .string()
.trim() .trim()
@@ -49,6 +49,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
} }
const init = async () => { const init = async () => {
await queueService.stopRepeatableJob(
QueueName.AuditLogPrune,
QueueJobs.AuditLogPrune,
{ pattern: "0 0 * * *", utc: true },
QueueName.AuditLogPrune // just a job id
);
await queueService.stopRepeatableJob(
QueueName.DailyResourceCleanUp,
QueueJobs.DailyResourceCleanUp,
{ pattern: "0 0 * * *", utc: true },
QueueName.DailyResourceCleanUp // just a job id
);
await queueService.startPg<QueueName.DailyResourceCleanUp>( await queueService.startPg<QueueName.DailyResourceCleanUp>(
QueueJobs.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp,
async () => { async () => {
@@ -238,8 +238,16 @@ export const secretFolderServiceFactory = ({
return doc; return doc;
}); });
const [folderWithFullPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]);
if (!folderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${folder.id}'`
});
}
await snapshotService.performSnapshot(folder.parentId as string); await snapshotService.performSnapshot(folder.parentId as string);
return folder; return { ...folder, path: folderWithFullPath.path };
}; };
const updateManyFolders = async ({ const updateManyFolders = async ({
@@ -496,8 +504,27 @@ export const secretFolderServiceFactory = ({
return doc; return doc;
}); });
const foldersWithFullPaths = await folderDAL.findSecretPathByFolderIds(projectId, [newFolder.id, folder.id]);
const newFolderWithFullPath = foldersWithFullPaths.find((f) => f?.id === newFolder.id);
if (!newFolderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${newFolder.id}'`
});
}
const folderWithFullPath = foldersWithFullPaths.find((f) => f?.id === folder.id);
if (!folderWithFullPath) {
throw new NotFoundError({
message: `Failed to retrieve path for folder with ID '${folder.id}'`
});
}
await snapshotService.performSnapshot(newFolder.parentId as string); await snapshotService.performSnapshot(newFolder.parentId as string);
return { folder: newFolder, old: folder }; return {
folder: { ...newFolder, path: newFolderWithFullPath.path },
old: { ...folder, path: folderWithFullPath.path }
};
}; };
const $checkFolderPolicy = async ({ const $checkFolderPolicy = async ({
+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",
@@ -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;
}
@@ -26,7 +26,8 @@ export const useCreateAccessApprovalPolicy = () => {
secretPath, secretPath,
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired approvalsRequired,
maxTimePeriod
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environments, environments,
@@ -38,7 +39,8 @@ export const useCreateAccessApprovalPolicy = () => {
name, name,
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired approvalsRequired,
maxTimePeriod
}); });
return data; return data;
}, },
@@ -64,7 +66,8 @@ export const useUpdateAccessApprovalPolicy = () => {
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired, approvalsRequired,
environments environments,
maxTimePeriod
}) => { }) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals, approvals,
@@ -75,7 +78,8 @@ export const useUpdateAccessApprovalPolicy = () => {
enforcementLevel, enforcementLevel,
allowedSelfApprovals, allowedSelfApprovals,
approvalsRequired, approvalsRequired,
environments environments,
maxTimePeriod
}); });
return data; return data;
}, },
@@ -18,6 +18,7 @@ export type TAccessApprovalPolicy = {
approvers?: Approver[]; approvers?: Approver[];
bypassers?: Bypasser[]; bypassers?: Bypasser[];
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
maxTimePeriod?: string | null;
}; };
export enum ApproverType { export enum ApproverType {
@@ -95,6 +96,7 @@ export type TAccessApprovalRequest = {
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
deletedAt: Date | null; deletedAt: Date | null;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
maxTimePeriod?: string | null;
}; };
reviewers: { reviewers: {
@@ -176,6 +178,7 @@ export type TCreateAccessPolicyDTO = {
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
maxTimePeriod?: string | null;
}; };
export type TUpdateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = {
@@ -191,6 +194,7 @@ export type TUpdateAccessPolicyDTO = {
// for invalidating list // for invalidating list
projectSlug: string; projectSlug: string;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
maxTimePeriod?: string | null;
}; };
export type TDeleteSecretPolicyDTO = { export type TDeleteSecretPolicyDTO = {
@@ -220,6 +220,24 @@ export const SpecificPrivilegeSecretForm = ({
return; return;
} }
const policy = policies.find(
(p) =>
p.environments.find((e) => e.slug === selectedEnvironment) && p.secretPath === secretPath
);
if (
policy?.maxTimePeriod &&
(!data.temporaryAccess.isTemporary ||
ms(data.temporaryAccess.temporaryRange) > ms(policy.maxTimePeriod))
) {
createNotification({
type: "error",
text: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`,
title: "Error"
});
return;
}
const actions = [ const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read }, { action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create }, { action: ProjectPermissionActions.Create, allowed: data.create },
@@ -91,7 +91,8 @@ const formSchema = z
}) })
.array() .array()
.default([]) .default([])
.optional() .optional(),
maxTimePeriod: z.string().trim().optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.policyType === PolicyType.ChangePolicy) { if (data.policyType === PolicyType.ChangePolicy) {
@@ -444,6 +445,25 @@ const Form = ({
</FormControl> </FormControl>
)} )}
/> />
{isAccessPolicyType && (
<Controller
control={control}
name="maxTimePeriod"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max. Time Period"
tooltipText="The maximum amount of time someone can request access for. Ex: 1h, 3w, 30d"
isError={Boolean(error)}
errorText={error?.message}
className="flex-shrink"
>
<Input {...field} value={field.value} placeholder="permanent" />
</FormControl>
)}
/>
)}
{!isAccessPolicyType && ( {!isAccessPolicyType && (
<Controller <Controller
control={control} control={control}
@@ -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>
); );
}; };
@@ -27,7 +27,10 @@ import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; import {
hasSecretReference,
SecretReferenceTree
} from "@app/components/secrets/SecretReferenceDetails";
import { import {
Button, Button,
Drawer, Drawer,
@@ -49,8 +52,12 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { getProjectBaseURL } from "@app/helpers/project"; import { getProjectBaseURL } from "@app/helpers/project";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";