diff --git a/backend/src/db/migrations/20251203002657_global-approvals.ts b/backend/src/db/migrations/20251203002657_global-approvals.ts index 7bfeabf81..51610f879 100644 --- a/backend/src/db/migrations/20251203002657_global-approvals.ts +++ b/backend/src/db/migrations/20251203002657_global-approvals.ts @@ -19,7 +19,7 @@ export async function up(knex: Knex): Promise { t.boolean("isActive").defaultTo(true); - t.integer("maxRequestTtlSeconds").nullable(); + t.string("maxRequestTtl").nullable(); // 1hour, 30seconds, etc t.jsonb("conditions").notNullable(); t.jsonb("constraints").notNullable(); @@ -71,11 +71,11 @@ export async function up(knex: Knex): Promise { t.uuid("organizationId").notNullable().index(); t.foreign("organizationId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.uuid("policyId").notNullable().index(); - t.foreign("policyId").references("id").inTable(TableName.ApprovalPolicies).onDelete("CASCADE"); + t.uuid("policyId").nullable().index(); + t.foreign("policyId").references("id").inTable(TableName.ApprovalPolicies).onDelete("SET NULL"); - t.uuid("requesterId").notNullable().index(); - t.foreign("requesterId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("requesterId").nullable().index(); + t.foreign("requesterId").references("id").inTable(TableName.Users).onDelete("SET NULL"); // To be used in the event of requester deletion t.string("requesterName").notNullable(); @@ -156,11 +156,11 @@ export async function up(knex: Knex): Promise { t.string("projectId").notNullable().index(); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - t.uuid("requestId").notNullable().index(); - t.foreign("requestId").references("id").inTable(TableName.ApprovalRequests).onDelete("CASCADE"); + t.uuid("requestId").nullable().index(); + t.foreign("requestId").references("id").inTable(TableName.ApprovalRequests).onDelete("SET NULL"); - t.uuid("granteeUserId").notNullable().index(); - t.foreign("granteeUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("granteeUserId").nullable().index(); + t.foreign("granteeUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); t.uuid("revokedByUserId").nullable().index(); t.foreign("revokedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); diff --git a/backend/src/db/schemas/approval-policies.ts b/backend/src/db/schemas/approval-policies.ts index a69012e31..d7f8fe8da 100644 --- a/backend/src/db/schemas/approval-policies.ts +++ b/backend/src/db/schemas/approval-policies.ts @@ -14,7 +14,7 @@ export const ApprovalPoliciesSchema = z.object({ type: z.string(), name: z.string(), isActive: z.boolean().default(true).nullable().optional(), - maxRequestTtlSeconds: z.number().nullable().optional(), + maxRequestTtl: z.string().nullable().optional(), conditions: z.unknown(), constraints: z.unknown(), createdAt: z.date(), diff --git a/backend/src/db/schemas/approval-request-grants.ts b/backend/src/db/schemas/approval-request-grants.ts index 7bf7703a6..5056a5b1d 100644 --- a/backend/src/db/schemas/approval-request-grants.ts +++ b/backend/src/db/schemas/approval-request-grants.ts @@ -10,8 +10,8 @@ import { TImmutableDBKeys } from "./models"; export const ApprovalRequestGrantsSchema = z.object({ id: z.string().uuid(), projectId: z.string(), - requestId: z.string().uuid(), - granteeUserId: z.string().uuid(), + requestId: z.string().uuid().nullable().optional(), + granteeUserId: z.string().uuid().nullable().optional(), revokedByUserId: z.string().uuid().nullable().optional(), revocationReason: z.string().nullable().optional(), status: z.string(), diff --git a/backend/src/db/schemas/approval-requests.ts b/backend/src/db/schemas/approval-requests.ts index 13b4a1ed8..c5d53fdf8 100644 --- a/backend/src/db/schemas/approval-requests.ts +++ b/backend/src/db/schemas/approval-requests.ts @@ -11,8 +11,8 @@ export const ApprovalRequestsSchema = z.object({ id: z.string().uuid(), projectId: z.string(), organizationId: z.string().uuid(), - policyId: z.string().uuid(), - requesterId: z.string().uuid(), + policyId: z.string().uuid().nullable().optional(), + requesterId: z.string().uuid().nullable().optional(), requesterName: z.string(), requesterEmail: z.string(), type: z.string(), diff --git a/backend/src/services/approval-policy/approval-policy-schemas.ts b/backend/src/services/approval-policy/approval-policy-schemas.ts index 16aa854b4..0dd5dd7a9 100644 --- a/backend/src/services/approval-policy/approval-policy-schemas.ts +++ b/backend/src/services/approval-policy/approval-policy-schemas.ts @@ -8,6 +8,7 @@ import { } from "@app/db/schemas"; import { ApproverType } from "./approval-policy-enums"; +import { ms } from "@app/lib/ms"; const ApprovalPolicyStepSchema = z.object({ name: z.string().min(1).max(128).nullable().optional(), @@ -21,6 +22,16 @@ const ApprovalPolicyStepSchema = z.object({ .array() }); +const MaxRequestTtlSchema = z.string().refine( + (val) => { + const duration = ms(val) / 1000; + + // 1 hour to 30 days + return duration >= 3600 && duration <= 2592000; + }, + { message: "Duration must be between 1 hour and 30 days" } +); + // Policy export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({ steps: ApprovalPolicyStepSchema.array() @@ -29,13 +40,13 @@ export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({ export const BaseCreateApprovalPolicySchema = z.object({ projectId: z.string().uuid(), name: z.string().min(1).max(128), - maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days + maxRequestTtl: MaxRequestTtlSchema.nullable().optional(), steps: ApprovalPolicyStepSchema.array() }); export const BaseUpdateApprovalPolicySchema = z.object({ name: z.string().min(1).max(128).optional(), - maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days + maxRequestTtlSeconds: MaxRequestTtlSchema.nullable().optional(), steps: ApprovalPolicyStepSchema.array().optional() }); @@ -64,5 +75,17 @@ export const BaseApprovalRequestSchema = ApprovalRequestsSchema.extend({ export const BaseCreateApprovalRequestSchema = z.object({ projectId: z.string().uuid(), justification: z.string().max(256).nullable().optional(), - expiresAt: z.coerce.date().nullable().optional() + requestDuration: z + .string() + .refine( + (val) => { + const duration = ms(val) / 1000; + + // 1 minute to 30 days + return duration >= 60 && duration <= 2592000; + }, + { message: "Duration must be between 1 minute and 30 days" } + ) + .nullable() + .optional() }); diff --git a/backend/src/services/approval-policy/approval-policy-service.ts b/backend/src/services/approval-policy/approval-policy-service.ts index 8c2dc4168..cde7dd839 100644 --- a/backend/src/services/approval-policy/approval-policy-service.ts +++ b/backend/src/services/approval-policy/approval-policy-service.ts @@ -31,6 +31,7 @@ import { TCreateRequestDTO, TUpdatePolicyDTO } from "./approval-policy-types"; +import { ms } from "@app/lib/ms"; type TApprovalPolicyServiceFactoryDep = { approvalPolicyDAL: TApprovalPolicyDALFactory; @@ -106,7 +107,7 @@ export const approvalPolicyServiceFactory = ({ const create = async ( policyType: ApprovalPolicyType, - { projectId, name, maxRequestTtlSeconds, conditions, constraints, steps }: TCreatePolicyDTO, + { projectId, name, maxRequestTtl, conditions, constraints, steps }: TCreatePolicyDTO, actor: OrgServiceActor ) => { const { hasRole } = await permissionService.getProjectPermission({ @@ -135,7 +136,7 @@ export const approvalPolicyServiceFactory = ({ projectId, organizationId: actor.orgId, name, - maxRequestTtlSeconds, + maxRequestTtl, conditions: { version: 1, conditions }, constraints: { version: 1, constraints }, type: policyType @@ -227,7 +228,7 @@ export const approvalPolicyServiceFactory = ({ const updateById = async ( policyId: string, - { name, maxRequestTtlSeconds, conditions, constraints, steps }: TUpdatePolicyDTO, + { name, maxRequestTtl, conditions, constraints, steps }: TUpdatePolicyDTO, actor: OrgServiceActor ) => { const policy = await approvalPolicyDAL.findById(policyId); @@ -264,8 +265,8 @@ export const approvalPolicyServiceFactory = ({ updateDoc.name = name; } - if (maxRequestTtlSeconds !== undefined) { - updateDoc.maxRequestTtlSeconds = maxRequestTtlSeconds; + if (maxRequestTtl !== undefined) { + updateDoc.maxRequestTtl = maxRequestTtl; } if (conditions !== undefined) { @@ -352,7 +353,7 @@ export const approvalPolicyServiceFactory = ({ { projectId, requestData, - expiresAt, + requestDuration, justification, requesterName, requesterEmail @@ -376,18 +377,20 @@ export const approvalPolicyServiceFactory = ({ throw new ForbiddenRequestError({ message: "Policy constraints not met" }); } - if (expiresAt) { - const now = new Date(); - const ttlSeconds = (new Date(expiresAt).getTime() - now.getTime()) / 1000; + let expiresAt: Date | undefined; - if (ttlSeconds < 3600) { - throw new BadRequestError({ message: "Expiration time must be at least 1 hour in the future" }); - } + if (requestDuration) { + const ttlMs = ms(requestDuration); - if (policy.maxRequestTtlSeconds && ttlSeconds > policy.maxRequestTtlSeconds) { - throw new BadRequestError({ - message: `Expiration time exceeds the maximum allowed TTL of ${policy.maxRequestTtlSeconds} seconds` - }); + expiresAt = new Date(Date.now() + ttlMs); + + if (policy.maxRequestTtl) { + const maxTtlMs = ms(policy.maxRequestTtl); + if (ttlMs > maxTtlMs) { + throw new BadRequestError({ + message: `Expiration time exceeds the maximum allowed TTL of ${policy.maxRequestTtl}` + }); + } } } diff --git a/backend/src/services/approval-policy/approval-policy-types.ts b/backend/src/services/approval-policy/approval-policy-types.ts index 471e8dc1d..560b06c40 100644 --- a/backend/src/services/approval-policy/approval-policy-types.ts +++ b/backend/src/services/approval-policy/approval-policy-types.ts @@ -35,7 +35,7 @@ export interface ApprovalPolicyStep { export interface TCreatePolicyDTO { projectId: TApprovalPolicy["projectId"]; name: TApprovalPolicy["name"]; - maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"]; + maxRequestTtl?: TApprovalPolicy["maxRequestTtl"]; conditions: TApprovalPolicy["conditions"]["conditions"]; constraints: TApprovalPolicy["constraints"]["constraints"]; steps: ApprovalPolicyStep[]; @@ -43,7 +43,7 @@ export interface TCreatePolicyDTO { export interface TUpdatePolicyDTO { name?: TApprovalPolicy["name"]; - maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"]; + maxRequestTtl?: TApprovalPolicy["maxRequestTtl"]; conditions?: TApprovalPolicy["conditions"]["conditions"]; constraints?: TApprovalPolicy["constraints"]["constraints"]; steps?: ApprovalPolicyStep[]; @@ -54,7 +54,7 @@ export interface TCreateRequestDTO { projectId: TApprovalRequest["projectId"]; requestData: TApprovalRequest["requestData"]["requestData"]; justification?: TApprovalRequest["justification"]; - expiresAt?: TApprovalRequest["expiresAt"]; + requestDuration?: string; } // Factory diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts index a40372daa..bae6206bb 100644 --- a/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts @@ -9,6 +9,7 @@ import { TApprovalResourceFactory } from "../approval-policy-types"; import { TPamAccessPolicy, TPamAccessPolicyInputs, TPamAccessRequestData } from "./pam-access-policy-types"; +import { ms } from "@app/lib/ms"; export const pamAccessPolicyFactory: TApprovalResourceFactory< TPamAccessPolicyInputs, @@ -84,10 +85,10 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory< policy, inputs ) => { - const reqDuration = inputs.requestDurationSeconds; - const durationConstraint = policy.constraints.constraints.requestDurationSeconds; + const reqDuration = ms(inputs.accessDuration); + const durationConstraint = policy.constraints.constraints.accessDuration; - return reqDuration >= durationConstraint.min && reqDuration <= durationConstraint.max; + return reqDuration >= ms(durationConstraint.min) && reqDuration <= ms(durationConstraint.max); }; const postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine = async (_request) => { diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts index 1dd679bad..a72758d2c 100644 --- a/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ms } from "@app/lib/ms"; import { BaseApprovalPolicySchema, @@ -22,11 +23,21 @@ export const PamAccessPolicyConditionsSchema = z }) .array(); +const DurationSchema = z.string().refine( + (val) => { + const duration = ms(val) / 1000; + + // 30 seconds to 7 days + return duration >= 30 && duration <= 604800; + }, + { message: "Duration must be between 30 seconds and 7 days" } +); + // Constraints export const PamAccessPolicyConstraintsSchema = z.object({ - requestDurationSeconds: z.object({ - min: z.number().min(30).max(604800), - max: z.number().min(30).max(604800) // 30 seconds to 7 days + accessDuration: z.object({ + min: DurationSchema, + max: DurationSchema }) }); @@ -34,7 +45,7 @@ export const PamAccessPolicyConstraintsSchema = z.object({ export const PamAccessPolicyRequestDataSchema = z.object({ resourceId: z.string().uuid(), accountPath: z.string(), - requestDurationSeconds: z.number().min(30).max(604800) // 30 seconds to 7 days + accessDuration: DurationSchema }); // Policy