diff --git a/.infisicalignore b/.infisicalignore
index d5cc9f15d..855047fe4 100644
--- a/.infisicalignore
+++ b/.infisicalignore
@@ -4,3 +4,4 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/Identit
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292
docs/self-hosting/configuration/envars.mdx:generic-api-key:106
+frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index b3e9d9952..71a7c887c 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -1,6 +1,8 @@
import "fastify";
import { TUsers } from "@app/db/schemas";
+import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
+import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
@@ -113,6 +115,8 @@ declare module "fastify" {
identityAccessToken: TIdentityAccessTokenServiceFactory;
identityProject: TIdentityProjectServiceFactory;
identityUa: TIdentityUaServiceFactory;
+ accessApprovalPolicy: TAccessApprovalPolicyServiceFactory;
+ accessApprovalRequest: TAccessApprovalRequestServiceFactory;
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
secretApprovalRequest: TSecretApprovalRequestServiceFactory;
secretRotation: TSecretRotationServiceFactory;
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index a7d76e944..14ebd4295 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -2,6 +2,18 @@ import { Knex } from "knex";
import {
TableName,
+ TAccessApprovalPolicies,
+ TAccessApprovalPoliciesApprovers,
+ TAccessApprovalPoliciesApproversInsert,
+ TAccessApprovalPoliciesApproversUpdate,
+ TAccessApprovalPoliciesInsert,
+ TAccessApprovalPoliciesUpdate,
+ TAccessApprovalRequests,
+ TAccessApprovalRequestsInsert,
+ TAccessApprovalRequestsReviewers,
+ TAccessApprovalRequestsReviewersInsert,
+ TAccessApprovalRequestsReviewersUpdate,
+ TAccessApprovalRequestsUpdate,
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
@@ -344,6 +356,31 @@ declare module "knex/types/tables" {
TIdentityProjectAdditionalPrivilegeInsert,
TIdentityProjectAdditionalPrivilegeUpdate
>;
+
+ [TableName.AccessApprovalPolicy]: Knex.CompositeTableType<
+ TAccessApprovalPolicies,
+ TAccessApprovalPoliciesInsert,
+ TAccessApprovalPoliciesUpdate
+ >;
+
+ [TableName.AccessApprovalPolicyApprover]: Knex.CompositeTableType<
+ TAccessApprovalPoliciesApprovers,
+ TAccessApprovalPoliciesApproversInsert,
+ TAccessApprovalPoliciesApproversUpdate
+ >;
+
+ [TableName.AccessApprovalRequest]: Knex.CompositeTableType<
+ TAccessApprovalRequests,
+ TAccessApprovalRequestsInsert,
+ TAccessApprovalRequestsUpdate
+ >;
+
+ [TableName.AccessApprovalRequestReviewer]: Knex.CompositeTableType<
+ TAccessApprovalRequestsReviewers,
+ TAccessApprovalRequestsReviewersInsert,
+ TAccessApprovalRequestsReviewersUpdate
+ >;
+
[TableName.ScimToken]: Knex.CompositeTableType;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies,
diff --git a/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts b/backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts
similarity index 100%
rename from backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts
rename to backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts
diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts
new file mode 100644
index 000000000..feeecd25b
--- /dev/null
+++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts
@@ -0,0 +1,41 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicy))) {
+ await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.string("name").notNullable();
+ t.integer("approvals").defaultTo(1).notNullable();
+ t.string("secretPath");
+
+ t.uuid("envId").notNullable();
+ t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
+ t.timestamps(true, true, true);
+ });
+ await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
+ }
+
+ if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) {
+ await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.uuid("approverId").notNullable();
+ t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
+
+ t.uuid("policyId").notNullable();
+ t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE");
+ t.timestamps(true, true, true);
+ });
+ await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover);
+ await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy);
+
+ await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
+ await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
+}
diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts
new file mode 100644
index 000000000..901be9a78
--- /dev/null
+++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts
@@ -0,0 +1,51 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.AccessApprovalRequest))) {
+ await knex.schema.createTable(TableName.AccessApprovalRequest, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+
+ t.uuid("policyId").notNullable();
+ t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE");
+
+ t.uuid("privilegeId").nullable();
+ t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE");
+
+ t.uuid("requestedBy").notNullable();
+ t.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
+
+ // We use these values to create the actual privilege at a later point in time.
+ t.boolean("isTemporary").notNullable();
+ t.string("temporaryRange").nullable();
+
+ t.jsonb("permissions").notNullable();
+
+ t.timestamps(true, true, true);
+ });
+ }
+ await createOnUpdateTrigger(knex, TableName.AccessApprovalRequest);
+
+ if (!(await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer))) {
+ await knex.schema.createTable(TableName.AccessApprovalRequestReviewer, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.uuid("member").notNullable();
+ t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
+ t.string("status").notNullable();
+ t.uuid("requestId").notNullable();
+ t.foreign("requestId").references("id").inTable(TableName.AccessApprovalRequest).onDelete("CASCADE");
+ t.timestamps(true, true, true);
+ });
+ }
+ await createOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer);
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer);
+ await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest);
+
+ await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer);
+ await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest);
+}
diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts
new file mode 100644
index 000000000..4ebbfa9ae
--- /dev/null
+++ b/backend/src/db/schemas/access-approval-policies-approvers.ts
@@ -0,0 +1,25 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const AccessApprovalPoliciesApproversSchema = z.object({
+ id: z.string().uuid(),
+ approverId: z.string().uuid(),
+ policyId: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TAccessApprovalPoliciesApprovers = z.infer;
+export type TAccessApprovalPoliciesApproversInsert = Omit<
+ z.input,
+ TImmutableDBKeys
+>;
+export type TAccessApprovalPoliciesApproversUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts
new file mode 100644
index 000000000..bf7e74ff2
--- /dev/null
+++ b/backend/src/db/schemas/access-approval-policies.ts
@@ -0,0 +1,24 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const AccessApprovalPoliciesSchema = z.object({
+ id: z.string().uuid(),
+ name: z.string(),
+ approvals: z.number().default(1),
+ envId: z.string().uuid(),
+ secretPath: z.string().nullable().optional(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TAccessApprovalPolicies = z.infer;
+export type TAccessApprovalPoliciesInsert = Omit, TImmutableDBKeys>;
+export type TAccessApprovalPoliciesUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts
new file mode 100644
index 000000000..509fd7425
--- /dev/null
+++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts
@@ -0,0 +1,26 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const AccessApprovalRequestsReviewersSchema = z.object({
+ id: z.string().uuid(),
+ member: z.string().uuid(),
+ status: z.string(),
+ requestId: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TAccessApprovalRequestsReviewers = z.infer;
+export type TAccessApprovalRequestsReviewersInsert = Omit<
+ z.input,
+ TImmutableDBKeys
+>;
+export type TAccessApprovalRequestsReviewersUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts
new file mode 100644
index 000000000..bd598bac6
--- /dev/null
+++ b/backend/src/db/schemas/access-approval-requests.ts
@@ -0,0 +1,26 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const AccessApprovalRequestsSchema = z.object({
+ id: z.string().uuid(),
+ policyId: z.string().uuid(),
+ privilegeId: z.string().uuid().nullable().optional(),
+ requestedBy: z.string().uuid(),
+ isTemporary: z.boolean(),
+ temporaryRange: z.string().nullable().optional(),
+ permissions: z.unknown(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TAccessApprovalRequests = z.infer;
+export type TAccessApprovalRequestsInsert = Omit, TImmutableDBKeys>;
+export type TAccessApprovalRequestsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index 0eb9b1986..0fd7c5271 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -1,3 +1,7 @@
+export * from "./access-approval-policies";
+export * from "./access-approval-policies-approvers";
+export * from "./access-approval-requests";
+export * from "./access-approval-requests-reviewers";
export * from "./api-keys";
export * from "./audit-log-streams";
export * from "./audit-logs";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index 3baa7f40d..24c6b8914 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -50,6 +50,10 @@ export enum TableName {
IdentityProjectMembershipRole = "identity_project_membership_role",
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
ScimToken = "scim_tokens",
+ AccessApprovalPolicy = "access_approval_policies",
+ AccessApprovalPolicyApprover = "access_approval_policies_approvers",
+ AccessApprovalRequest = "access_approval_requests",
+ AccessApprovalRequestReviewer = "access_approval_requests_reviewers",
SecretApprovalPolicy = "secret_approval_policies",
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
SecretApprovalRequest = "secret_approval_requests",
diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts
new file mode 100644
index 000000000..3b8949d3b
--- /dev/null
+++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts
@@ -0,0 +1,168 @@
+import { nanoid } from "nanoid";
+import { z } from "zod";
+
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ url: "/",
+ method: "POST",
+ schema: {
+ body: z
+ .object({
+ projectSlug: z.string().trim(),
+ name: z.string().optional(),
+ secretPath: z.string().trim().default("/"),
+ environment: z.string(),
+ approvers: z.string().array().min(1),
+ approvals: z.number().min(1).default(1)
+ })
+ .refine((data) => data.approvals <= data.approvers.length, {
+ path: ["approvals"],
+ message: "The number of approvals should be lower than the number of approvers."
+ }),
+ response: {
+ 200: z.object({
+ approval: sapPubSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.body,
+ projectSlug: req.body.projectSlug,
+ name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`
+ });
+ return { approval };
+ }
+ });
+
+ server.route({
+ url: "/",
+ method: "GET",
+ schema: {
+ querystring: z.object({
+ projectSlug: z.string().trim()
+ }),
+ response: {
+ 200: z.object({
+ approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectSlug({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ projectSlug: req.query.projectSlug
+ });
+ return { approvals };
+ }
+ });
+
+ server.route({
+ url: "/count",
+ method: "GET",
+ schema: {
+ querystring: z.object({
+ projectSlug: z.string(),
+ envSlug: z.string()
+ }),
+ response: {
+ 200: z.object({
+ count: z.number()
+ })
+ }
+ },
+
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const { count } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ projectSlug: req.query.projectSlug,
+ actorOrgId: req.permission.orgId,
+ envSlug: req.query.envSlug
+ });
+ return { count };
+ }
+ });
+
+ server.route({
+ url: "/:policyId",
+ method: "PATCH",
+ schema: {
+ params: z.object({
+ policyId: z.string()
+ }),
+ body: z
+ .object({
+ name: z.string().optional(),
+ secretPath: z
+ .string()
+ .trim()
+ .optional()
+ .transform((val) => (val === "" ? "/" : val)),
+ approvers: z.string().array().min(1),
+ approvals: z.number().min(1).default(1)
+ })
+ .refine((data) => data.approvals <= data.approvers.length, {
+ path: ["approvals"],
+ message: "The number of approvals should be lower than the number of approvers."
+ }),
+ response: {
+ 200: z.object({
+ approval: sapPubSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({
+ policyId: req.params.policyId,
+ actor: req.permission.type,
+ actorOrgId: req.permission.orgId,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ ...req.body
+ });
+ }
+ });
+
+ server.route({
+ url: "/:policyId",
+ method: "DELETE",
+ schema: {
+ params: z.object({
+ policyId: z.string()
+ }),
+ response: {
+ 200: z.object({
+ approval: sapPubSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ policyId: req.params.policyId
+ });
+ return { approval };
+ }
+ });
+};
diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts
new file mode 100644
index 000000000..4b173cfa7
--- /dev/null
+++ b/backend/src/ee/routes/v1/access-approval-request-router.ts
@@ -0,0 +1,160 @@
+import { z } from "zod";
+
+import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas";
+import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ url: "/",
+ method: "POST",
+ schema: {
+ body: z.object({
+ permissions: z.any().array(),
+ isTemporary: z.boolean(),
+ temporaryRange: z.string().optional()
+ }),
+ querystring: z.object({
+ projectSlug: z.string().trim()
+ }),
+ response: {
+ 200: z.object({
+ approval: AccessApprovalRequestsSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const { request } = await server.services.accessApprovalRequest.createAccessApprovalRequest({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ permissions: req.body.permissions,
+ actorOrgId: req.permission.orgId,
+ projectSlug: req.query.projectSlug,
+ temporaryRange: req.body.temporaryRange,
+ isTemporary: req.body.isTemporary
+ });
+ return { approval: request };
+ }
+ });
+
+ server.route({
+ url: "/count",
+ method: "GET",
+ schema: {
+ querystring: z.object({
+ projectSlug: z.string().trim()
+ }),
+ response: {
+ 200: z.object({
+ pendingCount: z.number(),
+ finalizedCount: z.number()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const { count } = await server.services.accessApprovalRequest.getCount({
+ projectSlug: req.query.projectSlug,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ actorAuthMethod: req.permission.authMethod
+ });
+
+ return { ...count };
+ }
+ });
+
+ server.route({
+ url: "/",
+ method: "GET",
+ schema: {
+ querystring: z.object({
+ projectSlug: z.string().trim(),
+ authorProjectMembershipId: z.string().trim().optional(),
+ envSlug: z.string().trim().optional()
+ }),
+ response: {
+ 200: z.object({
+ requests: AccessApprovalRequestsSchema.extend({
+ environmentName: z.string(),
+ isApproved: z.boolean(),
+ privilege: z
+ .object({
+ membershipId: z.string(),
+ isTemporary: z.boolean(),
+ temporaryMode: z.string().nullish(),
+ temporaryRange: z.string().nullish(),
+ temporaryAccessStartTime: z.date().nullish(),
+ temporaryAccessEndTime: z.date().nullish(),
+ permissions: z.unknown()
+ })
+ .nullable(),
+ policy: z.object({
+ id: z.string(),
+ name: z.string(),
+ approvals: z.number(),
+ approvers: z.string().array(),
+ secretPath: z.string().nullish(),
+ envId: z.string()
+ }),
+ reviewers: z
+ .object({
+ member: z.string(),
+ status: z.string()
+ })
+ .array()
+ }).array()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
+ projectSlug: req.query.projectSlug,
+ authorProjectMembershipId: req.query.authorProjectMembershipId,
+ envSlug: req.query.envSlug,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ actorAuthMethod: req.permission.authMethod
+ });
+
+ return { requests };
+ }
+ });
+
+ server.route({
+ url: "/:requestId/review",
+ method: "POST",
+ schema: {
+ params: z.object({
+ requestId: z.string().trim()
+ }),
+ body: z.object({
+ status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED])
+ }),
+ response: {
+ 200: z.object({
+ review: AccessApprovalRequestsReviewersSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const review = await server.services.accessApprovalRequest.reviewAccessRequest({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ actorAuthMethod: req.permission.authMethod,
+ requestId: req.params.requestId,
+ status: req.body.status
+ });
+
+ return { review };
+ }
+ });
+};
diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts
index cf325b2e3..16e23eb88 100644
--- a/backend/src/ee/routes/v1/index.ts
+++ b/backend/src/ee/routes/v1/index.ts
@@ -1,3 +1,5 @@
+import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
+import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
@@ -41,6 +43,9 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
prefix: "/secret-rotation-providers"
});
+ await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" });
+ await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" });
+
await server.register(
async (dynamicSecretRouter) => {
await dynamicSecretRouter.register(registerDynamicSecretRouter);
diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts
new file mode 100644
index 000000000..e14854d8f
--- /dev/null
+++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts
@@ -0,0 +1,10 @@
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { ormify } from "@app/lib/knex";
+
+export type TAccessApprovalPolicyApproverDALFactory = ReturnType;
+
+export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => {
+ const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover);
+ return { ...accessApprovalPolicyApproverOrm };
+};
diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts
new file mode 100644
index 000000000..88e288832
--- /dev/null
+++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts
@@ -0,0 +1,76 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName, TAccessApprovalPolicies } from "@app/db/schemas";
+import { DatabaseError } from "@app/lib/errors";
+import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex";
+
+export type TAccessApprovalPolicyDALFactory = ReturnType;
+
+export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
+ const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy);
+
+ const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter) => {
+ const result = await tx(TableName.AccessApprovalPolicy)
+ // eslint-disable-next-line
+ .where(buildFindFilter(filter))
+ .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
+ .join(
+ TableName.AccessApprovalPolicyApprover,
+ `${TableName.AccessApprovalPolicy}.id`,
+ `${TableName.AccessApprovalPolicyApprover}.policyId`
+ )
+ .select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover))
+ .select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
+ .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
+ .select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
+ .select(tx.ref("projectId").withSchema(TableName.Environment))
+ .select(selectAllTableCols(TableName.AccessApprovalPolicy));
+
+ return result;
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const doc = await accessApprovalPolicyFindQuery(tx || db, {
+ [`${TableName.AccessApprovalPolicy}.id` as "id"]: id
+ });
+ const formatedDoc = mergeOneToManyRelation(
+ doc,
+ "id",
+ ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
+ ...el,
+ envId,
+ environment: { id: envId, name, slug }
+ }),
+ ({ approverId }) => approverId,
+ "approvers"
+ );
+ return formatedDoc?.[0];
+ } catch (error) {
+ throw new DatabaseError({ error, name: "FindById" });
+ }
+ };
+
+ const find = async (filter: TFindFilter, tx?: Knex) => {
+ try {
+ const docs = await accessApprovalPolicyFindQuery(tx || db, filter);
+ const formatedDoc = mergeOneToManyRelation(
+ docs,
+ "id",
+ ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
+ ...el,
+ envId,
+ environment: { id: envId, name, slug }
+ }),
+ ({ approverId }) => approverId,
+ "approvers"
+ );
+ return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined }));
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find" });
+ }
+ };
+
+ return { ...accessApprovalPolicyOrm, find, findById };
+};
diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts
new file mode 100644
index 000000000..7b0a2681f
--- /dev/null
+++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts
@@ -0,0 +1,36 @@
+import { ForbiddenError, subject } from "@casl/ability";
+
+import { BadRequestError } from "@app/lib/errors";
+import { ActorType } from "@app/services/auth/auth-type";
+
+import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
+import { TVerifyApprovers } from "./access-approval-policy-types";
+
+export const verifyApprovers = async ({
+ userIds,
+ projectId,
+ orgId,
+ envSlug,
+ actorAuthMethod,
+ secretPath,
+ permissionService
+}: TVerifyApprovers) => {
+ for await (const userId of userIds) {
+ try {
+ const { permission: approverPermission } = await permissionService.getProjectPermission(
+ ActorType.USER,
+ userId,
+ projectId,
+ actorAuthMethod,
+ orgId
+ );
+
+ ForbiddenError.from(approverPermission).throwUnlessCan(
+ ProjectPermissionActions.Create,
+ subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath })
+ );
+ } catch (err) {
+ throw new BadRequestError({ message: "One or more approvers doesn't have access to be specified secret path" });
+ }
+ }
+};
diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts
new file mode 100644
index 000000000..51a51abb5
--- /dev/null
+++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts
@@ -0,0 +1,273 @@
+import { ForbiddenError } from "@casl/ability";
+
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
+import { BadRequestError } from "@app/lib/errors";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
+import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
+
+import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
+import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
+import { verifyApprovers } from "./access-approval-policy-fns";
+import {
+ TCreateAccessApprovalPolicy,
+ TDeleteAccessApprovalPolicy,
+ TGetAccessPolicyCountByEnvironmentDTO,
+ TListAccessApprovalPoliciesDTO,
+ TUpdateAccessApprovalPolicy
+} from "./access-approval-policy-types";
+
+type TSecretApprovalPolicyServiceFactoryDep = {
+ projectDAL: TProjectDALFactory;
+ permissionService: Pick;
+ accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory;
+ projectEnvDAL: Pick;
+ accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
+ projectMembershipDAL: Pick;
+};
+
+export type TAccessApprovalPolicyServiceFactory = ReturnType;
+
+export const accessApprovalPolicyServiceFactory = ({
+ accessApprovalPolicyDAL,
+ accessApprovalPolicyApproverDAL,
+ permissionService,
+ projectEnvDAL,
+ projectDAL,
+ projectMembershipDAL
+}: TSecretApprovalPolicyServiceFactoryDep) => {
+ const createAccessApprovalPolicy = async ({
+ name,
+ actor,
+ actorId,
+ actorOrgId,
+ secretPath,
+ actorAuthMethod,
+ approvals,
+ approvers,
+ projectSlug,
+ environment
+ }: TCreateAccessApprovalPolicy) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+ if (!project) throw new BadRequestError({ message: "Project not found" });
+
+ if (approvals > approvers.length)
+ throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
+
+ const { permission } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionActions.Create,
+ ProjectPermissionSub.SecretApproval
+ );
+ const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id });
+ if (!env) throw new BadRequestError({ message: "Environment not found" });
+
+ const secretApprovers = await projectMembershipDAL.find({
+ projectId: project.id,
+ $in: { id: approvers }
+ });
+
+ if (secretApprovers.length !== approvers.length) {
+ throw new BadRequestError({ message: "Approver not found in project" });
+ }
+
+ await verifyApprovers({
+ projectId: project.id,
+ orgId: actorOrgId,
+ envSlug: environment,
+ secretPath,
+ actorAuthMethod,
+ permissionService,
+ userIds: secretApprovers.map((approver) => approver.userId)
+ });
+
+ const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
+ const doc = await accessApprovalPolicyDAL.create(
+ {
+ envId: env.id,
+ approvals,
+ secretPath,
+ name
+ },
+ tx
+ );
+ await accessApprovalPolicyApproverDAL.insertMany(
+ secretApprovers.map(({ id }) => ({
+ approverId: id,
+ policyId: doc.id
+ })),
+ tx
+ );
+ return doc;
+ });
+ return { ...accessApproval, environment: env, projectId: project.id };
+ };
+
+ const getAccessApprovalPolicyByProjectSlug = async ({
+ actorId,
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ projectSlug
+ }: TListAccessApprovalPoliciesDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+ if (!project) throw new BadRequestError({ message: "Project not found" });
+
+ // Anyone in the project should be able to get the policies.
+ /* const { permission } = */ await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
+
+ const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id });
+ return accessApprovalPolicies;
+ };
+
+ const updateAccessApprovalPolicy = async ({
+ policyId,
+ approvers,
+ secretPath,
+ name,
+ actorId,
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ approvals
+ }: TUpdateAccessApprovalPolicy) => {
+ const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId);
+ if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" });
+ const { permission } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ accessApprovalPolicy.projectId,
+ actorAuthMethod,
+ actorOrgId
+ );
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval);
+
+ const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
+ const doc = await accessApprovalPolicyDAL.updateById(
+ accessApprovalPolicy.id,
+ {
+ approvals,
+ secretPath,
+ name
+ },
+ tx
+ );
+ if (approvers) {
+ // Find the workspace project memberships of the users passed in the approvers array
+ const secretApprovers = await projectMembershipDAL.find(
+ {
+ projectId: accessApprovalPolicy.projectId,
+ $in: { id: approvers }
+ },
+ { tx }
+ );
+
+ await verifyApprovers({
+ projectId: accessApprovalPolicy.projectId,
+ orgId: actorOrgId,
+ envSlug: accessApprovalPolicy.environment.slug,
+ secretPath: doc.secretPath!,
+ actorAuthMethod,
+ permissionService,
+ userIds: secretApprovers.map((approver) => approver.userId)
+ });
+
+ if (secretApprovers.length !== approvers.length)
+ throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
+ await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
+ await accessApprovalPolicyApproverDAL.insertMany(
+ secretApprovers.map(({ id }) => ({
+ approverId: id,
+ policyId: doc.id
+ })),
+ tx
+ );
+ }
+ return doc;
+ });
+ return {
+ ...updatedPolicy,
+ environment: accessApprovalPolicy.environment,
+ projectId: accessApprovalPolicy.projectId
+ };
+ };
+
+ const deleteAccessApprovalPolicy = async ({
+ policyId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TDeleteAccessApprovalPolicy) => {
+ const policy = await accessApprovalPolicyDAL.findById(policyId);
+ if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" });
+
+ const { permission } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ policy.projectId,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionActions.Delete,
+ ProjectPermissionSub.SecretApproval
+ );
+
+ await accessApprovalPolicyDAL.deleteById(policyId);
+ return policy;
+ };
+
+ const getAccessPolicyCountByEnvSlug = async ({
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ projectSlug,
+ actorId,
+ envSlug
+ }: TGetAccessPolicyCountByEnvironmentDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+
+ if (!project) throw new BadRequestError({ message: "Project not found" });
+
+ const { membership } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ if (!membership) throw new BadRequestError({ message: "User not found in project" });
+
+ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
+ if (!environment) throw new BadRequestError({ message: "Environment not found" });
+
+ const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id });
+ if (!policies) throw new BadRequestError({ message: "No policies found" });
+
+ return { count: policies.length };
+ };
+
+ return {
+ getAccessPolicyCountByEnvSlug,
+ createAccessApprovalPolicy,
+ deleteAccessApprovalPolicy,
+ updateAccessApprovalPolicy,
+ getAccessApprovalPolicyByProjectSlug
+ };
+};
diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts
new file mode 100644
index 000000000..601561b68
--- /dev/null
+++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts
@@ -0,0 +1,44 @@
+import { TProjectPermission } from "@app/lib/types";
+import { ActorAuthMethod } from "@app/services/auth/auth-type";
+
+import { TPermissionServiceFactory } from "../permission/permission-service";
+
+export type TVerifyApprovers = {
+ userIds: string[];
+ permissionService: Pick;
+ envSlug: string;
+ actorAuthMethod: ActorAuthMethod;
+ secretPath: string;
+ projectId: string;
+ orgId: string;
+};
+
+export type TCreateAccessApprovalPolicy = {
+ approvals: number;
+ secretPath: string;
+ environment: string;
+ approvers: string[];
+ projectSlug: string;
+ name: string;
+} & Omit;
+
+export type TUpdateAccessApprovalPolicy = {
+ policyId: string;
+ approvals?: number;
+ approvers?: string[];
+ secretPath?: string;
+ name?: string;
+} & Omit;
+
+export type TDeleteAccessApprovalPolicy = {
+ policyId: string;
+} & Omit;
+
+export type TGetAccessPolicyCountByEnvironmentDTO = {
+ envSlug: string;
+ projectSlug: string;
+} & Omit;
+
+export type TListAccessApprovalPoliciesDTO = {
+ projectSlug: string;
+} & Omit;
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts
new file mode 100644
index 000000000..c3f4c72a6
--- /dev/null
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts
@@ -0,0 +1,266 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas";
+import { DatabaseError } from "@app/lib/errors";
+import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex";
+
+import { ApprovalStatus } from "./access-approval-request-types";
+
+export type TAccessApprovalRequestDALFactory = ReturnType;
+
+export const accessApprovalRequestDALFactory = (db: TDbClient) => {
+ const accessApprovalRequestOrm = ormify(db, TableName.AccessApprovalRequest);
+
+ const findRequestsWithPrivilegeByPolicyIds = async (policyIds: string[]) => {
+ try {
+ const docs = await db(TableName.AccessApprovalRequest)
+ .whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds)
+
+ .leftJoin(
+ TableName.ProjectUserAdditionalPrivilege,
+ `${TableName.AccessApprovalRequest}.privilegeId`,
+ `${TableName.ProjectUserAdditionalPrivilege}.id`
+ )
+ .leftJoin(
+ TableName.AccessApprovalPolicy,
+ `${TableName.AccessApprovalRequest}.policyId`,
+ `${TableName.AccessApprovalPolicy}.id`
+ )
+
+ .leftJoin(
+ TableName.AccessApprovalRequestReviewer,
+ `${TableName.AccessApprovalRequest}.id`,
+ `${TableName.AccessApprovalRequestReviewer}.requestId`
+ )
+ .leftJoin(
+ TableName.AccessApprovalPolicyApprover,
+ `${TableName.AccessApprovalPolicy}.id`,
+ `${TableName.AccessApprovalPolicyApprover}.policyId`
+ )
+
+ .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
+
+ .select(selectAllTableCols(TableName.AccessApprovalRequest))
+ .select(
+ db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
+ db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
+ db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
+ db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
+ db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId")
+ )
+
+ .select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover))
+
+ .select(
+ db.ref("projectId").withSchema(TableName.Environment),
+ db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
+ db.ref("name").withSchema(TableName.Environment).as("envName")
+ )
+
+ .select(
+ db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"),
+ db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")
+ )
+
+ .select(
+ db
+ .ref("projectMembershipId")
+ .withSchema(TableName.ProjectUserAdditionalPrivilege)
+ .as("privilegeMembershipId"),
+ db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"),
+ db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"),
+ db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"),
+ db
+ .ref("temporaryAccessStartTime")
+ .withSchema(TableName.ProjectUserAdditionalPrivilege)
+ .as("privilegeTemporaryAccessStartTime"),
+ db
+ .ref("temporaryAccessEndTime")
+ .withSchema(TableName.ProjectUserAdditionalPrivilege)
+ .as("privilegeTemporaryAccessEndTime"),
+
+ db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions")
+ )
+ .orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc");
+
+ const formattedDocs = sqlNestRelationships({
+ data: docs,
+ key: "id",
+ parentMapper: (doc) => ({
+ ...AccessApprovalRequestsSchema.parse(doc),
+ projectId: doc.projectId,
+ environment: doc.envSlug,
+ environmentName: doc.envName,
+ policy: {
+ id: doc.policyId,
+ name: doc.policyName,
+ approvals: doc.policyApprovals,
+ secretPath: doc.policySecretPath,
+ envId: doc.policyEnvId
+ },
+ privilege: doc.privilegeId
+ ? {
+ membershipId: doc.privilegeMembershipId,
+ isTemporary: doc.privilegeIsTemporary,
+ temporaryMode: doc.privilegeTemporaryMode,
+ temporaryRange: doc.privilegeTemporaryRange,
+ temporaryAccessStartTime: doc.privilegeTemporaryAccessStartTime,
+ temporaryAccessEndTime: doc.privilegeTemporaryAccessEndTime,
+ permissions: doc.privilegePermissions
+ }
+ : null,
+
+ isApproved: !!doc.privilegeId
+ }),
+ childrenMapper: [
+ {
+ key: "reviewerMemberId",
+ label: "reviewers" as const,
+ mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
+ },
+ { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId }
+ ]
+ });
+
+ if (!formattedDocs) return [];
+
+ return formattedDocs.map((doc) => ({
+ ...doc,
+ policy: { ...doc.policy, approvers: doc.approvers }
+ }));
+ } catch (error) {
+ throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" });
+ }
+ };
+
+ const findQuery = (filter: TFindFilter, tx: Knex) =>
+ tx(TableName.AccessApprovalRequest)
+ .where(filter)
+ .join(
+ TableName.AccessApprovalPolicy,
+ `${TableName.AccessApprovalRequest}.policyId`,
+ `${TableName.AccessApprovalPolicy}.id`
+ )
+
+ .join(
+ TableName.AccessApprovalPolicyApprover,
+ `${TableName.AccessApprovalPolicy}.id`,
+ `${TableName.AccessApprovalPolicyApprover}.policyId`
+ )
+ .leftJoin(
+ TableName.AccessApprovalRequestReviewer,
+ `${TableName.AccessApprovalRequest}.id`,
+ `${TableName.AccessApprovalRequestReviewer}.requestId`
+ )
+
+ .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
+ .select(selectAllTableCols(TableName.AccessApprovalRequest))
+ .select(
+ tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"),
+ tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"),
+ tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
+ tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
+ tx.ref("projectId").withSchema(TableName.Environment),
+ tx.ref("slug").withSchema(TableName.Environment).as("environment"),
+ tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
+ tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
+ tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)
+ );
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db);
+ const docs = await sql;
+ const formatedDoc = sqlNestRelationships({
+ data: docs,
+ key: "id",
+ parentMapper: (el) => ({
+ ...AccessApprovalRequestsSchema.parse(el),
+ projectId: el.projectId,
+ environment: el.environment,
+ policy: {
+ id: el.policyId,
+ name: el.policyName,
+ approvals: el.policyApprovals,
+ secretPath: el.policySecretPath
+ }
+ }),
+ childrenMapper: [
+ {
+ key: "reviewerMemberId",
+ label: "reviewers" as const,
+ mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
+ },
+ { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId }
+ ]
+ });
+ if (!formatedDoc?.[0]) return;
+ return {
+ ...formatedDoc[0],
+ policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers }
+ };
+ } catch (error) {
+ throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" });
+ }
+ };
+
+ const getCount = async ({ projectId }: { projectId: string }) => {
+ try {
+ const accessRequests = await db(TableName.AccessApprovalRequest)
+ .leftJoin(
+ TableName.AccessApprovalPolicy,
+ `${TableName.AccessApprovalRequest}.policyId`,
+ `${TableName.AccessApprovalPolicy}.id`
+ )
+ .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
+ .leftJoin(
+ TableName.ProjectUserAdditionalPrivilege,
+ `${TableName.AccessApprovalRequest}.privilegeId`,
+ `${TableName.ProjectUserAdditionalPrivilege}.id`
+ )
+
+ .leftJoin(
+ TableName.AccessApprovalRequestReviewer,
+ `${TableName.AccessApprovalRequest}.id`,
+ `${TableName.AccessApprovalRequestReviewer}.requestId`
+ )
+
+ .where(`${TableName.Environment}.projectId`, projectId)
+ .select(selectAllTableCols(TableName.AccessApprovalRequest))
+ .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
+ .select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"));
+
+ const formattedRequests = sqlNestRelationships({
+ data: accessRequests,
+ key: "id",
+ parentMapper: (doc) => ({
+ ...AccessApprovalRequestsSchema.parse(doc)
+ }),
+ childrenMapper: [
+ {
+ key: "reviewerMemberId",
+ label: "reviewers" as const,
+ mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
+ }
+ ]
+ });
+
+ // an approval is pending if there is no reviewer rejections and no privilege ID is set
+ const pendingApprovals = formattedRequests.filter(
+ (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED)
+ );
+
+ // an approval is finalized if there are any rejections or a privilege ID is set
+ const finalizedApprovals = formattedRequests.filter(
+ (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED)
+ );
+
+ return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length };
+ } catch (error) {
+ throw new DatabaseError({ error, name: "GetCountAccessApprovalRequest" });
+ }
+ };
+
+ return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount };
+};
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts
new file mode 100644
index 000000000..90b42aaf7
--- /dev/null
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts
@@ -0,0 +1,53 @@
+import { PackRule, unpackRules } from "@casl/ability/extra";
+
+import { UnauthorizedError } from "@app/lib/errors";
+
+import { TVerifyPermission } from "./access-approval-request-types";
+
+function filterUnique(value: string, index: number, array: string[]) {
+ return array.indexOf(value) === index;
+}
+
+export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => {
+ const permission = unpackRules(
+ permissions as PackRule<{
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ conditions?: Record;
+ action: string;
+ subject: [string];
+ }>[]
+ );
+
+ if (!permission || !permission.length) {
+ throw new UnauthorizedError({ message: "No permission provided" });
+ }
+
+ const requestedPermissions: string[] = [];
+
+ for (const p of permission) {
+ if (p.action[0] === "read") requestedPermissions.push("Read Access");
+ if (p.action[0] === "create") requestedPermissions.push("Create Access");
+ if (p.action[0] === "delete") requestedPermissions.push("Delete Access");
+ if (p.action[0] === "edit") requestedPermissions.push("Edit Access");
+ }
+
+ const firstPermission = permission[0];
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment
+ const permissionEnv = firstPermission.conditions?.environment;
+
+ if (!permissionEnv || typeof permissionEnv !== "string") {
+ throw new UnauthorizedError({ message: "Permission environment is not a string" });
+ }
+ if (!permissionSecretPath || typeof permissionSecretPath !== "string") {
+ throw new UnauthorizedError({ message: "Permission path is not a string" });
+ }
+
+ return {
+ envSlug: permissionEnv,
+ secretPath: permissionSecretPath,
+ accessTypes: requestedPermissions.filter(filterUnique)
+ };
+};
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts
new file mode 100644
index 000000000..251015b22
--- /dev/null
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts
@@ -0,0 +1,10 @@
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { ormify } from "@app/lib/knex";
+
+export type TAccessApprovalRequestReviewerDALFactory = ReturnType;
+
+export const accessApprovalRequestReviewerDALFactory = (db: TDbClient) => {
+ const secretApprovalRequestReviewerOrm = ormify(db, TableName.AccessApprovalRequestReviewer);
+ return secretApprovalRequestReviewerOrm;
+};
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts
new file mode 100644
index 000000000..becdb78da
--- /dev/null
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts
@@ -0,0 +1,369 @@
+import slugify from "@sindresorhus/slugify";
+import ms from "ms";
+
+import { ProjectMembershipRole } from "@app/db/schemas";
+import { getConfig } from "@app/lib/config/env";
+import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
+import { alphaNumericNanoId } from "@app/lib/nanoid";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
+import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
+import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
+import { TUserDALFactory } from "@app/services/user/user-dal";
+
+import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal";
+import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
+import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns";
+import { TPermissionServiceFactory } from "../permission/permission-service";
+import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
+import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
+import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
+import { verifyRequestedPermissions } from "./access-approval-request-fns";
+import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal";
+import {
+ ApprovalStatus,
+ TCreateAccessApprovalRequestDTO,
+ TGetAccessRequestCountDTO,
+ TListApprovalRequestsDTO,
+ TReviewAccessRequestDTO
+} from "./access-approval-request-types";
+
+type TSecretApprovalRequestServiceFactoryDep = {
+ additionalPrivilegeDAL: Pick;
+ permissionService: Pick;
+ accessApprovalPolicyApproverDAL: Pick;
+ projectEnvDAL: Pick;
+ projectDAL: Pick;
+ accessApprovalRequestDAL: Pick<
+ TAccessApprovalRequestDALFactory,
+ | "create"
+ | "find"
+ | "findRequestsWithPrivilegeByPolicyIds"
+ | "findById"
+ | "transaction"
+ | "updateById"
+ | "findOne"
+ | "getCount"
+ >;
+ accessApprovalPolicyDAL: Pick;
+ accessApprovalRequestReviewerDAL: Pick<
+ TAccessApprovalRequestReviewerDALFactory,
+ "create" | "find" | "findOne" | "transaction"
+ >;
+ projectMembershipDAL: Pick;
+ smtpService: Pick;
+ userDAL: Pick;
+};
+
+export type TAccessApprovalRequestServiceFactory = ReturnType;
+
+export const accessApprovalRequestServiceFactory = ({
+ projectDAL,
+ projectEnvDAL,
+ permissionService,
+ accessApprovalRequestDAL,
+ accessApprovalRequestReviewerDAL,
+ projectMembershipDAL,
+ accessApprovalPolicyDAL,
+ accessApprovalPolicyApproverDAL,
+ additionalPrivilegeDAL,
+ smtpService,
+ userDAL
+}: TSecretApprovalRequestServiceFactoryDep) => {
+ const createAccessApprovalRequest = async ({
+ isTemporary,
+ temporaryRange,
+ actorId,
+ permissions: requestedPermissions,
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ projectSlug
+ }: TCreateAccessApprovalRequestDTO) => {
+ const cfg = getConfig();
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+ if (!project) throw new UnauthorizedError({ message: "Project not found" });
+
+ // Anyone can create an access approval request.
+ const { membership } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
+
+ const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id);
+ if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" });
+
+ await projectDAL.checkProjectUpgradeStatus(project.id);
+
+ const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions });
+ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
+
+ if (!environment) throw new UnauthorizedError({ message: "Environment not found" });
+
+ const policy = await accessApprovalPolicyDAL.findOne({
+ envId: environment.id,
+ secretPath
+ });
+ if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." });
+
+ const approvers = await accessApprovalPolicyApproverDAL.find({
+ policyId: policy.id
+ });
+
+ const approverUsers = await userDAL.findUsersByProjectMembershipIds(
+ approvers.map((approver) => approver.approverId)
+ );
+
+ const duplicateRequests = await accessApprovalRequestDAL.find({
+ policyId: policy.id,
+ requestedBy: membership.id,
+ permissions: JSON.stringify(requestedPermissions),
+ isTemporary
+ });
+
+ if (duplicateRequests?.length > 0) {
+ for await (const duplicateRequest of duplicateRequests) {
+ if (duplicateRequest.privilegeId) {
+ const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId);
+
+ const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string));
+
+ if (!isExpired || !privilege.isTemporary) {
+ throw new BadRequestError({ message: "You already have an active privilege with the same criteria" });
+ }
+ } else {
+ const reviewers = await accessApprovalRequestReviewerDAL.find({
+ requestId: duplicateRequest.id
+ });
+
+ const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED);
+
+ if (!isRejected) {
+ throw new BadRequestError({ message: "You already have a pending access request with the same criteria" });
+ }
+ }
+ }
+ }
+
+ const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
+ const approvalRequest = await accessApprovalRequestDAL.create(
+ {
+ policyId: policy.id,
+ requestedBy: membership.id,
+ temporaryRange: temporaryRange || null,
+ permissions: JSON.stringify(requestedPermissions),
+ isTemporary
+ },
+ tx
+ );
+
+ await smtpService.sendMail({
+ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!),
+ subjectLine: "Access Approval Request",
+
+ substitutions: {
+ projectName: project.name,
+ requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`,
+ requesterEmail: requestedByUser.email,
+ isTemporary,
+ ...(isTemporary && {
+ expiresIn: ms(ms(temporaryRange || ""), { long: true })
+ }),
+ secretPath,
+ environment: envSlug,
+ permissions: accessTypes,
+ approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval`
+ },
+ template: SmtpTemplates.AccessApprovalRequest
+ });
+
+ return approvalRequest;
+ });
+
+ return { request: approval };
+ };
+
+ const listApprovalRequests = async ({
+ projectSlug,
+ authorProjectMembershipId,
+ envSlug,
+ actor,
+ actorOrgId,
+ actorId,
+ actorAuthMethod
+ }: TListApprovalRequestsDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+ if (!project) throw new UnauthorizedError({ message: "Project not found" });
+
+ const { membership } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
+
+ const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
+ let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
+
+ if (authorProjectMembershipId) {
+ requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId);
+ }
+
+ if (envSlug) {
+ requests = requests.filter((request) => request.environment === envSlug);
+ }
+
+ return { requests };
+ };
+
+ const reviewAccessRequest = async ({
+ requestId,
+ actor,
+ status,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TReviewAccessRequestDTO) => {
+ const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
+ if (!accessApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
+
+ const { policy } = accessApprovalRequest;
+ const { membership, hasRole } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ accessApprovalRequest.projectId,
+ actorAuthMethod,
+ actorOrgId
+ );
+
+ if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
+
+ if (
+ !hasRole(ProjectMembershipRole.Admin) &&
+ accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user
+ !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver
+ ) {
+ throw new UnauthorizedError({ message: "You are not authorized to approve this request" });
+ }
+
+ const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id);
+
+ await verifyApprovers({
+ projectId: accessApprovalRequest.projectId,
+ orgId: actorOrgId,
+ envSlug: accessApprovalRequest.environment,
+ secretPath: accessApprovalRequest.policy.secretPath!,
+ actorAuthMethod,
+ permissionService,
+ userIds: [reviewerProjectMembership.userId]
+ });
+
+ const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id });
+ if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
+ throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
+ }
+
+ const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
+ const review = await accessApprovalRequestReviewerDAL.findOne(
+ {
+ requestId: accessApprovalRequest.id,
+ member: membership.id
+ },
+ tx
+ );
+ if (!review) {
+ const newReview = await accessApprovalRequestReviewerDAL.create(
+ {
+ status,
+ requestId: accessApprovalRequest.id,
+ member: membership.id
+ },
+ tx
+ );
+
+ const allReviews = [...existingReviews, newReview];
+
+ const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED);
+
+ // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved.
+ if (approvedReviews.length === policy.approvals) {
+ if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
+ throw new BadRequestError({ message: "Temporary range is required for temporary access" });
+ }
+
+ let privilegeId: string | null = null;
+
+ if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
+ // Permanent access
+ const privilege = await additionalPrivilegeDAL.create(
+ {
+ projectMembershipId: accessApprovalRequest.requestedBy,
+ slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
+ permissions: JSON.stringify(accessApprovalRequest.permissions)
+ },
+ tx
+ );
+ privilegeId = privilege.id;
+ } else {
+ // Temporary access
+ const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!);
+ const startTime = new Date();
+
+ const privilege = await additionalPrivilegeDAL.create(
+ {
+ projectMembershipId: accessApprovalRequest.requestedBy,
+ slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
+ permissions: JSON.stringify(accessApprovalRequest.permissions),
+ isTemporary: true,
+ temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
+ temporaryRange: accessApprovalRequest.temporaryRange!,
+ temporaryAccessStartTime: startTime,
+ temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs)
+ },
+ tx
+ );
+ privilegeId = privilege.id;
+ }
+
+ await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx);
+ }
+
+ return newReview;
+ }
+ throw new BadRequestError({ message: "You have already reviewed this request" });
+ });
+
+ return reviewStatus;
+ };
+
+ const getCount = async ({ projectSlug, actor, actorAuthMethod, actorId, actorOrgId }: TGetAccessRequestCountDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+ if (!project) throw new UnauthorizedError({ message: "Project not found" });
+
+ const { membership } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+ if (!membership) throw new BadRequestError({ message: "User not found in project" });
+
+ const count = await accessApprovalRequestDAL.getCount({ projectId: project.id });
+
+ return { count };
+ };
+
+ return {
+ createAccessApprovalRequest,
+ listApprovalRequests,
+ reviewAccessRequest,
+ getCount
+ };
+};
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts
new file mode 100644
index 000000000..e11ca58d5
--- /dev/null
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts
@@ -0,0 +1,33 @@
+import { TProjectPermission } from "@app/lib/types";
+
+export enum ApprovalStatus {
+ PENDING = "pending",
+ APPROVED = "approved",
+ REJECTED = "rejected"
+}
+
+export type TVerifyPermission = {
+ permissions: unknown;
+};
+
+export type TGetAccessRequestCountDTO = {
+ projectSlug: string;
+} & Omit;
+
+export type TReviewAccessRequestDTO = {
+ requestId: string;
+ status: ApprovalStatus;
+} & Omit;
+
+export type TCreateAccessApprovalRequestDTO = {
+ projectSlug: string;
+ permissions: unknown;
+ isTemporary: boolean;
+ temporaryRange?: string;
+} & Omit;
+
+export type TListApprovalRequestsDTO = {
+ projectSlug: string;
+ authorProjectMembershipId?: string;
+ envSlug?: string;
+} & Omit;
diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts
index e81f6dc12..47b46d010 100644
--- a/backend/src/ee/services/license/license-service.ts
+++ b/backend/src/ee/services/license/license-service.ts
@@ -121,8 +121,8 @@ export const licenseServiceFactory = ({
if (isValidOfflineLicense) {
onPremFeatures = contents.license.features;
- instanceType = InstanceType.EnterpriseOnPrem;
- logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`);
+ instanceType = InstanceType.EnterpriseOnPremOffline;
+ logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`);
isValidLicense = true;
return;
}
diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts
index a2379ddaa..0c8fdc197 100644
--- a/backend/src/ee/services/license/license-types.ts
+++ b/backend/src/ee/services/license/license-types.ts
@@ -3,6 +3,7 @@ import { TOrgPermission } from "@app/lib/types";
export enum InstanceType {
OnPrem = "self-hosted",
EnterpriseOnPrem = "enterprise-self-hosted",
+ EnterpriseOnPremOffline = "enterprise-self-hosted-offline",
Cloud = "cloud"
}
diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts
index 4c0683797..d8814dd40 100644
--- a/backend/src/server/plugins/auth/inject-identity.ts
+++ b/backend/src/server/plugins/auth/inject-identity.ts
@@ -108,6 +108,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
if (req.url.includes("/api/v3/auth/")) {
return;
}
+
if (!authMode) return;
switch (authMode) {
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index aeb66d93f..269a97c49 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -2,6 +2,12 @@ import { Knex } from "knex";
import { z } from "zod";
import { registerV1EERoutes } from "@app/ee/routes/v1";
+import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal";
+import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal";
+import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
+import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal";
+import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal";
+import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
@@ -207,6 +213,12 @@ export const registerRoutes = async (
const scimDAL = scimDALFactory(db);
const ldapConfigDAL = ldapConfigDALFactory(db);
const ldapGroupMapDAL = ldapGroupMapDALFactory(db);
+
+ const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db);
+ const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db);
+ const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db);
+ const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db);
+
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
@@ -265,6 +277,7 @@ export const registerRoutes = async (
secretApprovalPolicyDAL
});
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
+
const samlService = samlConfigServiceFactory({
permissionService,
orgBotDAL,
@@ -596,6 +609,30 @@ export const registerRoutes = async (
secretVersionTagDAL,
secretQueueService
});
+
+ const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
+ accessApprovalPolicyDAL,
+ accessApprovalPolicyApproverDAL,
+ permissionService,
+ projectEnvDAL,
+ projectMembershipDAL,
+ projectDAL
+ });
+
+ const accessApprovalRequestService = accessApprovalRequestServiceFactory({
+ projectDAL,
+ permissionService,
+ accessApprovalRequestReviewerDAL,
+ additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL,
+ projectMembershipDAL,
+ accessApprovalPolicyDAL,
+ accessApprovalRequestDAL,
+ projectEnvDAL,
+ userDAL,
+ smtpService,
+ accessApprovalPolicyApproverDAL
+ });
+
const secretRotationQueue = secretRotationQueueFactory({
telemetryService,
secretRotationDAL,
@@ -732,6 +769,8 @@ export const registerRoutes = async (
identityProject: identityProjectService,
identityUa: identityUaService,
secretApprovalPolicy: sapService,
+ accessApprovalPolicy: accessApprovalPolicyService,
+ accessApprovalRequest: accessApprovalRequestService,
secretApprovalRequest: sarService,
secretRotation: secretRotationService,
dynamicSecret: dynamicSecretService,
diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts
index 0b43ffb90..81680537d 100644
--- a/backend/src/services/smtp/smtp-service.ts
+++ b/backend/src/services/smtp/smtp-service.ts
@@ -21,6 +21,7 @@ export enum SmtpTemplates {
EmailVerification = "emailVerification.handlebars",
SecretReminder = "secretReminder.handlebars",
EmailMfa = "emailMfa.handlebars",
+ AccessApprovalRequest = "accessApprovalRequest.handlebars",
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
NewDeviceJoin = "newDevice.handlebars",
OrgInvite = "organizationInvitation.handlebars",
diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars
new file mode 100644
index 000000000..82c66ce5f
--- /dev/null
+++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Access Approval Request
+
+
+
+ Infisical
+ New access approval request pending your review
+ You have a new access approval request pending review in project "{{projectName}}".
+
+
+ {{requesterFullName}}
+ ({{requesterEmail}}) has requested
+ {{#if isTemporary}}
+ temporary
+ {{else}}
+ permanent
+ {{/if}}
+ access to
+ {{secretPath}}
+ in the
+ {{environment}}
+ environment.
+
+ {{#if isTemporary}}
+
+ This access will expire
+ {{expiresIn}}
+ after it has been approved.
+ {{/if}}
+
+
+ The following permissions are requested:
+
+ {{#each permissions}}
+ - {{this}}
+ {{/each}}
+
+
+
+
+ View the request and approve or deny it
+ here.
+
+
+
+
\ No newline at end of file
diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts
index 530ca3ad1..f2da0df0e 100644
--- a/backend/src/services/user/user-dal.ts
+++ b/backend/src/services/user/user-dal.ts
@@ -74,6 +74,17 @@ export const userDALFactory = (db: TDbClient) => {
}
};
+ const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => {
+ try {
+ return await db(TableName.ProjectMembership)
+ .whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds)
+ .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
+ .select("*");
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find users by project membership ids" });
+ }
+ };
+
const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => {
try {
const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*");
@@ -140,6 +151,7 @@ export const userDALFactory = (db: TDbClient) => {
findUserEncKeyByUserId,
updateUserEncryptionByUserId,
findUserByProjectMembershipId,
+ findUsersByProjectMembershipIds,
upsertUserEncryptionKey,
createUserEncryption,
findOneUserAction,
diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx
index 067ee9c4a..b3c7a4f53 100644
--- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx
+++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx
@@ -17,23 +17,20 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
containerClassName
)}
>
-
-
-
-
-
-
Access Restricted
- {children || (
-
- Your role has limited permissions, please
contact your administrator to gain
- access
-
- )}
+
+
+
+
+
+
+
Access Restricted
+ {children || (
+
+ Your role has limited permissions, please
contact your administrator to gain
+ access
+
+ )}
+
diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx
new file mode 100644
index 000000000..321c03296
--- /dev/null
+++ b/frontend/src/components/v2/Badge/Badge.tsx
@@ -0,0 +1,32 @@
+import { cva, VariantProps } from "cva";
+import { twMerge } from "tailwind-merge";
+
+interface IProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+const badgeVariants = cva(
+ [
+ "inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100"
+ ],
+ {
+ variants: {
+ variant: {
+ primary: "bg-yellow/20 text-yellow",
+ danger: "bg-red/20 text-red",
+ success: "bg-green/20 text-green"
+ }
+ }
+ }
+);
+
+export type BadgeProps = VariantProps
& IProps;
+
+export const Badge = ({ children, className, variant }: BadgeProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/components/v2/Badge/index.tsx b/frontend/src/components/v2/Badge/index.tsx
new file mode 100644
index 000000000..5c7042709
--- /dev/null
+++ b/frontend/src/components/v2/Badge/index.tsx
@@ -0,0 +1 @@
+export { Badge } from "./Badge";
diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx
index 5536d699a..7707805e9 100644
--- a/frontend/src/components/v2/Button/Button.tsx
+++ b/frontend/src/components/v2/Button/Button.tsx
@@ -29,7 +29,7 @@ const buttonVariants = cva(
colorSchema: {
primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"],
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"],
- danger: ["bg-red", "text-white", "border-red hover:bg-opacity-90"],
+ danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"],
gray: ["bg-bunker-500", "text-bunker-200"]
},
variant: {
diff --git a/frontend/src/components/v2/Divider/Divider.tsx b/frontend/src/components/v2/Divider/Divider.tsx
new file mode 100644
index 000000000..39b0f84c5
--- /dev/null
+++ b/frontend/src/components/v2/Divider/Divider.tsx
@@ -0,0 +1,13 @@
+import { twMerge } from "tailwind-merge";
+
+interface IProps {
+ className?: string;
+}
+
+export const Divider = ({ className }: IProps): JSX.Element => {
+ return (
+
+ );
+};
diff --git a/frontend/src/components/v2/Divider/index.tsx b/frontend/src/components/v2/Divider/index.tsx
new file mode 100644
index 000000000..ac407aa37
--- /dev/null
+++ b/frontend/src/components/v2/Divider/index.tsx
@@ -0,0 +1 @@
+export { Divider } from "./Divider";
diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx
index 2a76be2ab..12a9094e0 100644
--- a/frontend/src/components/v2/Select/Select.tsx
+++ b/frontend/src/components/v2/Select/Select.tsx
@@ -41,18 +41,22 @@ export const Select = forwardRef(
ref={ref}
className={twMerge(
`inline-flex items-center justify-between rounded-md
- bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200 focus:bg-mineshaft-700/80`,
- className
+ bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200`,
+ className,
+ isDisabled && "cursor-not-allowed opacity-50"
)}
>
{props.icon ? : placeholder}
- {!isDisabled && (
-
-
-
- )}
+
+
+
+
{
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, TCreateAccessPolicyDTO>({
+ mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => {
+ const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
+ environment,
+ projectSlug,
+ approvals,
+ approvers,
+ secretPath,
+ name
+ });
+ return data;
+ },
+ onSuccess: (_, { projectSlug }) => {
+ queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
+ }
+ });
+};
+
+export const useUpdateAccessApprovalPolicy = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
+ mutationFn: async ({ id, approvers, approvals, name, secretPath }) => {
+ const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
+ approvals,
+ approvers,
+ secretPath,
+ name
+ });
+ return data;
+ },
+ onSuccess: (_, { projectSlug }) => {
+ queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
+ }
+ });
+};
+
+export const useDeleteAccessApprovalPolicy = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, TDeleteSecretPolicyDTO>({
+ mutationFn: async ({ id }) => {
+ const { data } = await apiRequest.delete(`/api/v1/access-approvals/policies/${id}`);
+ return data;
+ },
+ onSuccess: (_, { projectSlug }) => {
+ queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
+ }
+ });
+};
+
+export const useCreateAccessRequest = () => {
+ const queryClient = useQueryClient();
+ return useMutation<{}, {}, TCreateAccessRequestDTO>({
+ mutationFn: async ({ projectSlug, ...request }) => {
+ const { data } = await apiRequest.post(
+ "/api/v1/access-approvals/requests",
+ {
+ ...request,
+ permissions: request.permissions ? packRules(request.permissions) : undefined
+ },
+ {
+ params: {
+ projectSlug
+ }
+ }
+ );
+
+ return data;
+ },
+ onSuccess: (_, { projectSlug }) => {
+ queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
+ }
+ });
+};
+
+export const useReviewAccessRequest = () => {
+ const queryClient = useQueryClient();
+ return useMutation<
+ {},
+ {},
+ {
+ requestId: string;
+ status: "approved" | "rejected";
+ projectSlug: string;
+ envSlug?: string;
+ requestedBy?: string;
+ }
+ >({
+ mutationFn: async ({ requestId, status }) => {
+ const { data } = await apiRequest.post(
+ `/api/v1/access-approvals/requests/${requestId}/review`,
+ {
+ status
+ }
+ );
+ return data;
+ },
+ onSuccess: (_, { projectSlug, envSlug, requestedBy }) => {
+ queryClient.invalidateQueries(
+ accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy)
+ );
+ queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx
new file mode 100644
index 000000000..599962e43
--- /dev/null
+++ b/frontend/src/hooks/api/accessApproval/queries.tsx
@@ -0,0 +1,159 @@
+import { PackRule, unpackRules } from "@casl/ability/extra";
+import { useQuery, UseQueryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { TProjectPermission } from "../roles/types";
+import {
+ TAccessApprovalPolicy,
+ TAccessApprovalRequest,
+ TAccessRequestCount,
+ TGetAccessApprovalRequestsDTO,
+ TGetAccessPolicyApprovalCountDTO
+} from "./types";
+
+export const accessApprovalKeys = {
+ getAccessApprovalPolicies: (projectSlug: string) =>
+ [{ projectSlug }, "access-approval-policies"] as const,
+ getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
+ [{ workspaceId, environment }, "access-approval-policy"] as const,
+
+ getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) =>
+ [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const,
+ getAccessApprovalRequestCount: (projectSlug: string) =>
+ [{ projectSlug }, "access-approval-request-count"] as const
+};
+
+export const fetchPolicyApprovalCount = async ({
+ projectSlug,
+ envSlug
+}: TGetAccessPolicyApprovalCountDTO) => {
+ const { data } = await apiRequest.get<{ count: number }>(
+ "/api/v1/access-approvals/policies/count",
+ {
+ params: { projectSlug, envSlug }
+ }
+ );
+ return data.count;
+};
+
+export const useGetAccessPolicyApprovalCount = ({
+ projectSlug,
+ envSlug,
+ options = {}
+}: TGetAccessPolicyApprovalCountDTO & {
+ options?: UseQueryOptions<
+ number,
+ unknown,
+ number,
+ ReturnType
+ >;
+}) =>
+ useQuery({
+ queryFn: () => fetchPolicyApprovalCount({ projectSlug, envSlug }),
+ ...options,
+ enabled: Boolean(projectSlug) && (options?.enabled ?? true)
+ });
+
+const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => {
+ const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>(
+ "/api/v1/access-approvals/policies",
+ { params: { projectSlug } }
+ );
+ return data.approvals;
+};
+
+const fetchApprovalRequests = async ({
+ projectSlug,
+ envSlug,
+ authorProjectMembershipId
+}: TGetAccessApprovalRequestsDTO) => {
+ const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
+ "/api/v1/access-approvals/requests",
+ { params: { projectSlug, envSlug, authorProjectMembershipId } }
+ );
+
+ return data.requests.map((request) => ({
+ ...request,
+
+ privilege: request.privilege
+ ? {
+ ...request.privilege,
+ permissions: unpackRules(
+ request.privilege.permissions as unknown as PackRule[]
+ )
+ }
+ : null,
+ permissions: unpackRules(request.permissions as unknown as PackRule[])
+ }));
+};
+
+const fetchAccessRequestsCount = async (projectSlug: string) => {
+ const { data } = await apiRequest.get(
+ "/api/v1/access-approvals/requests/count",
+ { params: { projectSlug } }
+ );
+ return data;
+};
+
+export const useGetAccessRequestsCount = ({
+ projectSlug,
+ options = {}
+}: TGetAccessApprovalRequestsDTO & {
+ options?: UseQueryOptions<
+ TAccessRequestCount,
+ unknown,
+ { pendingCount: number; finalizedCount: number },
+ ReturnType
+ >;
+}) =>
+ useQuery({
+ queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug),
+ queryFn: () => fetchAccessRequestsCount(projectSlug),
+ ...options,
+ enabled: Boolean(projectSlug) && (options?.enabled ?? true)
+ });
+
+export const useGetAccessApprovalPolicies = ({
+ projectSlug,
+ envSlug,
+ authorProjectMembershipId,
+ options = {}
+}: TGetAccessApprovalRequestsDTO & {
+ options?: UseQueryOptions<
+ TAccessApprovalPolicy[],
+ unknown,
+ TAccessApprovalPolicy[],
+ ReturnType
+ >;
+}) =>
+ useQuery({
+ queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
+ queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
+ ...options,
+ enabled: Boolean(projectSlug) && (options?.enabled ?? true)
+ });
+
+export const useGetAccessApprovalRequests = ({
+ projectSlug,
+ envSlug,
+ authorProjectMembershipId,
+ options = {}
+}: TGetAccessApprovalRequestsDTO & {
+ options?: UseQueryOptions<
+ TAccessApprovalRequest[],
+ unknown,
+ TAccessApprovalRequest[],
+ ReturnType
+ >;
+}) =>
+ useQuery({
+ queryKey: accessApprovalKeys.getAccessApprovalRequests(
+ projectSlug,
+ envSlug,
+ authorProjectMembershipId
+ ),
+ queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
+ ...options,
+ enabled: Boolean(projectSlug) && (options?.enabled ?? true)
+ });
diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts
new file mode 100644
index 000000000..2176b8bc1
--- /dev/null
+++ b/frontend/src/hooks/api/accessApproval/types.ts
@@ -0,0 +1,139 @@
+import { TProjectPermission } from "../roles/types";
+import { WorkspaceEnv } from "../workspace/types";
+
+export type TAccessApprovalPolicy = {
+ id: string;
+ name: string;
+ approvals: number;
+ secretPath: string;
+ envId: string;
+ workspace: string;
+ environment: WorkspaceEnv;
+ projectId: string;
+ approvers: string[];
+};
+
+export type TAccessApprovalRequest = {
+ id: string;
+ policyId: string;
+ privilegeId: string | null;
+ requestedBy: string;
+ createdAt: Date;
+ updatedAt: Date;
+ isTemporary: boolean;
+ temporaryRange: string | null | undefined;
+
+ permissions: TProjectPermission[] | null;
+
+ // Computed
+ environmentName: string;
+ isApproved: boolean;
+
+ privilege: {
+ membershipId: string;
+ isTemporary: boolean;
+ temporaryMode?: string | null;
+ temporaryRange?: string | null;
+ temporaryAccessStartTime?: Date | null;
+ temporaryAccessEndTime?: Date | null;
+ permissions: TProjectPermission[];
+ isApproved: boolean;
+ } | null;
+
+ policy: {
+ id: string;
+ name: string;
+ approvals: number;
+ approvers: string[];
+ secretPath?: string | null;
+ envId: string;
+ };
+
+ reviewers: {
+ member: string;
+ status: string;
+ }[];
+};
+
+export type TAccessApproval = {
+ id: string;
+ policyId: string;
+ privilegeId: string;
+ requestedBy: string;
+};
+
+export type TAccessRequestCount = {
+ pendingCount: number;
+ finalizedCount: number;
+};
+
+export type TProjectUserPrivilege = {
+ projectMembershipId: string;
+ slug: string;
+ id: string;
+ createdAt: Date;
+ updatedAt: Date;
+ permissions?: TProjectPermission[];
+} & (
+ | {
+ isTemporary: true;
+ temporaryMode: string;
+ temporaryRange: string;
+ temporaryAccessStartTime: string;
+ temporaryAccessEndTime?: string;
+ }
+ | {
+ isTemporary: false;
+ temporaryMode?: null;
+ temporaryRange?: null;
+ temporaryAccessStartTime?: null;
+ temporaryAccessEndTime?: null;
+ }
+);
+
+export type TCreateAccessRequestDTO = {
+ projectSlug: string;
+} & Omit;
+
+export type TGetAccessApprovalRequestsDTO = {
+ projectSlug: string;
+ envSlug?: string;
+ authorProjectMembershipId?: string;
+};
+
+export type TGetAccessPolicyApprovalCountDTO = {
+ projectSlug: string;
+ envSlug: string;
+};
+
+export type TGetSecretApprovalPolicyOfBoardDTO = {
+ workspaceId: string;
+ environment: string;
+ secretPath: string;
+};
+
+export type TCreateAccessPolicyDTO = {
+ projectSlug: string;
+ name?: string;
+ environment: string;
+ approvers?: string[];
+ approvals?: number;
+ secretPath?: string;
+};
+
+export type TUpdateAccessPolicyDTO = {
+ id: string;
+ name?: string;
+ approvers?: string[];
+ secretPath?: string;
+ environment?: string;
+ approvals?: number;
+ // for invalidating list
+ projectSlug: string;
+};
+
+export type TDeleteSecretPolicyDTO = {
+ id: string;
+ // for invalidating list
+ projectSlug: string;
+};
diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx
index 574da5a31..61e8cb666 100644
--- a/frontend/src/hooks/api/index.tsx
+++ b/frontend/src/hooks/api/index.tsx
@@ -1,3 +1,4 @@
+export * from "./accessApproval";
export * from "./admin";
export * from "./apiKeys";
export * from "./auditLogs";
diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts
index 49949d88e..516a5d7cf 100644
--- a/frontend/src/hooks/api/types.ts
+++ b/frontend/src/hooks/api/types.ts
@@ -1,5 +1,6 @@
import { ZodIssue } from "zod";
+export type { TAccessApprovalPolicy } from "./accessApproval/types";
export type { TAuditLogStream } from "./auditLogStreams/types";
export type { GetAuthTokenAPI } from "./auth/types";
export type { IncidentContact } from "./incidentContacts/types";
@@ -49,13 +50,13 @@ export enum ApiErrorTypes {
export type TApiErrors =
| {
- error: ApiErrorTypes.ValidationError;
- message: ZodIssue[];
- statusCode: 403;
- }
+ error: ApiErrorTypes.ValidationError;
+ message: ZodIssue[];
+ statusCode: 403;
+ }
| { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 }
| {
- statusCode: 400;
- message: string;
- error: ApiErrorTypes.BadRequestError;
- };
+ statusCode: 400;
+ message: string;
+ error: ApiErrorTypes.BadRequestError;
+ };
diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx
index 2550150f3..2fcdc9339 100644
--- a/frontend/src/layouts/AppLayout/AppLayout.tsx
+++ b/frontend/src/layouts/AppLayout/AppLayout.tsx
@@ -5,7 +5,7 @@
/* eslint-disable no-var */
/* eslint-disable func-names */
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Image from "next/image";
@@ -64,6 +64,7 @@ import {
fetchOrgUsers,
useAddUserToWsNonE2EE,
useCreateWorkspace,
+ useGetAccessRequestsCount,
useGetOrgTrialUrl,
useGetSecretApprovalRequestCount,
useGetUserAction,
@@ -115,7 +116,7 @@ type TAddProjectFormData = yup.InferType;
export const AppLayout = ({ children }: LayoutProps) => {
const router = useRouter();
-
+
const { mutateAsync } = useGetOrgTrialUrl();
const { workspaces, currentWorkspace } = useWorkspace();
@@ -124,9 +125,15 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { user } = useUser();
const { subscription } = useSubscription();
const workspaceId = currentWorkspace?.id || "";
+ const projectSlug = currentWorkspace?.slug || "";
const { data: updateClosed } = useGetUserAction("december_update_closed");
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
+ const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
+
+ const pendingRequestsCount = useMemo(() => {
+ return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
+ }, [secretApprovalReqCount, accessApprovalRequestCount]);
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
@@ -554,10 +561,13 @@ export const AppLayout = ({ children }: LayoutProps) => {
}
icon="system-outline-189-domain-verification"
>
- Secret Approvals
- {Boolean(secretApprovalReqCount?.open) && (
+ Approvals
+ {Boolean(
+ secretApprovalReqCount?.open ||
+ accessApprovalRequestCount?.pendingCount
+ ) && (
- {secretApprovalReqCount?.open}
+ {pendingRequestsCount}
)}
diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx
index 586ed62f9..de866a323 100644
--- a/frontend/src/pages/login/select-organization.tsx
+++ b/frontend/src/pages/login/select-organization.tsx
@@ -35,8 +35,6 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization();
const { user, isLoading: userLoading } = useUser();
-
-
const queryParams = new URLSearchParams(window.location.search);
const logout = useLogoutUser(true);
@@ -153,7 +151,7 @@ export default function LoginPage() {
- You‘re currently logged in as {user.email}
+ You‘re currently logged in as {user.username}
Not you?{" "}
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx
index 228683726..6fb97063b 100644
--- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx
+++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx
@@ -1,9 +1,11 @@
+import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
faClock,
+ faLockOpen,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
@@ -44,11 +46,13 @@ import {
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
+ useCreateAccessRequest,
useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege,
useListProjectUserPrivileges,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
+import { TAccessApprovalPolicy } from "@app/hooks/api/types";
const secretPermissionSchema = z.object({
secretPath: z.string().optional(),
@@ -70,51 +74,105 @@ const secretPermissionSchema = z.object({
])
});
type TSecretPermissionForm = z.infer;
-const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
+export const SpecificPrivilegeSecretForm = ({
+ privilege,
+ policies,
+ onClose
+}: {
+ privilege?: TProjectUserPrivilege;
+ policies?: TAccessApprovalPolicy[];
+ onClose?: () => void;
+}) => {
const { currentWorkspace } = useWorkspace();
+
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
- "deletePrivilege"
+ "deletePrivilege",
+ "requestAccess"
] as const);
const { permission } = useProjectPermission();
- const isMemberEditDisabled = permission.cannot(
- ProjectPermissionActions.Edit,
- ProjectPermissionSub.Member
- );
+ const isMemberEditDisabled =
+ permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
+ const requestAccess = useCreateAccessRequest();
const privilegeForm = useForm({
resolver: zodResolver(secretPermissionSchema),
values: {
- environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
- // secret path will be inside $glob operator
- secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
- read: privilege.permissions?.some(({ action }) =>
- action.includes(ProjectPermissionActions.Read)
- ),
- edit: privilege.permissions?.some(({ action }) =>
- action.includes(ProjectPermissionActions.Edit)
- ),
- create: privilege.permissions?.some(({ action }) =>
- action.includes(ProjectPermissionActions.Create)
- ),
- delete: privilege.permissions?.some(({ action }) =>
- action.includes(ProjectPermissionActions.Delete)
- ),
- // zod will pick it
- temporaryAccess: privilege
+ ...(privilege
+ ? {
+ environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
+ // secret path will be inside $glob operator
+ secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
+ read: privilege.permissions?.some(({ action }) =>
+ action.includes(ProjectPermissionActions.Read)
+ ),
+ edit: privilege.permissions?.some(({ action }) =>
+ action.includes(ProjectPermissionActions.Edit)
+ ),
+ create: privilege.permissions?.some(({ action }) =>
+ action.includes(ProjectPermissionActions.Create)
+ ),
+ delete: privilege.permissions?.some(({ action }) =>
+ action.includes(ProjectPermissionActions.Delete)
+ ),
+ // zod will pick it
+ temporaryAccess: privilege
+ }
+ : {
+ environmentSlug: currentWorkspace?.environments?.[0].slug!,
+ read: false,
+ edit: false,
+ create: false,
+ delete: false,
+ temporaryAccess: {
+ isTemporary: false
+ }
+ })
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
- const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug");
+ const selectedEnvironment = privilegeForm.watch("environmentSlug");
+ const secretPath = privilegeForm.watch("secretPath");
+
+ const readAccess = privilegeForm.watch("read");
+ const createAccess = privilegeForm.watch("create");
+ const editAccess = privilegeForm.watch("edit");
+ const deleteAccess = privilegeForm.watch("delete");
+
+ const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
+
+ const selectablePaths = useMemo(() => {
+ if (!policies) return [];
+ const environmentPolicies = policies.filter(
+ (policy) => policy.environment.slug === selectedEnvironment
+ );
+
+ privilegeForm.setValue("secretPath", "", {
+ shouldValidate: true
+ });
+
+ return [...environmentPolicies.map((policy) => policy.secretPath)];
+ }, [policies, selectedEnvironment]);
+
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
+ if (!privilege) {
+ createNotification({
+ type: "error",
+ text: "No privilege to update found.",
+ title: "Error"
+ });
+
+ return;
+ }
+
if (updateUserPrivilege.isLoading) return;
try {
const actions = [
@@ -152,6 +210,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
};
const handleDeletePrivilege = async () => {
+ if (!privilege) {
+ createNotification({
+ type: "error",
+ text: "No privilege to delete found.",
+ title: "Error"
+ });
+ return;
+ }
+
if (deleteUserPrivilege.isLoading) return;
try {
await deleteUserPrivilege.mutateAsync({
@@ -170,35 +237,100 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
}
};
+ // This is used for requesting access additional privileges, not directly creating a privilege!
+ const handleRequestAccess = async (data: TSecretPermissionForm) => {
+ if (!policies) return;
+ if (!currentWorkspace) {
+ createNotification({
+ type: "error",
+ text: "No workspace found.",
+ title: "Error"
+ });
+ return;
+ }
+
+ if (!data.secretPath) {
+ createNotification({
+ type: "error",
+ text: "Please select a secret path",
+ title: "Error"
+ });
+ return;
+ }
+
+ const actions = [
+ { action: ProjectPermissionActions.Read, allowed: data.read },
+ { action: ProjectPermissionActions.Create, allowed: data.create },
+ { action: ProjectPermissionActions.Delete, allowed: data.delete },
+ { action: ProjectPermissionActions.Edit, allowed: data.edit }
+ ];
+ const conditions: Record = { environment: data.environmentSlug };
+ if (data.secretPath) {
+ conditions.secretPath = { $glob: data.secretPath };
+ }
+ await requestAccess.mutateAsync({
+ ...data,
+ ...(data.temporaryAccess.isTemporary && {
+ temporaryRange: data.temporaryAccess.temporaryRange
+ }),
+ projectSlug: currentWorkspace.slug,
+ isTemporary: data.temporaryAccess.isTemporary,
+ permissions: actions
+ .filter(({ allowed }) => allowed)
+ .map(({ action }) => ({
+ action,
+ subject: [ProjectPermissionSub.Secrets],
+ conditions
+ }))
+ });
+
+ createNotification({
+ type: "success",
+ text: "Successfully requested access"
+ });
+ privilegeForm.reset();
+ if (onClose) onClose();
+ };
+
+ const handleSubmit = async (data: TSecretPermissionForm) => {
+ if (privilege) {
+ handleUpdatePrivilege(data);
+ } else {
+ handleRequestAccess(data);
+ }
+ };
+
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
- if (exactTime)
+
+ if (exactTime && !policies) {
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
+ }
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
-