diff --git a/README.md b/README.md index d68481428e..9a825e09df 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,6 @@ Hiring (Remote/SF) -

- - - - - Deploy to DO - -

-

Infisical is released under the MIT license. @@ -75,7 +66,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus ### Key Management (KMS): -- **[Cryptograhic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API. +- **[Cryptographic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API. - **[Encrypt and Decrypt Data](https://infisical.com/docs/documentation/platform/kms#guide-to-encrypting-data)**: Use symmetric keys to encrypt and decrypt data. ### General Platform: diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 9ca485236e..58f2bffebf 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -53,13 +53,13 @@ export default { extension: "ts" }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(cfg.REDIS_URL, { dbConnectionUrl: cfg.DB_CONNECTION_URI }); const keyStore = keyStoreFactory(cfg.REDIS_URL); const hsmModule = initializeHsmModule(); hsmModule.initialize(); - const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule() }); + const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule(), redis }); // @ts-expect-error type globalThis.testServer = server; diff --git a/backend/package-lock.json b/backend/package-lock.json index 2fba00120e..9d0a3c8c9f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,7 +49,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -5678,14 +5677,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@grpc/grpc-js": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz", @@ -9970,18 +9961,6 @@ "optional": true, "peer": true }, - "node_modules/@team-plain/typescript-sdk": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz", - "integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "graphql": "^16.6.0", - "zod": "3.22.4" - } - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -15180,14 +15159,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0dafc475cb..a7321f67d2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -157,7 +157,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", diff --git a/backend/src/@types/fastify-request-context.d.ts b/backend/src/@types/fastify-request-context.d.ts index caef4d5b26..fc8d94e071 100644 --- a/backend/src/@types/fastify-request-context.d.ts +++ b/backend/src/@types/fastify-request-context.d.ts @@ -2,6 +2,6 @@ import "@fastify/request-context"; declare module "@fastify/request-context" { interface RequestContextData { - requestId: string; + reqId: string; } } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2843648da7..4221eadcb8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,5 +1,7 @@ import "fastify"; +import { Redis } from "ioredis"; + 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"; @@ -87,6 +89,10 @@ import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; declare module "fastify" { + interface Session { + callbackPort: string; + } + interface FastifyRequest { realIp: string; // used for mfa session authentication @@ -115,6 +121,7 @@ declare module "fastify" { } interface FastifyInstance { + redis: Redis; services: { login: TAuthLoginFactory; password: TAuthPasswordFactory; diff --git a/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts new file mode 100644 index 0000000000..c7fb6fe39c --- /dev/null +++ b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts @@ -0,0 +1,59 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (!hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + if (!hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("SET NULL"); + }); +} + +export async function down(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + if (hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index f4c525a4f6..3650face94 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 94aeba0509..06ae3e5c46 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7dbb62fc22..4aa26eb36d 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -109,7 +109,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), reviewers: z .object({ diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 4b1566c557..1d24c05788 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -8,6 +7,7 @@ import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,15 +48,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), - name: z - .string() - .describe(DYNAMIC_SECRETS.CREATE.name) - .min(1) - .toLowerCase() - .max(64) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) + name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 780e5ec005..67f955ecb0 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { GROUPS } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -14,15 +15,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(GROUPS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(GROUPS.CREATE.slug), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) }), response: { @@ -100,14 +93,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { body: z .object({ name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(GROUPS.UPDATE.slug), + slug: slugSchema({ min: 5, max: 36 }).describe(GROUPS.UPDATE.slug), role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) }) .partial(), @@ -166,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search) + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -179,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - isPartOfGroup: z.boolean() + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() }) ) .array(), diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index d342f95ce7..1eadb40515 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -8,6 +8,7 @@ import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ProjectPermissionSchema, @@ -33,17 +34,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -77,7 +68,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: false, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -103,17 +94,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -159,7 +140,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: true, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -189,16 +170,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), privilegeDetails: z .object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index e675121e97..cd25c5be52 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -9,7 +9,6 @@ import { Authenticator, Strategy } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; -import { Redis } from "ioredis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; @@ -21,7 +20,6 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); - const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); /* @@ -30,7 +28,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { - Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js */ const redisStore = new RedisStore({ - client: redis, + client: server.redis, prefix: "oidc-session:", ttl: 600 // 10 minutes }); diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 232f4b0b53..30f31c545b 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,17 +18,10 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z - .string() - .min(1) - .trim() - .refine( - (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), - "Please choose a different slug, the slug you have entered is reserved" - ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }), + slug: slugSchema({ min: 1, max: 64 }).refine( + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -94,17 +87,13 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z - .string() - .trim() - .optional() + // TODO: Switch to slugSchema after verifying correct methods with Akhil - Omar 11/24 + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.keys(OrgMembershipRole).includes(val), "Please choose a different slug, the slug you have entered is reserved." ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional(), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array().optional() diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index ba2c0aa9f5..0fa35ab1df 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,5 +1,4 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; @@ -9,6 +8,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -32,18 +32,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), @@ -94,21 +87,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .describe(PROJECT_ROLE.UPDATE.slug) + .optional(), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index 5b115ab4ec..60f93d65dc 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; @@ -8,22 +7,13 @@ import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-tem import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { ProjectTemplates } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { AuthMode } from "@app/services/auth/auth-type"; const MAX_JSON_SIZE_LIMIT_IN_BYTES = 32_768; -const SlugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Must be valid slug format" - }); - const isReservedRoleSlug = (slug: string) => Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); @@ -34,14 +24,14 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ roles: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: UnpackedPermissionSchema.array() }) .array(), environments: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -50,7 +40,7 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ const ProjectTemplateRolesSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: ProjectPermissionV2Schema.array() }) .array() @@ -78,7 +68,7 @@ const ProjectTemplateRolesSchema = z const ProjectTemplateEnvironmentsSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -188,9 +178,11 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) schema: { description: "Create a project template.", body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }).describe(ProjectTemplates.CREATE.name), + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) + .describe(ProjectTemplates.CREATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( @@ -230,9 +222,10 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) description: "Update a project template.", params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.UPDATE.templateId) }), body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }) + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) .optional() .describe(ProjectTemplates.UPDATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description), diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 5fbf784f61..e1c56583c7 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -52,7 +52,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }) .array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -260,7 +261,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index e58a6335b6..bb3e179dd8 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/pr import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedUserProjectAdditionalPrivilegeSchema } from "@app/server/routes/santizedSchemas/user-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,17 +22,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr schema: { body: z.object({ projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -87,15 +78,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), body: z .object({ - slug: z - .string() - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 5df03f68d1..7934c3f904 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-p import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedIdentityPrivilegeSchema } from "@app/server/routes/santizedSchemas/identitiy-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -28,17 +29,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId), projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -100,16 +91,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F id: z.string().trim().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.id) }), body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission), diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 70511ce87e..0152104c66 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -1,11 +1,11 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -29,18 +29,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ min: 1, max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), @@ -90,21 +83,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional() + .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() 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 index 220701410f..e14451498a 100644 --- 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 @@ -139,5 +139,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...accessApprovalPolicyOrm, find, findById }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await accessApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; }; 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 index ee7cf25729..24436e695a 100644 --- 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 @@ -8,7 +8,11 @@ import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; +import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal"; +import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { @@ -21,7 +25,7 @@ import { TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; -type TSecretApprovalPolicyServiceFactoryDep = { +type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; permissionService: Pick; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; @@ -30,6 +34,9 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; + accessApprovalRequestDAL: Pick; + additionalPrivilegeDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -41,8 +48,11 @@ export const accessApprovalPolicyServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - userDAL -}: TSecretApprovalPolicyServiceFactoryDep) => { + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL, + accessApprovalRequestReviewerDAL +}: TAccessApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, actor, @@ -189,7 +199,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null }); return accessApprovalPolicies; }; @@ -326,7 +336,29 @@ export const accessApprovalPolicyServiceFactory = ({ ProjectPermissionSub.SecretApproval ); - await accessApprovalPolicyDAL.deleteById(policyId); + await accessApprovalPolicyDAL.transaction(async (tx) => { + await accessApprovalPolicyDAL.softDeleteById(policyId, tx); + const allAccessApprovalRequests = await accessApprovalRequestDAL.find({ policyId }); + + if (allAccessApprovalRequests.length) { + const accessApprovalRequestsIds = allAccessApprovalRequests.map((request) => request.id); + + const privilegeIdsArray = allAccessApprovalRequests + .map((request) => request.privilegeId) + .filter((id): id is string => id != null); + + if (privilegeIdsArray.length) { + await additionalPrivilegeDAL.delete({ $in: { id: privilegeIdsArray } }, tx); + } + + await accessApprovalRequestReviewerDAL.update( + { $in: { id: accessApprovalRequestsIds }, status: ApprovalStatus.PENDING }, + { status: ApprovalStatus.REJECTED }, + tx + ); + } + }); + return policy; }; @@ -356,7 +388,11 @@ export const accessApprovalPolicyServiceFactory = ({ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + const policies = await accessApprovalPolicyDAL.find({ + envId: environment.id, + projectId: project.id, + deletedAt: null + }); if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` }); return { count: policies.length }; 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 index 8784d05e2c..c1ccedff7d 100644 --- 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 @@ -61,7 +61,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), + db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -118,7 +119,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: doc.policyApprovals, secretPath: doc.policySecretPath, enforcementLevel: doc.policyEnforcementLevel, - envId: doc.policyEnvId + envId: doc.policyEnvId, + deletedAt: doc.policyDeletedAt }, requestedByUser: { userId: doc.requestedByUserId, @@ -141,7 +143,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId }), childrenMapper: [ { @@ -252,7 +254,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -271,7 +274,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + deletedAt: el.policyDeletedAt }, requestedByUser: { userId: el.requestedByUserId, @@ -363,6 +367,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .where(`${TableName.Environment}.projectId`, projectId) + .where(`${TableName.AccessApprovalPolicy}.deletedAt`, null) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); 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 index 14accff41f..b8475c4460 100644 --- 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 @@ -130,6 +130,9 @@ export const accessApprovalRequestServiceFactory = ({ message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.` }); } + if (policy.deletedAt) { + throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); + } const approverIds: string[] = []; const approverGroupIds: string[] = []; @@ -309,6 +312,12 @@ export const accessApprovalRequestServiceFactory = ({ } const { policy } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index a1c35bc403..e312c38866 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -37,7 +37,7 @@ export const auditLogQueueServiceFactory = async ({ const appCfg = getConfig(); const pushToLog = async (data: TCreateAuditLogDTO) => { - if (appCfg.USE_PG_QUEUE) { + if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) { await queueService.queuePg(QueueJobs.AuditLog, data, { retryLimit: 10, retryBackoff: true @@ -52,96 +52,98 @@ export const auditLogQueueServiceFactory = async ({ } }; - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; + if (appCfg.SHOULD_INIT_PG_QUEUE) { + await queueService.startPg( + QueueJobs.AuditLog, + async ([job]) => { + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; + const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + project = await projectDAL.findById(projectId as string); + orgId = project.orgId; + } - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } + const plan = await licenseService.getPlan(orgId); + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; - const ttl = ttlInDays * MS_IN_DAY; + const ttl = ttlInDays * MS_IN_DAY; - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - infisicalSymmetricDecrypt({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) }); - - return request.post(url, auditLog, { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - }); - } - ) - ); - }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 - } - ); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + } queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 51090e594d..601436d1b6 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -60,6 +60,7 @@ export enum EventType { DELETE_SECRETS = "delete-secrets", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", @@ -357,6 +358,13 @@ interface AuthorizeIntegrationEvent { }; } +interface UpdateIntegrationAuthEvent { + type: EventType.UPDATE_INTEGRATION_AUTH; + metadata: { + integration: string; + }; +} + interface UnauthorizeIntegrationEvent { type: EventType.UNAUTHORIZE_INTEGRATION; metadata: { @@ -1680,6 +1688,7 @@ export type Event = | DeleteSecretBatchEvent | GetWorkspaceKeyEvent | AuthorizeIntegrationEvent + | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f61138..fc38a2a9b4 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { EFilterReturnedUsers } from "./group-types"; + export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { @@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => { offset = 0, limit, username, // depreciated in favor of search - search + search, + filter }: { orgId: string; groupId: string; @@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; }) => { try { const query = db @@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => { void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); } + switch (filter) { + case EFilterReturnedUsers.EXISTING_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + break; + case EFilterReturnedUsers.NON_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + const members = await query; return { members: members.map( - ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + ({ + email, + username: memberUsername, + firstName, + lastName, + userId, + groupId: memberGroupId, + joinedGroupAt + }) => ({ id: userId, email, username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId + isPartOfGroup: !!memberGroupId, + joinedGroupAt }) ), // @ts-expect-error col select is raw and not strongly typed diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7e7139a6b0..68c48524b6 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -222,7 +222,8 @@ export const groupServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - search + search, + filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -251,7 +252,8 @@ export const groupServiceFactory = ({ offset, limit, username, - search + search, + filter }); return { users: members, totalCount }; @@ -283,8 +285,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); const user = await userDAL.findOne({ username }); @@ -338,8 +340,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); const user = await userDAL.findOne({ username }); diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a6eb4782b3..9424075ca8 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -39,6 +39,7 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; } & TGenericPermission; export type TAddUserToGroupDTO = { @@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { projectBotDAL: Pick; tx?: Knex; }; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 26a694a4a5..b1c40a8793 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; import ms from "ms"; @@ -62,7 +62,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -139,7 +142,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -216,7 +222,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -258,7 +267,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); return { ...identityPrivilege, @@ -289,7 +301,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -321,7 +336,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find( { diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 4811eb52ae..127de1383b 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; @@ -69,7 +69,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -146,7 +150,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, @@ -241,7 +249,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -294,7 +306,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -333,7 +348,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ projectMembershipId: identityProjectMembership.id diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c6e574fb12..f6d7f715f7 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -82,6 +82,10 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type IdentityManagementSubjectFields = { + identityId: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionActions, @@ -121,7 +125,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] - | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ + ProjectPermissionActions, + ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) + ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] @@ -213,6 +220,21 @@ const SecretConditionV2Schema = z }) .partial(); +const IdentityManagementConditionSchema = z + .object({ + identityId: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -262,12 +284,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.ServiceTokens).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -373,6 +389,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); @@ -417,6 +439,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ), + conditions: IdentityManagementConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); @@ -697,26 +729,26 @@ export const buildServiceTokenProjectPermission = ( [ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretFolders].forEach( (subject) => { if (canWrite) { - // TODO: @Akhi - // @ts-expect-error type can(ProjectPermissionActions.Edit, subject, { + // TODO: @Akhi + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); - // @ts-expect-error type can(ProjectPermissionActions.Create, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); - // @ts-expect-error type can(ProjectPermissionActions.Delete, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); } if (canRead) { - // @ts-expect-error type can(ProjectPermissionActions.Read, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index bb77660aa6..6644b14b8c 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -177,5 +177,10 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...secretApprovalPolicyOrm, findById, find }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await secretApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...secretApprovalPolicyOrm, findById, find, softDeleteById }; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index cb34526854..4e7bf6d153 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -11,6 +11,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; +import { RequestState } from "../secret-approval-request/secret-approval-request-types"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { @@ -34,6 +36,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; licenseService: Pick; + secretApprovalRequestDAL: Pick; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -44,7 +47,8 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyApproverDAL, projectEnvDAL, userDAL, - licenseService + licenseService, + secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createSecretApprovalPolicy = async ({ name, @@ -301,8 +305,16 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - await secretApprovalPolicyDAL.deleteById(secretPolicyId); - return sapPolicy; + const deletedPolicy = await secretApprovalPolicyDAL.transaction(async (tx) => { + await secretApprovalRequestDAL.update( + { policyId: secretPolicyId, status: RequestState.Open }, + { status: RequestState.Closed }, + tx + ); + const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx); + return updatedPolicy; + }); + return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment }; }; const getSecretApprovalPolicyByProjectId = async ({ @@ -321,7 +333,7 @@ export const secretApprovalPolicyServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const sapPolicies = await secretApprovalPolicyDAL.find({ projectId }); + const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; }; @@ -334,7 +346,7 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const policies = await secretApprovalPolicyDAL.find({ envId: env.id }); + const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null }); if (!policies.length) return; // this will filter policies either without scoped to secret path or the one that matches with secret path const policiesFilteredByPath = policies.filter( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 803b9464c2..f842359bca 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -111,7 +111,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -147,7 +148,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, - envId: el.policyEnvId + envId: el.policyEnvId, + deletedAt: el.policyDeletedAt } }), childrenMapper: [ @@ -222,6 +224,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) .where({ projectId }) .andWhere( (bd) => @@ -229,6 +236,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) ) + .andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null)) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .count("status") diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a39f44fd62..e1c75b3f99 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -232,10 +232,10 @@ export const secretApprovalRequestServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( secretApprovalRequest.id ); - secrets = encrypedSecrets.map((el) => ({ + secrets = encryptedSecrets.map((el) => ({ ...el, secretKey: el.key, id: el.id, @@ -274,8 +274,8 @@ export const secretApprovalRequestServiceFactory = ({ })); } else { if (!botKey) throw new NotFoundError({ message: `Project bot key not found`, name: "BotKeyNotFound" }); // CLI depends on this error message. TODO(daniel): Make API check for name BotKeyNotFound instead of message - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); - secrets = encrypedSecrets.map((el) => ({ + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); + secrets = encryptedSecrets.map((el) => ({ ...el, ...decryptSecretWithBot(el, botKey), secret: el.secret @@ -323,6 +323,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -383,6 +389,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -433,6 +445,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy, folderId, projectId } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da295..7118373262 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -19,7 +19,9 @@ export const GROUPS = { offset: "The offset to start from. If you enter 10, it will start from the 10th user.", limit: "The number of users to return.", username: "The username to search for.", - search: "The text string that user email or name will be filtered by." + search: "The text string that user email or name will be filtered by.", + filterUsers: + "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, ADD_USER: { id: "The ID of the group to add the user to.", @@ -1032,6 +1034,9 @@ export const INTEGRATION_AUTH = { DELETE_BY_ID: { integrationAuthId: "The ID of integration authentication object to delete." }, + UPDATE_BY_ID: { + integrationAuthId: "The ID of integration authentication object to update." + }, CREATE_ACCESS_TOKEN: { workspaceId: "The ID of the project to create the integration auth for.", integration: "The slug of integration for the auth object.", @@ -1088,11 +1093,13 @@ export const INTEGRATION = { }, UPDATE: { integrationId: "The ID of the integration object.", + region: "AWS region to sync secrets to.", app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", appId: "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", isActive: "Whether the integration should be active or disabled.", secretPath: "The path of the secrets to sync secrets from.", + path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault.", owner: "External integration providers service entity owner. Used in Github.", targetEnvironment: "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8a4cf07b30..7bb95468a6 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -166,8 +166,7 @@ const envSchema = z OTEL_COLLECTOR_BASIC_AUTH_PASSWORD: zpStr(z.string().optional()), OTEL_EXPORT_TYPE: z.enum(["prometheus", "otlp"]).optional(), - PLAIN_API_KEY: zpStr(z.string().optional()), - PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), + PYLON_API_KEY: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), @@ -180,7 +179,8 @@ const envSchema = z HSM_KEY_LABEL: zpStr(z.string().optional()), HSM_SLOT: z.coerce.number().optional().default(0), - USE_PG_QUEUE: zodStrBool.default("false") + USE_PG_QUEUE: zodStrBool.default("false"), + SHOULD_INIT_PG_QUEUE: zodStrBool.default("false") }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index 5563499e58..9676496f72 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -89,9 +89,9 @@ const redactedKeys = [ const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID"; -const extractRequestId = () => { +const extractReqId = () => { try { - return requestContext.get("requestId") || UNKNOWN_REQUEST_ID; + return requestContext.get("reqId") || UNKNOWN_REQUEST_ID; } catch (err) { console.log("failed to get request context", err); return UNKNOWN_REQUEST_ID; @@ -133,22 +133,22 @@ export const initLogger = async () => { const wrapLogger = (originalLogger: Logger): CustomLogger => { // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).info(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).error(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).warn(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).debug(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args); }; return originalLogger; diff --git a/backend/src/main.ts b/backend/src/main.ts index 8e36029747..850298f89a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import "./lib/telemetry/instrumentation"; import dotenv from "dotenv"; +import { Redis } from "ioredis"; import path from "path"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; @@ -56,15 +57,20 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(appCfg.REDIS_URL, { + dbConnectionUrl: appCfg.DB_CONNECTION_URI, + dbRootCert: appCfg.DB_ROOT_CERT + }); + await queue.initialize(); const keyStore = keyStoreFactory(appCfg.REDIS_URL); + const redis = new Redis(appCfg.REDIS_URL); const hsmModule = initializeHsmModule(); hsmModule.initialize(); - const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore }); + const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore, redis }); const bootstrap = await bootstrapCheck({ db }); // eslint-disable-next-line diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f18562513c..051fe9cbd9 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -8,6 +8,7 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TFailedIntegrationSyncEmailsPayload, @@ -186,7 +187,10 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => { +export const queueServiceFactory = ( + redisUrl: string, + { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } +) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, @@ -197,7 +201,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried - deleteAfterSeconds: 30 + deleteAfterSeconds: 30, + ssl: dbRootCert + ? { + rejectUnauthorized: true, + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + } + : false }); const queueContainerPg = {} as Record; @@ -208,11 +218,15 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = >; const initialize = async () => { - await pgBoss.start(); + const appCfg = getConfig(); + if (appCfg.SHOULD_INIT_PG_QUEUE) { + logger.info("Initializing pg-queue..."); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); + } }; const start = ( diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 83c34e5a73..52fc989cf8 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -12,6 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit"; import { fastifyRequestContext } from "@fastify/request-context"; import fastify from "fastify"; +import { Redis } from "ioredis"; import { Knex } from "knex"; import { HsmModule } from "@app/ee/services/hsm/hsm-types"; @@ -41,10 +42,11 @@ type TMain = { queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; hsmModule: HsmModule; + redis: Redis; }; // Run the server! -export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore }: TMain) => { +export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore, redis }: TMain) => { const appCfg = getConfig(); const server = fastify({ @@ -60,6 +62,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + server.decorate("redis", redis); server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; @@ -109,9 +112,9 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(maintenanceMode); await server.register(fastifyRequestContext, { - defaultStoreValues: (request) => ({ - requestId: request.id, - log: request.log.child({ requestId: request.id }) + defaultStoreValues: (req) => ({ + reqId: req.id, + log: req.log.child({ reqId: req.id }) }) }); diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts new file mode 100644 index 0000000000..ed97cb7d0f --- /dev/null +++ b/backend/src/server/lib/schemas.ts @@ -0,0 +1,23 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 920f17efbc..ac4803c985 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -40,42 +40,42 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider if (error instanceof BadRequestError) { void res .status(HttpStatusCodes.BadRequest) - .send({ requestId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); + .send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); } else if (error instanceof NotFoundError) { void res .status(HttpStatusCodes.NotFound) - .send({ requestId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); + .send({ reqId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); } else if (error instanceof UnauthorizedError) { void res.status(HttpStatusCodes.Unauthorized).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Unauthorized, message: error.message, error: error.name }); } else if (error instanceof DatabaseError || error instanceof InternalServerError) { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); } else if (error instanceof GatewayTimeoutError) { void res.status(HttpStatusCodes.GatewayTimeout).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.GatewayTimeout, message: error.message, error: error.name }); } else if (error instanceof ZodError) { void res.status(HttpStatusCodes.UnprocessableContent).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.UnprocessableContent, error: "ValidationFailure", message: error.issues }); } else if (error instanceof ForbiddenError) { void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}`, @@ -88,28 +88,28 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider }); } else if (error instanceof ForbiddenRequestError) { void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, message: error.message, error: error.name }); } else if (error instanceof RateLimitError) { void res.status(HttpStatusCodes.TooManyRequests).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.TooManyRequests, message: error.message, error: error.name }); } else if (error instanceof ScimRequestError) { void res.status(error.status).send({ - requestId: req.id, + reqId: req.id, schemas: error.schemas, status: error.status, detail: error.detail }); } else if (error instanceof OidcAuthError) { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: error.message, error: error.name @@ -128,14 +128,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider } void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "TokenError", message: errorMessage }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, error: "InternalServerError", message: "Something went wrong" diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f07579bd6..806b3575c0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -414,7 +414,8 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL, licenseService, - userDAL + userDAL, + secretApprovalRequestDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -994,7 +995,10 @@ export const registerRoutes = async ( projectEnvDAL, projectMembershipDAL, projectDAL, - userDAL + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + accessApprovalRequestReviewerDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ @@ -1238,7 +1242,8 @@ export const registerRoutes = async ( }); const userEngagementService = userEngagementServiceFactory({ - userDAL + userDAL, + orgDAL }); const slackService = slackServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index e24c5db6ba..69a648d9ed 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -30,25 +30,25 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({ export const DefaultResponseErrorsSchema = { 400: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(400), message: z.string(), error: z.string() }), 404: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(404), message: z.string(), error: z.string() }), 401: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(401), message: z.string(), error: z.string() }), 403: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(403), message: z.string(), details: z.any().optional(), @@ -56,13 +56,13 @@ export const DefaultResponseErrorsSchema = { }), // Zod errors return a message of varying shapes and sizes, so z.any() is used here 422: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(422), message: z.any(), error: z.string() }), 500: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(500), message: z.string(), error: z.string() diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 18d13e67fa..e3982f3d69 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; @@ -8,19 +7,12 @@ import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CmekOrderBy } from "@app/services/cmek/cmek-types"; -const keyNameSchema = z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be slug friendly" - }); +const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); const base64Schema = z.string().superRefine((val, ctx) => { diff --git a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts index 032deda7d3..67db5de6fe 100644 --- a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts +++ b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ExternalGroupOrgRoleMappingsSchema } from "@app/db/schemas/external-group-org-role-mappings"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,13 +48,7 @@ export const registerExternalGroupOrgRoleMappingRouter = async (server: FastifyZ mappings: z .object({ groupName: z.string().trim().min(1), - roleSlug: z - .string() - .min(1) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Role must be a valid slug" - }) + roleSlug: slugSchema({ max: 64 }) }) .array() }), diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 575544cc77..e00e06b775 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -6,6 +6,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { OctopusDeployScope } from "@app/services/integration-auth/integration-auth-types"; +import { Integrations } from "@app/services/integration-auth/integration-list"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -82,6 +83,67 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/:integrationAuthId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update the integration authentication object required for syncing secrets.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) + }), + body: z.object({ + integration: z.nativeEnum(Integrations).optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), + accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), + awsAssumeIamRoleArn: z + .string() + .url() + .trim() + .optional() + .describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn), + url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url), + namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace), + refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken) + }), + response: { + 200: z.object({ + integrationAuth: integrationAuthPubSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.updateIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + integrationAuthId: req.params.integrationAuthId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integrationAuth.projectId, + event: { + type: EventType.UPDATE_INTEGRATION_AUTH, + metadata: { + integration: integrationAuth.integration + } + } + }); + return { integrationAuth }; + } + }); + server.route({ method: "DELETE", url: "/", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 40141e2c09..059d244638 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -141,7 +141,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment), - metadata: IntegrationMetadataSchema.optional() + path: z.string().trim().optional().describe(INTEGRATION.UPDATE.path), + metadata: IntegrationMetadataSchema.optional(), + region: z.string().trim().optional().describe(INTEGRATION.UPDATE.region) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 07f795779f..1327faeb1e 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -14,6 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; @@ -243,22 +243,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { params: z.object({ organizationId: z.string().trim() }), body: z.object({ name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), - slug: z - .string() - .trim() - .max(64, { message: "Slug must be 64 or fewer characters" }) - .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") - .optional(), + slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), - defaultMembershipRoleSlug: z - .string() - .min(1) - .trim() - .refine((v) => slugify(v) === v, { - message: "Membership role must be a valid slug" - }) - .optional(), + defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index c5ded83e46..7050166960 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ENVIRONMENTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -124,13 +124,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), position: z.number().min(1).optional().describe(ENVIRONMENTS.CREATE.position), - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.CREATE.slug) + slug: slugSchema({ max: 64 }).describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -188,14 +182,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z - .string() - .trim() - .optional() - .refine((v) => !v || slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.UPDATE.slug), + slug: slugSchema({ max: 64 }).optional().describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 7d696999e8..ed9837084a 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; import { SECRET_TAGS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -111,14 +111,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.CREATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.CREATE.slug), color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { @@ -153,14 +146,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.UPDATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.UPDATE.slug), color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) }), response: { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 0601e2d1f1..f05aa18f0b 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SlackIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -35,12 +35,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), + slug: slugSchema({ max: 64 }), description: z.string().optional() }), response: { @@ -288,13 +283,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional(), + slug: slugSchema({ max: 64 }).optional(), description: z.string().optional() }), response: { diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 9b3ef6528a..a4570389f4 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -8,6 +8,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; +import RedisStore from "connect-redis"; import { Strategy as GitHubStrategy } from "passport-github"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; @@ -23,8 +24,22 @@ import { OrgAuthMethod } from "@app/services/org/org-types"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); - await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + const redisStore = new RedisStore({ + client: server.redis, + prefix: "oauth-session:", + ttl: 600 // 10 minutes + }); + + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, + cookie: { + secure: appCfg.HTTPS_ENABLED, + sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server + } + }); await server.register(passport.initialize()); await server.register(passport.secureSession()); // passport oauth strategy for Google @@ -37,11 +52,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`, - scope: ["profile", " email"] + scope: ["profile", " email"], + state: true }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { try { + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); + const email = profile?.emails?.[0]?.value; if (!email) throw new NotFoundError({ @@ -54,7 +73,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort: req.query.state as string + callbackPort }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -76,10 +95,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, - scope: ["user:email"] + scope: ["user:email"], + // akhilmhdh: because the ts type for this is outdated by the maintainer + state: true as unknown as string }, // eslint-disable-next-line async (req, accessToken, _refreshToken, profile, cb) => { + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; @@ -88,7 +111,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITHUB, - callbackPort: req.query.state as string + callbackPort }); return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -112,17 +135,20 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GITLAB_LOGIN, clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`, - baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL + baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL, + state: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { + const callbackPort = req.session.get("callbackPort"); + const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITLAB, - callbackPort: req.query.state as string + callbackPort }); return cb(null, { isUserCompleted, providerAuthToken }); @@ -143,17 +169,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + return ( + passport.authenticate("google", { + scope: ["profile", "email"], + authInfo: false + // this is due to zod type difference + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -166,7 +199,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { authInfo: false // this is due to zod type difference }) as never, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -186,15 +220,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("github", { - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + return ( + passport.authenticate("github", { + session: false, + authInfo: false + // this is due to zod type difference + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -245,7 +288,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { authInfo: false // this is due to zod type difference }) as any, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -265,16 +309,25 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("gitlab", { - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + return ( + passport.authenticate("gitlab", { + session: false, + authInfo: false + // this is due to zod type difference + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -288,7 +341,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` diff --git a/backend/src/server/routes/v1/user-engagement-router.ts b/backend/src/server/routes/v1/user-engagement-router.ts index e3ce6532e1..1a13dbc6e2 100644 --- a/backend/src/server/routes/v1/user-engagement-router.ts +++ b/backend/src/server/routes/v1/user-engagement-router.ts @@ -21,7 +21,7 @@ export const registerUserEngagementRouter = async (server: FastifyZodProvider) = }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - return server.services.userEngagement.createUserWish(req.permission.id, req.body.text); + return server.services.userEngagement.createUserWish(req.permission.id, req.permission.orgId, req.body.text); } }); }; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e271eb0e0..0df88e38c5 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -12,6 +11,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -27,14 +27,6 @@ const projectWithEnv = SanitizedProjectSchema.extend({ environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() }); -const slugSchema = z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be at least 5 character but no more than 36" - }); - export const registerProjectRouter = async (server: FastifyZodProvider) => { /* Get project key */ server.route({ @@ -162,21 +154,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECTS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(PROJECTS.CREATE.slug), kmsKeyId: z.string().optional(), - template: z - .string() - .refine((v) => slugify(v) === v, { - message: "Template name must be in slug format" - }) + template: slugSchema({ field: "Template Name", max: 64 }) .optional() .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template) @@ -244,7 +224,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema.describe("The slug of the project to delete.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.") }), response: { 200: SanitizedProjectSchema @@ -278,7 +258,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to get.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -311,7 +291,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to update.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.") }), body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), @@ -354,7 +334,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CAS.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug) }), querystring: z.object({ status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), @@ -395,7 +375,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug) }), querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 7f9cf920e1..a2524a0b97 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import ms from "ms"; import { ProjectMembershipRole } from "@app/db/schemas"; @@ -61,7 +61,12 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Identity, { + identityId + }) + ); const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId }); if (existingIdentity) @@ -161,7 +166,10 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId }); if (!projectIdentity) @@ -253,7 +261,11 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -317,7 +329,11 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId }); if (!identityMembership) diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index be1a8d53c8..42a3f038b5 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -55,6 +55,7 @@ import { TOctopusDeployVariableSet, TSaveIntegrationAccessTokenDTO, TTeamCityBuildConfig, + TUpdateIntegrationAuthDTO, TVercelBranches } from "./integration-auth-types"; import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list"; @@ -368,6 +369,148 @@ export const integrationAuthServiceFactory = ({ return integrationAuthDAL.create(updateDoc); }; + const updateIntegrationAuth = async ({ + integrationAuthId, + refreshToken, + actorId, + integration: newIntegration, + url, + actor, + actorOrgId, + actorAuthMethod, + accessId, + namespace, + accessToken, + awsAssumeIamRoleArn + }: TUpdateIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDAL.findById(integrationAuthId); + if (!integrationAuth) { + throw new NotFoundError({ message: `Integration auth with id ${integrationAuthId} not found.` }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + + const { projectId } = integrationAuth; + const integration = newIntegration || integrationAuth.integration; + + const updateDoc: TIntegrationAuthsInsert = { + projectId, + integration, + namespace, + url, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + ...(integration === Integrations.GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) + }; + + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.refreshToken) + }).cipherTextBlob; + updateDoc.encryptedRefresh = refreshEncToken; + + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (accessId) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessId) + }).cipherTextBlob; + updateDoc.encryptedAccessId = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({ + plainText: Buffer.from(awsAssumeIamRoleArn) + }).cipherTextBlob; + updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted; + updateDoc.encryptedAccess = null; + updateDoc.encryptedAccessId = null; + } + } + } else { + if (!botKey) throw new NotFoundError({ message: `Project bot key for project with ID '${projectId}' not found` }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + if (accessId) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, botKey); + updateDoc.accessIdIV = accessEncToken.iv; + updateDoc.accessIdTag = accessEncToken.tag; + updateDoc.accessIdCiphertext = accessEncToken.ciphertext; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, botKey); + updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext; + updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv; + updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag; + } + } + } + + return integrationAuthDAL.updateById(integrationAuthId, updateDoc); + }; + // helper function const getIntegrationAccessToken = async ( integrationAuth: TIntegrationAuths, @@ -1615,6 +1758,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationAuth, oauthExchange, saveIntegrationToken, + updateIntegrationAuth, deleteIntegrationAuthById, deleteIntegrationAuths, getIntegrationAuthTeams, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 80e8d6c36f..3ffa6959a1 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -22,6 +22,11 @@ export type TSaveIntegrationAccessTokenDTO = { awsAssumeIamRoleArn?: string; } & TProjectPermission; +export type TUpdateIntegrationAuthDTO = Omit & { + integrationAuthId: string; + integration?: string; +}; + export type TDeleteIntegrationAuthsDTO = TProjectPermission & { integration: string; projectId: string; diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 1db10405d7..a990b1ca6e 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -151,7 +151,9 @@ export const integrationServiceFactory = ({ isActive, environment, secretPath, - metadata + region, + metadata, + path }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` }); @@ -192,7 +194,9 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, + region, secretPath, + path, metadata: { ...(integration.metadata as object), ...metadata diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index a27c4f6acb..f662affd81 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -49,6 +49,8 @@ export type TUpdateIntegrationDTO = { appId?: string; isActive?: boolean; secretPath?: string; + region?: string; + path?: string; targetEnvironment?: string; owner?: string; environment?: string; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index bdf2fe18c5..a2ed85749d 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -53,6 +53,13 @@ export const smtpServiceFactory = (cfg: TSmtpConfig) => { const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); + handlebars.registerHelper("emailFooter", () => { + const { SITE_URL } = getConfig(); + return new handlebars.SafeString( + `

Email sent via Infisical at ${SITE_URL}

` + ); + }); + const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { const appCfg = getConfig(); const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8"); diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index 82c66ce5fe..3c0811a1c4 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -45,6 +45,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars index 3313d352f3..8c82df289c 100644 --- a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars +++ b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars @@ -11,8 +11,11 @@

A secret approval request has been bypassed in the project "{{projectName}}".

- {{requesterFullName}} ({{requesterEmail}}) has merged - a secret to environment {{environment}} at secret path {{secretPath}} + {{requesterFullName}} + ({{requesterEmail}}) has merged a secret to environment + {{environment}} + at secret path + {{secretPath}} without obtaining the required approvals.

@@ -24,5 +27,7 @@ To review this action, please visit the request panel here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailMfa.handlebars b/backend/src/services/smtp/templates/emailMfa.handlebars index 936195c340..4c948b08c0 100644 --- a/backend/src/services/smtp/templates/emailMfa.handlebars +++ b/backend/src/services/smtp/templates/emailMfa.handlebars @@ -1,4 +1,3 @@ - @@ -14,6 +13,8 @@

{{code}}

The MFA code will be valid for 2 minutes.

Not you? Contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index ad9694d5c5..4a989626e4 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -10,6 +10,8 @@

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportFailed.handlebars b/backend/src/services/smtp/templates/externalImportFailed.handlebars index c7869af27a..1755052c14 100644 --- a/backend/src/services/smtp/templates/externalImportFailed.handlebars +++ b/backend/src/services/smtp/templates/externalImportFailed.handlebars @@ -16,6 +16,7 @@

Error: {{error}}

+ {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportStarted.handlebars b/backend/src/services/smtp/templates/externalImportStarted.handlebars index 551f972cc5..90026f7621 100644 --- a/backend/src/services/smtp/templates/externalImportStarted.handlebars +++ b/backend/src/services/smtp/templates/externalImportStarted.handlebars @@ -12,6 +12,8 @@ {{provider}} to Infisical is in progress. The import process may take up to 30 minutes, and you will receive once the import has finished or if it fails.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars index 51a1c465e1..a918e9ec73 100644 --- a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars +++ b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars @@ -9,6 +9,8 @@

An import from {{provider}} to Infisical was successful

An import from {{provider}} was successful. Your data is now available in Infisical.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars index 0798538fbd..4a918ee0d6 100644 --- a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars @@ -1,21 +1,21 @@ - - - - - Incident alert: secrets potentially leaked - + + + + Incident alert: secrets potentially leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

-

View leaked secrets

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

+

View leaked secrets

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars index 5c5d766938..2aff820fad 100644 --- a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars +++ b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars @@ -26,6 +26,8 @@ {{#if syncMessage}}

Reason: {{syncMessage}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/newDevice.handlebars b/backend/src/services/smtp/templates/newDevice.handlebars index 6c7f2e9f62..197e0b7a7c 100644 --- a/backend/src/services/smtp/templates/newDevice.handlebars +++ b/backend/src/services/smtp/templates/newDevice.handlebars @@ -1,4 +1,3 @@ - @@ -13,7 +12,11 @@

Timestamp: {{timestamp}}

IP address: {{ip}}

User agent: {{userAgent}}

-

If you believe that this login is suspicious, please contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} or reset your password immediately.

+

If you believe that this login is suspicious, please contact + {{#if isCloud}}Infisical{{else}}your administrator{{/if}} + or reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index 3ee16ee374..da429477be 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -8,9 +8,11 @@

Join your organization on Infisical

-

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}

- Join now +

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization named {{organizationName}}

+ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} - \ No newline at end of file + diff --git a/backend/src/services/smtp/templates/passwordReset.handlebars b/backend/src/services/smtp/templates/passwordReset.handlebars index 6499a629c4..1cb2ae8ce1 100644 --- a/backend/src/services/smtp/templates/passwordReset.handlebars +++ b/backend/src/services/smtp/templates/passwordReset.handlebars @@ -1,14 +1,16 @@ - - - - + + + Account Recovery - - + +

Reset your password

Someone requested a password reset.

Reset password -

If you didn't initiate this request, please contact {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

- +

If you didn't initiate this request, please contact + {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars index 77d2543aec..f9013e24db 100644 --- a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars +++ b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars @@ -27,5 +27,7 @@

Please take necessary actions to renew these items before they expire.

For more details, please log in to your Infisical account and check your PKI management section.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars index b1482aa172..ba04d72011 100644 --- a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars +++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars @@ -1,16 +1,18 @@ - - - - + + + Organization Invitation - - + +

Join your organization on Infisical

You've been invited to join the Infisical organization — {{organizationName}}

Join now

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- +

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets + and configs.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars index 9dd6fe7470..c12c084607 100644 --- a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars +++ b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars @@ -17,6 +17,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretLeakIncident.handlebars b/backend/src/services/smtp/templates/secretLeakIncident.handlebars index c3c5f353a6..d0d9a617ce 100644 --- a/backend/src/services/smtp/templates/secretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/secretLeakIncident.handlebars @@ -1,25 +1,27 @@ - - - - - Incident alert: secret leaked - + + + + Incident alert: secret leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

-

View leaked secrets

-

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed - by {{pusher_name}} ({{pusher_email}}). If - these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as comment - in the given programming. This will prevent future notifications from being sent out for those secret(s).

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

+

View leaked secrets

+

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed + by + {{pusher_name}} + ({{pusher_email}}). If these are test secrets, please add `infisical-scan:ignore` at the end of the line + containing the secret as comment in the given programming. This will prevent future notifications from being sent + out for those secret(s).

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretReminder.handlebars b/backend/src/services/smtp/templates/secretReminder.handlebars index 2a0efcac81..d64c4bf42f 100644 --- a/backend/src/services/smtp/templates/secretReminder.handlebars +++ b/backend/src/services/smtp/templates/secretReminder.handlebars @@ -13,6 +13,8 @@ {{#if reminderNote}}

Here's the note included with the reminder: {{reminderNote}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars index 3ba18619f3..39f47ae48b 100644 --- a/backend/src/services/smtp/templates/signupEmailVerification.handlebars +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -1,17 +1,19 @@ - - - - + + + Code - + - +

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

{{code}}

-

Questions about setting up Infisical? {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

- +

Questions about setting up Infisical? + {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars index 36664be872..b65cb56259 100644 --- a/backend/src/services/smtp/templates/unlockAccount.handlebars +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -11,6 +11,8 @@

Your account has been temporarily locked due to multiple failed login attempts. To unlock your account, follow the link here

If these attempts were not made by you, reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index 39a9b74ba5..fde75a6d65 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -6,10 +6,12 @@

Join your team on Infisical

-

You have been invited to a new Infisical project — {{workspaceName}}

- Join now +

You have been invited to a new Infisical project named {{workspaceName}}

+ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} - \ No newline at end of file + diff --git a/backend/src/services/user-engagement/user-engagement-service.ts b/backend/src/services/user-engagement/user-engagement-service.ts index 5d7b549299..b146729036 100644 --- a/backend/src/services/user-engagement/user-engagement-service.ts +++ b/backend/src/services/user-engagement/user-engagement-service.ts @@ -1,87 +1,44 @@ -import { PlainClient } from "@team-plain/typescript-sdk"; +import axios from "axios"; import { getConfig } from "@app/lib/config/env"; import { InternalServerError } from "@app/lib/errors"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; type TUserEngagementServiceFactoryDep = { userDAL: Pick; + orgDAL: Pick; }; export type TUserEngagementServiceFactory = ReturnType; -export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => { - const createUserWish = async (userId: string, text: string) => { +export const userEngagementServiceFactory = ({ userDAL, orgDAL }: TUserEngagementServiceFactoryDep) => { + const createUserWish = async (userId: string, orgId: string, text: string) => { const user = await userDAL.findById(userId); + const org = await orgDAL.findById(orgId); const appCfg = getConfig(); - if (!appCfg.PLAIN_API_KEY) { + if (!appCfg.PYLON_API_KEY) { throw new InternalServerError({ - message: "Plain is not configured." + message: "Pylon is not configured." }); } - const client = new PlainClient({ - apiKey: appCfg.PLAIN_API_KEY - }); - - const customerUpsertRes = await client.upsertCustomer({ - identifier: { - emailAddress: user.email - }, - onCreate: { - fullName: `${user.firstName} ${user.lastName}`, - shortName: user.firstName, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - - externalId: user.id - }, - - onUpdate: { - fullName: { - value: `${user.firstName} ${user.lastName}` - }, - shortName: { - value: user.firstName - }, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - externalId: { - value: user.id - } + const request = axios.create({ + baseURL: "https://api.usepylon.com", + headers: { + Authorization: `Bearer ${appCfg.PYLON_API_KEY}` } }); - if (customerUpsertRes.error) { - throw new InternalServerError({ message: customerUpsertRes.error.message }); - } - - const createThreadRes = await client.createThread({ - title: "Wish", - customerIdentifier: { - externalId: customerUpsertRes.data.customer.externalId - }, - components: [ - { - componentText: { - text - } - } - ], - labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",") + await request.post("/issues", { + title: `New Wish From: ${user.firstName} ${user.lastName} (${org.name})`, + body_html: text, + requester_email: user.email, + requester_name: `${user.firstName} ${user.lastName} (${org.name})`, + tags: ["wish"] }); - - if (createThreadRes.error) { - throw new InternalServerError({ - message: createThreadRes.error.message - }); - } }; return { createUserWish diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 8f42b4dde9..6defea1197 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -186,6 +186,10 @@ spec: secretName: managed-secret secretNamespace: default creationPolicy: "Orphan" ## Owner | Orphan + # template: + # includeAllSecrets: true + # data: + # CUSTOM_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" # secretType: kubernetes.io/dockerconfigjson ``` @@ -698,6 +702,51 @@ The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. + +Templates enable you to transform data from Infisical before storing it as a Kubernetes Secret. + + +When set to true, this option injects all secrets retrieved from Infisical into your configuration. +Secrets defined in the template will override the automatically injected secrets. + + +Define secret keys and their corresponding templates. +Each data value uses a Golang template with access to all secrets retrieved from the specified scope. + +Secrets are structured as follows: +```golang +type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` +} +``` + +#### Example template configuration: +```golang + managedSecretReference: + secretName: managed-secret + secretNamespace: default + template: + includeAllSecrets: true + data: + NEW_KEY: "{{ .KEY1.SecretPath }} {{ .KEY1.Value }}" +``` + +When you run the following command: +```bash +kubectl get secret managed-secret -o jsonpath='{.data}' +``` + +You'll receive Kubernetes secrets output that includes the NEW_KEY: +```bash +{... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="} +``` + +When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be: +```bash +{"NEW_KEY":"LyBoZWxsbw=="} +``` + Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 91b8d4bc34..64380ae9f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -89,7 +89,7 @@ "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", - "react-select": "^5.8.3", + "react-select": "^5.8.1", "react-table": "^7.8.0", "react-toastify": "^9.1.3", "sanitize-html": "^2.12.1", diff --git a/frontend/public/images/integrations/GitHub.png b/frontend/public/images/integrations/GitHub.png index 9490ffc6d2..7492fcb54a 100644 Binary files a/frontend/public/images/integrations/GitHub.png and b/frontend/public/images/integrations/GitHub.png differ diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index a2fde465d1..23389cbca7 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,6 +17,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; export const secretTagsColors = [ { @@ -88,13 +88,7 @@ type Props = { }; const createTagSchema = z.object({ - slug: z - .string() - .trim() - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ min: 1, field: "Tag Slug" }), color: z.string().trim() }); diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx index 8f2cf79e8e..1662b07ff1 100644 --- a/frontend/src/components/v2/projects/NewProjectModal.tsx +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -36,7 +36,8 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, - useGetExternalKmsList + useGetExternalKmsList, + useGetUserWorkspaces } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; @@ -68,6 +69,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { const { permission } = useOrgPermission(); const { user } = useUser(); const createWs = useCreateWorkspace(); + const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); const addUsersToProject = useAddUserToWsNonE2EE(); const { subscription } = useSubscription(); @@ -137,8 +139,8 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { orgId: currentOrg.id }); } - // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. - await new Promise((resolve) => setTimeout(resolve, 2_000)); + + await refetchWorkspaces(); createNotification({ text: "Project created", type: "success" }); reset(); diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 8f10d5f211..673b2b41a1 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -33,6 +33,10 @@ export enum PermissionConditionOperators { $GLOB = "$glob" } +export type IdentityManagementSubjectFields = { + identityId: string; +}; + export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", [PermissionConditionOperators.$IN]: "contains", @@ -151,7 +155,13 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] - | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ + ProjectPermissionActions, + ( + | ProjectPermissionSub.Identity + | (ForcedSubject & IdentityManagementSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] diff --git a/frontend/src/helpers/parseEnvVar.ts b/frontend/src/helpers/parseEnvVar.ts index 27640b515c..8bde050844 100644 --- a/frontend/src/helpers/parseEnvVar.ts +++ b/frontend/src/helpers/parseEnvVar.ts @@ -1,14 +1,31 @@ /** Extracts the key and value from a passed in env string based on the provided delimiters. */ export const getKeyValue = (pastedContent: string, delimiters: string[]) => { - const foundDelimiter = delimiters.find((delimiter) => pastedContent.includes(delimiter)); + if (!pastedContent) { + return { key: "", value: "" }; + } - if (!foundDelimiter) { + let firstDelimiterIndex = -1; + let foundDelimiter = ""; + + delimiters.forEach((delimiter) => { + const index = pastedContent.indexOf(delimiter); + if (index !== -1 && (firstDelimiterIndex === -1 || index < firstDelimiterIndex)) { + firstDelimiterIndex = index; + foundDelimiter = delimiter; + } + }); + + const hasValueAfterDelimiter = pastedContent.length > firstDelimiterIndex + foundDelimiter.length; + + if (firstDelimiterIndex === -1 || !hasValueAfterDelimiter) { return { key: pastedContent.trim(), value: "" }; } - const [key, value] = pastedContent.split(foundDelimiter); + const key = pastedContent.substring(0, firstDelimiterIndex); + const value = pastedContent.substring(firstDelimiterIndex + foundDelimiter.length); + return { key: key.trim(), - value: (value ?? "").trim() + value: value.trim() }; }; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 6df2575903..bd6173d916 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; }; -export enum ApproverType{ +export enum ApproverType { User = "user", Group = "group" } -export type Approver ={ +export type Approver = { id: string; type: ApproverType; -} +}; export type TAccessApprovalRequest = { id: string; @@ -70,6 +70,7 @@ export type TAccessApprovalRequest = { secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; + deletedAt: Date | null; }; reviewers: { diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 4045929080..a75767108c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_SECRET]: "Delete secret", [EventType.GET_WORKSPACE_KEY]: "Read project key", [EventType.AUTHORIZE_INTEGRATION]: "Authorize integration", + [EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth", [EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration", [EventType.CREATE_INTEGRATION]: "Create integration", [EventType.DELETE_INTEGRATION]: "Delete integration", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 1db55d7396..0b0c44d7b0 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -23,6 +23,7 @@ export enum EventType { DELETE_SECRET = "delete-secret", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4f..c23a558328 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,9 +1,8 @@ export { - useAddUserToGroup, - useCreateGroup, - useDeleteGroup, - useRemoveUserFromGroup, - useUpdateGroup} from "./mutations"; -export { - useListGroupUsers -} from "./queries"; \ No newline at end of file + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup +} from "./mutations"; +export { useGetGroupById, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 445ae10bc6..2f5c5984ce 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -56,8 +56,9 @@ export const useUpdateGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; @@ -70,8 +71,9 @@ export const useDeleteGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index b239b0a614..dc3791db79 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; + export const groupKeys = { + getGroupById: (groupId: string) => [{ groupId }, "group"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const, forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, @@ -10,22 +13,27 @@ export const groupKeys = { slug, offset, limit, - search + search, + filter }: { slug: string; offset: number; limit: number; search: string; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const + filter?: EFilterReturnedUsers; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const }; -type TUser = { - id: string; - email: string; - username: string; - firstName: string; - lastName: string; - isPartOfGroup: boolean; +export const useGetGroupById = (groupId: string) => { + return useQuery({ + enabled: Boolean(groupId), + queryKey: groupKeys.getGroupById(groupId), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/groups/${groupId}`); + + return { group: data }; + } + }); }; export const useListGroupUsers = ({ @@ -33,20 +41,23 @@ export const useListGroupUsers = ({ groupSlug, offset = 0, limit = 10, - search + search, + filter }: { id: string; groupSlug: string; offset: number; limit: number; search: string; + filter?: EFilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ slug: groupSlug, offset, limit, - search + search, + filter }), enabled: Boolean(groupSlug), keepPreviousData: true, @@ -54,10 +65,11 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search + search, + ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>( + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( `/api/v1/groups/${id}/users`, { params diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3f69b9a0e7..6bc82b39e0 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -11,7 +11,7 @@ export type TGroup = { name: string; slug: string; orgId: string; - createAt: string; + createdAt: string; updatedAt: string; role: string; }; @@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = { slug: string; orgId: string; }; + +export type TGroupUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; + joinedGroupAt: Date; +}; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 3e4b69880a..73b821b1a2 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,6 +1,8 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { slugSchema } from "@app/lib/schemas"; + export type Kms = { id: string; description: string; @@ -119,13 +121,7 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ ]); export const AddExternalKmsSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + name: slugSchema({ min: 1, field: "Alias" }), description: z.string().trim().optional(), provider: ExternalKmsInputSchema }); diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index b387d33d17..c03358b42f 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -51,26 +51,26 @@ export enum ApiErrorTypes { export type TApiErrors = | { - requestId: string; + reqId: string; error: ApiErrorTypes.ValidationError; message: ZodIssue[]; statusCode: 422; } | { - requestId: string; + reqId: string; error: ApiErrorTypes.UnauthorizedError; message: string; statusCode: 401; } | { - requestId: string; + reqId: string; error: ApiErrorTypes.ForbiddenError; message: string; details: PureAbility["rules"]; statusCode: 403; } | { - requestId: string; + reqId: string; statusCode: 400; message: string; error: ApiErrorTypes.BadRequestError; diff --git a/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx index bf87d0672a..91907ee8cc 100644 --- a/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx +++ b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx @@ -65,7 +65,7 @@ export const WishForm = () => {
- Make a wish + Request a feature
val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Invalid slug format" - }); +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx index ad82a218c8..cea246f2f2 100644 --- a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx +++ b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx @@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2"; @@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() { const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`; window.location.assign(link); }; diff --git a/frontend/src/pages/integrations/github/auth-mode-selection.tsx b/frontend/src/pages/integrations/github/auth-mode-selection.tsx index 5fcb4f0ddf..4a512ff603 100644 --- a/frontend/src/pages/integrations/github/auth-mode-selection.tsx +++ b/frontend/src/pages/integrations/github/auth-mode-selection.tsx @@ -18,6 +18,7 @@ import { SelectItem } from "@app/components/v2"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; enum AuthMethod { APP = "APP", @@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() { if (selectedAuthMethod === AuthMethod.APP) { router.push("/integrations/select-integration-auth?integrationSlug=github"); } else { + if (!githubIntegration?.clientId) { + createIntegrationMissingEnvVarsNotification( + "githubactions", + "cicd", + "connecting-with-github-oauth" + ); + return; + } + const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); diff --git a/frontend/src/pages/integrations/gitlab/authorize.tsx b/frontend/src/pages/integrations/gitlab/authorize.tsx index 380aad08eb..d6c80c2c11 100644 --- a/frontend/src/pages/integrations/gitlab/authorize.tsx +++ b/frontend/src/pages/integrations/gitlab/authorize.tsx @@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; @@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() { if (!integrationOption) return; + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } + const baseURL = (gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim(); diff --git a/frontend/src/pages/integrations/select-integration-auth.tsx b/frontend/src/pages/integrations/select-integration-auth.tsx index a9d2766a47..1f9f17afa9 100644 --- a/frontend/src/pages/integrations/select-integration-auth.tsx +++ b/frontend/src/pages/integrations/select-integration-auth.tsx @@ -13,6 +13,7 @@ import { useGetOrgIntegrationAuths } from "@app/hooks/api"; import { IntegrationAuth } from "@app/hooks/api/types"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; export default function SelectIntegrationAuthPage() { const router = useRouter(); @@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() { localStorage.setItem("latestCSRFToken", state); if (integrationSlug === "github") { + if (!currentIntegration?.clientSlug) { + createIntegrationMissingEnvVarsNotification("githubactions", "cicd"); + return; + } + // for now we only handle Github apps window.location.assign( `https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}` diff --git a/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx new file mode 100644 index 0000000000..e193d9bd5c --- /dev/null +++ b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { GroupPage } from "@app/views/Org/GroupPage"; + +export default function Group() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +Group.requireAuth = true; diff --git a/frontend/src/reactQuery.tsx b/frontend/src/reactQuery.tsx index 8e5d674ca3..0cfe0180f8 100644 --- a/frontend/src/reactQuery.tsx +++ b/frontend/src/reactQuery.tsx @@ -64,9 +64,9 @@ export const queryClient = new QueryClient({ ), copyActions: [ { - value: serverResponse.requestId, + value: serverResponse.reqId, name: "Request ID", - label: `Request ID: ${serverResponse.requestId}` + label: `Request ID: ${serverResponse.reqId}` } ] }, @@ -93,7 +93,7 @@ export const queryClient = new QueryClient({ >
{serverResponse.details?.map((el, index) => { - const hasConditions = Object.keys(el.conditions || {}).length; + const hasConditions = Boolean(Object.keys(el.conditions || {}).length); return (
+ createNotification({ + type: "error", + text: ( + + Click here to view docs + + ), + title: "Missing Environment Variables" + }); + export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => { try { // generate CSRF token for OAuth2 code-token exchange integrations @@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`; break; case "azure-key-vault": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; break; case "azure-app-configuration": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`; break; case "aws-parameter-store": @@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/aws-secret-manager/authorize`; break; case "heroku": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; break; case "vercel": + if (!integrationOption.clientSlug) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; break; case "netlify": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; break; case "github": @@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/cloudflare-workers/authorize`; break; case "bitbucket": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`; break; case "codefresh": diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx index e44fd2539d..3249e1eb54 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { motion } from "framer-motion"; import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { @@ -28,11 +30,17 @@ type Props = { }>; }; +enum IntegrationView { + List = "list", + New = "new" +} + export const IntegrationsPage = withProjectPermission( ({ frameworkIntegrations, infrastructureIntegrations }: Props) => { const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; const environments = currentWorkspace?.environments || []; + const [view, setView] = useState(IntegrationView.New); const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } = useGetCloudIntegrations(); @@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission( const { data: integrations, isLoading: isIntegrationLoading, - isFetching: isIntegrationFetching + isFetching: isIntegrationFetching, + isFetched: isIntegrationsFetched } = useGetWorkspaceIntegrations(workspaceId); const { mutateAsync: deleteIntegration } = useDeleteIntegration(); @@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission( isIntegrationsEmpty ]); + useEffect(() => { + setView(integrations?.length ? IntegrationView.List : IntegrationView.New); + }, [isIntegrationsFetched]); + const handleProviderIntegration = async (provider: string) => { const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); if (!selectedCloudIntegration) return; @@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission( } }; + if (isIntegrationLoading || isCloudIntegrationsLoading) + return ( +
+ +
+ ); + return ( -
- - - - +
+
+ {view === IntegrationView.List ? ( + + setView(IntegrationView.New)} + isLoading={isIntegrationLoading} + integrations={integrations} + environments={environments} + onIntegrationDelete={handleIntegrationDelete} + workspaceId={workspaceId} + /> + + ) : ( + + setView(IntegrationView.List) : undefined + } + isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading} + cloudIntegrations={cloudIntegrations} + integrationAuths={integrationAuths} + onIntegrationStart={handleProviderIntegrationStart} + onIntegrationRevoke={handleIntegrationAuthRevoke} + /> + + + + )} +
); }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations } + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Integrations + } ); diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx index 1644702898..8c63aa40c1 100644 --- a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -1,11 +1,24 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCheck, + faChevronLeft, + faMagnifyingGlass, + faSearch, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner"; import { createNotification } from "@app/components/notifications"; -import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2"; +import { + Button, + DeleteActionModal, + EmptyState, + Input, + Skeleton, + Tooltip +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -22,6 +35,7 @@ type Props = { onIntegrationStart: (slug: string) => void; // cb: handle popUpClose child->parent communication pattern onIntegrationRevoke: (slug: string, cb: () => void) => void; + onViewActiveIntegrations?: () => void; }; type TRevokeIntegrationPopUp = { provider: string }; @@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({ cloudIntegrations = [], integrationAuths = {}, onIntegrationStart, - onIntegrationRevoke + onIntegrationRevoke, + onViewActiveIntegrations }: Props) => { const { t } = useTranslation(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({ return sortedIntegrations; }, [cloudIntegrations, currentWorkspace?.environments]); + const [search, setSearch] = useState(""); + + const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) => + cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim()) + ); + return (
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({ )}
-
-

{t("integrations.cloud-integrations")}

-

{t("integrations.click-to-start")}

+
+ {onViewActiveIntegrations && ( + + )} +
+
+

{t("integrations.cloud-integrations")}

+

{t("integrations.click-to-start")}

+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search cloud integrations..." + containerClassName="flex-1 h-min text-base" + /> +
- -
+
{isLoading && Array.from({ length: 12 }).map((_, index) => ( ))} - {!isLoading && - sortedCloudIntegrations?.map((cloudIntegration) => ( + + {!isLoading && filteredIntegrations.length ? ( + filteredIntegrations.map((cloudIntegration) => (
null} role="button" @@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({ cloudIntegration.isAvailable ? "cursor-pointer duration-200 hover:bg-mineshaft-700" : "opacity-50" - } flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} + } flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} onClick={() => { if (!cloudIntegration.isAvailable) return; if ( @@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({ > integration logo -
+
{cloudIntegration.name}
{cloudIntegration.isAvailable && @@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
)}
- ))} + )) + ) : ( + + )}
{isEmpty && (
diff --git a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx index 3b1df9bdd6..a4e6bb5866 100644 --- a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx @@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {

{t("integrations.framework-integrations")}

{t("integrations.click-to-setup")}

-
+
{sortedFrameworks.map((framework) => ( -
1 ? "px-1 text-sm" : "px-2 text-xl" - } w-full max-w-xs text-center`} - > - {framework?.image && ( - integration logo - )} - {framework?.name && framework?.image &&
} - {framework?.name && framework.name} -
+ {framework?.image && ( + integration logo + )} + {framework?.name && ( +
+ {framework.name} +
+ )}
))} { href="https://infisical.com/docs/cli/commands/run" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
CLI
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => { href="https://infisical.com/docs/sdks/overview" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
SDKs
diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx deleted file mode 100644 index 2be3d1f3e0..0000000000 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import { useRouter } from "next/router"; -import { - faArrowRight, - faCalendarCheck, - faEllipsis, - faRefresh, - faWarning, - faXmark -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; -import { integrationSlugNameMapping } from "public/data/frequentConstants"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; -import { TIntegration } from "@app/hooks/api/types"; - -type IProps = { - integration: TIntegration; - environments: Array<{ name: string; slug: string; id: string }>; - onRemoveIntegration: VoidFunction; - onManualSyncIntegration: VoidFunction; -}; - -export const ConfiguredIntegrationItem = ({ - integration, - environments, - onRemoveIntegration, - onManualSyncIntegration -}: IProps) => { - const router = useRouter(); - - return ( -
router.push(`/integrations/details/${integration.id}`)} - key={`integration-${integration?.id.toString()}`} - > -
-
- -
- {environments.find((e) => e.id === integration.envId)?.name || "-"} -
-
-
- -
- {integration.secretPath} -
-
-
- -
-
- - {/* eslint-disable-next-line no-nested-ternary */} - {integration.metadata?.githubVisibility === "selected" - ? "Syncing to selected repositories in the organization. " - : integration.metadata?.githubVisibility === "private" - ? "Syncing to all private repositories in the organization" - : "Syncing to all public and private repositories in the organization"} -
- ) : undefined - } - label="Integration" - /> -
- {integrationSlugNameMapping[integration.integration]} -
-
- {integration.integration === "octopus-deploy" && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "qovery" && ( -
-
- -
- {integration?.owner || "-"} -
-
-
- -
- {integration?.targetService || "-"} -
-
-
- -
- {integration?.targetEnvironment || "-"} -
-
-
- )} - {!( - integration.integration === "aws-secret-manager" && - integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE - ) && ( -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (["aws-parameter-store", "rundeck"].includes(integration.integration) && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} -
-
- )} - {(integration.integration === "vercel" || - integration.integration === "netlify" || - integration.integration === "railway" || - integration.integration === "gitlab" || - integration.integration === "teamcity" || - (integration.integration === "github" && integration.scope === "github-env")) && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "bitbucket" && ( - <> - {integration.targetServiceId && ( -
- -
- {integration.targetService || integration.targetServiceId} -
-
- )} -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- - )} - {integration.integration === "checkly" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {integration.integration === "circleci" && integration.owner && ( -
- -
- {integration.owner} -
-
- )} - {integration.integration === "terraform-cloud" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {(integration.integration === "checkly" || integration.integration === "github") && ( -
- -
- {integration?.metadata?.secretSuffix || "-"} -
-
- )} -
-
- {integration.isSynced != null && integration.lastUsed != null && ( - - -
- -
Last successful sync
-
-
- {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} -
- {!integration.isSynced && ( - <> -
- -
Fail reason
-
-
{integration.syncMessage}
- - )} -
- } - > -
-
{integration.isSynced ? "Synced" : "Not synced"}
- {!integration.isSynced && } -
- - - )} -
- - { - e.stopPropagation(); - onManualSyncIntegration(); - }} - ariaLabel="sync" - colorSchema="primary" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - - {(isAllowed: boolean) => ( - - { - e.stopPropagation(); - onRemoveIntegration(); - }} - ariaLabel="delete" - isDisabled={!isAllowed} - colorSchema="danger" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - )} - - - - - - - -
-
-
- ); -}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 2fbcca15d1..2708226f61 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,13 +1,16 @@ -import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2"; -import { usePopUp, useToggle } from "@app/hooks"; -import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; -import { TIntegration } from "@app/hooks/api/types"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem"; +import { Button, Checkbox, DeleteActionModal } from "@app/components/v2"; +import { usePopUp, useToggle } from "@app/hooks"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/types"; + +import { IntegrationsTable } from "./components"; type Props = { environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; isLoading?: boolean; onIntegrationDelete: ( integrationId: string, @@ -15,6 +18,7 @@ type Props = { cb: () => void ) => Promise; workspaceId: string; + onAddIntegration: () => void; }; export const IntegrationsSection = ({ @@ -22,58 +26,47 @@ export const IntegrationsSection = ({ environments = [], isLoading, onIntegrationDelete, - workspaceId + workspaceId, + onAddIntegration, + cloudIntegrations = [] }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteConfirmation", "deleteSecretsConfirmation" ] as const); - const { mutate: syncIntegration } = useSyncIntegration(); const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); return ( -
-
-

Current Integrations

+
+
+

Integrations

Manage integrations with third-party services.

- {isLoading && ( -
- +
+
+

Active Integrations

+
- )} - - {!isLoading && !integrations.length && ( -
- -
- )} - {!isLoading && ( -
- {integrations?.map((integration) => ( - { - syncIntegration({ - workspaceId, - id: integration.id, - lastUsed: integration.lastUsed as string - }); - }} - onRemoveIntegration={() => { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - integration={integration} - environments={environments} - /> - ))} -
- )} + { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + /> +
+ (integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || + (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || + integration.app || + "-"; + +export const IntegrationDetails = ({ integration }: Props) => { + return ( +
+ {integration.integration === "octopus-deploy" && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "qovery" && ( + <> +
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+ + )} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
{getIntegrationDestination(integration)}
+
+ )} + {(integration.integration === "vercel" || + integration.integration === "netlify" || + integration.integration === "railway" || + integration.integration === "gitlab" || + integration.integration === "teamcity" || + (integration.integration === "github" && integration.scope === "github-env")) && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "bitbucket" && ( + <> + {integration.targetServiceId && ( +
+ +
+ {integration.targetService || integration.targetServiceId} +
+
+ )} +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ + )} + {integration.integration === "checkly" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {integration.integration === "circleci" && integration.owner && ( +
+ +
{integration.owner}
+
+ )} + {integration.integration === "terraform-cloud" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {(integration.integration === "checkly" || integration.integration === "github") && + integration?.metadata?.secretSuffix && ( +
+ +
{integration.metadata.secretSuffix}
+
+ )} + {integration.integration === "github" && integration.metadata?.githubVisibility ? ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {integration.metadata?.githubVisibility === "selected" + ? "* Syncing to selected repositories in the organization. " + : integration.metadata?.githubVisibility === "private" + ? "* Syncing to all private repositories in the organization" + : "* Syncing to all public and private repositories in the organization"} +
+ ) : undefined} +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx new file mode 100644 index 0000000000..79c0e26f34 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx @@ -0,0 +1,185 @@ +import { useMemo } from "react"; +import { useRouter } from "next/router"; +import { + faCalendarCheck, + faCheck, + faInfoCircle, + faRefresh, + faTrash, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { TCloudIntegration } from "@app/hooks/api/integrations/types"; +import { TIntegration } from "@app/hooks/api/types"; + +import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails"; + +type IProps = { + integration: TIntegration; + environment?: { name: string; slug: string; id: string }; + onRemoveIntegration: VoidFunction; + onManualSyncIntegration: VoidFunction; + cloudIntegration: TCloudIntegration; +}; + +export const IntegrationRow = ({ + integration, + environment, + onRemoveIntegration, + onManualSyncIntegration, + cloudIntegration +}: IProps) => { + const router = useRouter(); + + const { id, secretPath, syncMessage, isSynced } = integration; + + const failureMessage = useMemo(() => { + if (isSynced === false) { + if (syncMessage) + try { + return JSON.stringify(JSON.parse(syncMessage), null, 2); + } catch (e) { + return syncMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [isSynced, syncMessage]); + + return ( + router.push(`/integrations/details/${integration.id}`)} + className={twMerge( + "group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700", + isSynced === false && "bg-red/5 hover:bg-red/10" + )} + key={`integration-${id}`} + > + +
+ {`${cloudIntegration?.name} + {cloudIntegration?.name} +
+ + + +

{secretPath}

+
{" "} + + {environment?.name ?? "-"} + +
+

{getIntegrationDestination(integration)}

+ } + > + + +
+ + + {" "} + {typeof integration.isSynced !== "boolean" ? ( + + Pending Sync + + ) : ( + + {integration.lastUsed && ( +
+
+ +
Last Synced
+
+
+ {format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")} +
+
+ )} + {failureMessage && ( +
+
+ +
Failure Reason
+
+
{failureMessage}
+
+ )} +
+ } + > +
+ +
+ +
{integration.isSynced ? "Synced" : "Not Synced"}
+
+
+
+ + )} + + +
+ + { + e.stopPropagation(); + onManualSyncIntegration(); + }} + ariaLabel="sync" + colorSchema="secondary" + variant="plain" + > + + + + + {(isAllowed: boolean) => ( + + { + e.stopPropagation(); + onRemoveIntegration(); + }} + ariaLabel="delete" + isDisabled={!isAllowed} + colorSchema="danger" + variant="plain" + > + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx new file mode 100644 index 0000000000..ea2ecd8887 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useState } from "react"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDown, + faArrowUp, + faCheck, + faClock, + faFilter, + faMagnifyingGlass, + faPlug, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types"; + +import { getIntegrationDestination } from "./IntegrationDetails"; +import { IntegrationRow } from "./IntegrationRow"; + +type Props = { + integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; + workspaceId: string; + isLoading?: boolean; + environments: Array<{ name: string; slug: string; id: string }>; + onDeleteIntegration: (integration: TIntegration) => void; +}; + +enum IntegrationsOrderBy { + App = "app", + Status = "status", + SecretPath = "secretPath", + Environment = "environment", + Destination = "destination" +} + +enum IntegrationStatus { + Synced = "synced", + NotSynced = "not-synced", + PendingSync = "pending-sync" +} + +type IntegrationFilters = { + environmentIds: string[]; + integrations: string[]; + status: IntegrationStatus[]; +}; + +const STATUS_ICON_MAP = { + [IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" }, + [IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" }, + [IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" } +}; + +export const IntegrationsTable = ({ + integrations = [], + cloudIntegrations = [], + workspaceId, + environments, + onDeleteIntegration, + isLoading +}: Props) => { + const { mutate: syncIntegration } = useSyncIntegration(); + + const initialFilters = useMemo( + () => ({ + environmentIds: environments.map((env) => env.id), + integrations: [...new Set(integrations.map(({ integration }) => integration))], + status: Object.values(IntegrationStatus) + }), + [environments, integrations] + ); + + const [filters, setFilters] = useState(initialFilters); + + const cloudIntegrationMap = useMemo(() => { + return new Map( + cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration]) + ); + }, [cloudIntegrations]); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(IntegrationsOrderBy.App, { initPerPage: 20 }); + + useEffect(() => { + if (integrations?.some((integration) => integration.isSynced === false)) + setOrderBy(IntegrationsOrderBy.Status); + }, []); + + const environmentMap = new Map(environments.map((env) => [env.id, env])); + + const filteredIntegrations = useMemo( + () => + integrations + .filter((integration) => { + const { secretPath, envId, isSynced } = integration; + + if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false; + if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false) + return false; + if ( + !filters.status.includes(IntegrationStatus.PendingSync) && + typeof isSynced !== "boolean" + ) + return false; + + if (!filters.integrations.includes(integration.integration)) return false; + + if (!filters.environmentIds.includes(envId)) return false; + + return ( + integration.integration + .replace("-", " ") + .toLowerCase() + .includes(search.trim().toLowerCase()) || + secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) || + getIntegrationDestination(integration) + .toLowerCase() + .includes(search.trim().toLowerCase()) || + environmentMap + .get(envId) + ?.name.replace("-", " ") + .toLowerCase() + .includes(search.trim().toLowerCase()) + ); + }) + .sort((a, b) => { + const [integrationOne, integrationTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case IntegrationsOrderBy.SecretPath: + return integrationOne.secretPath + .toLowerCase() + .localeCompare(integrationTwo.secretPath.toLowerCase()); + case IntegrationsOrderBy.Environment: + return (environmentMap.get(integrationOne.envId)?.name ?? "-") + .toLowerCase() + .localeCompare( + (environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase() + ); + case IntegrationsOrderBy.Destination: + return getIntegrationDestination(integrationOne) + .toLowerCase() + .localeCompare(getIntegrationDestination(integrationTwo).toLowerCase()); + case IntegrationsOrderBy.Status: + if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end + if (typeof integrationTwo.isSynced !== "boolean") return -1; + + return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced); + case IntegrationsOrderBy.App: + default: + return integrationOne.integration + .toLowerCase() + .localeCompare(integrationTwo.integration.toLowerCase()); + } + }), + [integrations, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredIntegrations.length, + offset, + setPage + }); + + const handleSort = (column: IntegrationsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: IntegrationsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: IntegrationsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = + filters.integrations.length !== initialFilters.integrations.length || + filters.environmentIds.length !== initialFilters.environmentIds.length || + filters.status.length !== initialFilters.status.length; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search integrations..." + className="flex-1" + /> + + + + + + + + + + Status + {Object.values(IntegrationStatus).map((status) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + status: prev.status.includes(status) + ? prev.status.filter((s) => s !== status) + : [...prev.status, status] + })); + }} + key={status} + icon={ + filters.status.includes(status) && ( + + ) + } + iconPos="right" + > +
+ + {status.replace("-", " ")} +
+
+ ))} + Integration + {[...new Set(integrations.map(({ integration }) => integration))].map((integration) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + integrations: prev.integrations.includes(integration) + ? prev.integrations.filter((i) => i !== integration) + : [...prev.integrations, integration] + })); + }} + key={integration} + icon={ + filters.integrations.includes(integration) && ( + + ) + } + iconPos="right" + > +
+ {`${cloudIntegrationMap.get(integration)!.name} + {cloudIntegrationMap.get(integration)!.name} +
+
+ ))} + Environment + {environments.map((env) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + environmentIds: prev.environmentIds.includes(env.id) + ? prev.environmentIds.filter((i) => i !== env.id) + : [...prev.environmentIds, env.id] + })); + }} + key={env.id} + icon={ + filters.environmentIds.includes(env.id) && ( + + ) + } + iconPos="right" + > + {env.name} + + ))} +
+
+
+ + + + + + + + + + + + + {filteredIntegrations.slice(offset, perPage * page).map((integration) => ( + { + syncIntegration({ + workspaceId, + id: integration.id, + lastUsed: integration.lastUsed as string + }); + }} + onRemoveIntegration={() => onDeleteIntegration(integration)} + integration={integration} + environment={environmentMap.get(integration.envId)} + /> + ))} + +
+
+ Integration + handleSort(IntegrationsOrderBy.App)} + > + + +
+
+
+ Source Path + handleSort(IntegrationsOrderBy.SecretPath)} + > + + +
+
+
+ Source Environment + handleSort(IntegrationsOrderBy.Environment)} + > + + +
+
+
+ Destination + handleSort(IntegrationsOrderBy.Destination)} + > + + +
+
+
+ Status + handleSort(IntegrationsOrderBy.Status)} + > + + +
+
+
+ {Boolean(filteredIntegrations.length) && ( + + )} + {!isLoading && !filteredIntegrations?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts new file mode 100644 index 0000000000..d9567592c0 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts @@ -0,0 +1 @@ +export * from "./IntegrationsTable"; diff --git a/frontend/src/views/Org/GroupPage/GroupPage.tsx b/frontend/src/views/Org/GroupPage/GroupPage.tsx new file mode 100644 index 0000000000..acde15760a --- /dev/null +++ b/frontend/src/views/Org/GroupPage/GroupPage.tsx @@ -0,0 +1,175 @@ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteGroup } from "@app/hooks/api"; +import { useGetGroupById } from "@app/hooks/api/groups/queries"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { TabSections } from "@app/views/Org/Types"; + +import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; +import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupDetailsSection } from "./components"; + +export const GroupPage = withPermission( + () => { + const router = useRouter(); + const groupId = router.query.groupId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data, isLoading } = useGetGroupById(groupId); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "groupCreateUpdate", + "deleteGroup", + "upgradePlan" + ] as const); + + const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { + try { + await deleteMutateAsync({ + id + }); + createNotification({ + text: `Successfully deleted the ${name} group`, + type: "success" + }); + router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the ${name} group`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + }; + + if (isLoading) return ; + + return ( +
+ {data && ( +
+ +
+

{data.group.name}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + disabled={!isAllowed} + > + Edit Group + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + id: groupId, + name: data.group.name + }); + }} + disabled={!isAllowed} + > + Delete Group + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx similarity index 71% rename from frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx rename to frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx index e7f38318af..ab81aa4450 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx @@ -22,21 +22,22 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["groupMembers"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; + popUp: UsePopUpState<["addGroupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; }; -export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); - const popUpData = popUp?.groupMembers?.data as { + const popUpData = popUp?.addGroupMembers?.data as { groupId: string; slug: string; }; @@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { groupSlug: popUpData?.slug, offset, limit: perPage, - search: debouncedSearch + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS }); const { totalCount = 0 } = data ?? {}; @@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { setPage }); - const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); - const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - const handleAssignment = async (username: string, assign: boolean) => { + const handleAddMember = async (username: string) => { try { - if (!popUpData?.slug) return; - - if (assign) { - await assignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - } else { - await unassignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" }); + return; } + await addUserToGroupMutateAsync({ + groupId: popUpData.groupId, + username, + slug: popUpData.slug + }); + createNotification({ - text: `Successfully ${assign ? "assigned" : "removed"} user ${ - assign ? "to" : "from" - } group`, + text: "Successfully assigned user to the group", type: "success" }); } catch (err) { createNotification({ - text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, + text: "Failed to assign user to the group", type: "error" }); } @@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("groupMembers", isOpen); + handlePopUpToggle("addGroupMembers", isOpen); }} > - + setSearchMemberFilter(e.target.value)} @@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { {isLoading && } {!isLoading && - data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { + data?.users?.map(({ id, firstName, lastName, username }) => { return ( @@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { colorSchema="primary" variant="outline_bg" type="submit" - onClick={() => handleAssignment(username, !isPartOfGroup)} + onClick={() => handleAddMember(username)} > - {isPartOfGroup ? "Unassign" : "Assign"} + Assign ); }} @@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { )} {!isLoading && !data?.users?.length && ( )} diff --git a/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx new file mode 100644 index 0000000000..39187f3cbc --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx @@ -0,0 +1,192 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z + .string() + .min(5, "Slug must be at least 5 characters long") + .max(36, "Slug must be 36 characters or fewer"), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["groupCreateUpdate"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, + state?: boolean + ) => void; +}; + +export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) + }); + } else { + reset({ + name: "", + slug: "", + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + }); + } + }, [popUp?.groupCreateUpdate?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + id: group.groupId, + name, + slug, + role: role.slug || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role.slug || undefined + }); + } + handlePopUpToggle("groupCreateUpdate", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("groupCreateUpdate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx new file mode 100644 index 0000000000..624cc72419 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx @@ -0,0 +1,88 @@ +import { faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetGroupById } from "@app/hooks/api/"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + groupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void; +}; + +export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => { + const { data, isLoading } = useGetGroupById(groupId); + + if (isLoading) return ; + + return data ? ( +
+
+

Group Details

+ + {(isAllowed) => { + return ( + + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + > + + + + ); + }} + +
+
+
+

Group ID

+
+

{data.group.id}

+ +
+
+
+

Name

+

{data.group.name}

+
+
+

Slug

+
+

{data.group.slug}

+ +
+
+
+

Organization Role

+

{data.group.role}

+
+
+

Created At

+

+ {new Date(data.group.createdAt).toLocaleString()} +

+
+
+
+ ) : ( +
+
+

Group data not found

+
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx new file mode 100644 index 0000000000..08de6c724d --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupMembersModal } from "../AddGroupMemberModal"; +import { GroupMembersTable } from "./GroupMembersTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupMembers", + "removeMemberFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); + const handleRemoveUserFromGroup = async (username: string) => { + try { + await removeUserFromGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${username} from the group`, + type: "success" + }); + + handlePopUpToggle("removeMemberFromGroup", false); + } catch (err) { + createNotification({ + text: `Failed to remove user ${username} from the group`, + type: "error" + }); + } + }; + + return ( +
+
+

Group Members

+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
+
+ +
+ + handlePopUpToggle("removeMemberFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const userData = popUp?.removeMemberFromGroup?.data as { + username: string; + id: string; + }; + + return handleRemoveUserFromGroup(userData.username); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx new file mode 100644 index 0000000000..2423fd6d5c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupMembershipRow } from "./GroupMembershipRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>, + data?: {} + ) => void; +}; + +enum GroupMembersOrderBy { + Name = "name" +} + +export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + + const { data: groupMemberships, isLoading } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: EFilterReturnedUsers.EXISTING_MEMBERS + }); + + const filteredGroupMemberships = useMemo(() => { + return groupMemberships && groupMemberships?.users + ? groupMemberships?.users + ?.filter((membership) => { + const userSearchString = `${membership.firstName && membership.firstName} ${ + membership.lastName && membership.lastName + } ${membership.email && membership.email} ${ + membership.username && membership.username + }`; + return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); + }) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + const membershipOneComparisonString = membershipOne.firstName + ? membershipOne.firstName + : membershipOne.email; + + const membershipTwoComparisonString = membershipTwo.firstName + ? membershipTwo.firstName + : membershipTwo.email; + + const comparison = membershipOneComparisonString + .toLowerCase() + .localeCompare(membershipTwoComparisonString.toLowerCase()); + + return comparison; + }) + : []; + }, [groupMemberships, orderDirection, search]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships?.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
EmailAdded On +
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} + {!groupMemberships?.users.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx new file mode 100644 index 0000000000..943a6574e4 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx @@ -0,0 +1,53 @@ +import { faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TGroupUser } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + user: TGroupUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void; +}; + +export const GroupMembershipRow = ({ + user: { firstName, lastName, username, joinedGroupAt, email, id }, + handlePopUpOpen +}: Props) => { + return ( + + +

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + +

{email}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + {(isAllowed) => { + return ( + + handlePopUpOpen("removeMemberFromGroup", { username })} + variant="plain" + colorSchema="danger" + > + + + + ); + }} + + + + ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx new file mode 100644 index 0000000000..70c696609c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx @@ -0,0 +1 @@ +export { GroupMembersSection } from "./GroupMembersSection"; diff --git a/frontend/src/views/Org/GroupPage/components/index.tsx b/frontend/src/views/Org/GroupPage/components/index.tsx new file mode 100644 index 0000000000..003c479108 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/index.tsx @@ -0,0 +1 @@ +export { GroupDetailsSection } from "./GroupDetailsSection"; diff --git a/frontend/src/views/Org/GroupPage/index.tsx b/frontend/src/views/Org/GroupPage/index.tsx new file mode 100644 index 0000000000..3dec23a1cb --- /dev/null +++ b/frontend/src/views/Org/GroupPage/index.tsx @@ -0,0 +1 @@ +export { GroupPage } from "./GroupPage"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index f72adf61fa..9c39491505 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupsTable } from "./OrgGroupsTable"; @@ -78,7 +77,6 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> - { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); @@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { .slice(offset, perPage * page) .map(({ id, name, slug, role, customRole }) => { return ( - + router.push(`/org/${orgId}/groups/${id}`)} + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`org-group-${id}`} + > {name} {slug} @@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("groupMembers", { - groupId: id, - slug - }); - }} - disabled={!isAllowed} - > - Manage Users - - )} - - {(isAllowed) => ( { )} + + {(isAllowed) => ( + router.push(`/org/${orgId}/groups/${id}`)} + disabled={!isAllowed} + > + Manage Members + + )} + diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index f69cd1478b..db1888a228 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -1,4 +1,5 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; import { faCaretDown, faChevronLeft, @@ -17,12 +18,13 @@ import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, FormControl, FormLabel, Input, - Modal, - ModalContent, - ModalTrigger, Popover, PopoverContent, PopoverTrigger, @@ -35,7 +37,6 @@ import { useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useCreateIdentityProjectAdditionalPrivilege, useGetIdentityProjectPrivilegeDetails, @@ -43,10 +44,10 @@ import { } from "@app/hooks/api"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types"; import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule"; import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState"; import { formRolePermission2API, + isConditionalSubjects, PROJECT_PERMISSION_OBJECT, projectRoleFormSchema, rolePermission2Form @@ -88,7 +89,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ }: Props) => { const isCreate = !privilegeId; const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: privilegeDetails, isLoading } = useGetIdentityProjectPrivilegeDetails({ identityId, @@ -98,7 +98,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId }) ); const form = useForm({ @@ -194,6 +194,30 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ } } + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + return (
Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((permissionSubject) => ( + onNewPolicy(permissionSubject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title} + + ))} + +
@@ -376,17 +415,19 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
Policies
{(isCreate || !isLoading) && } - {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - - {renderConditionalComponents(subject, isDisabled)} - - ))} + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map( + (permissionSubject) => ( + + {renderConditionalComponents(permissionSubject, isDisabled)} + + ) + )}
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index 975ddb5c78..db900b1b2b 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format, formatDistance } from "date-fns"; @@ -83,7 +84,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={permission.cannot( ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { + identityId + }) )} /> @@ -103,7 +106,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe @@ -192,7 +197,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx index 300114228f..1fa98628e9 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format, formatDistance } from "date-fns"; @@ -93,7 +94,9 @@ export const IdentityRoleDetailsSection = ({

Project Roles

@@ -175,7 +178,9 @@ export const IdentityRoleDetailsSection = ({
diff --git a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx index 4b6bd9f390..8735b047de 100644 --- a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx +++ b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -17,16 +16,10 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .min(1) - .toLowerCase() - .max(32) - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) }); diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx index e5a6adf4d2..4118429b27 100644 --- a/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -17,12 +17,13 @@ import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, FormControl, FormLabel, Input, - Modal, - ModalContent, - ModalTrigger, Popover, PopoverContent, PopoverTrigger, @@ -35,7 +36,6 @@ import { useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useCreateProjectUserAdditionalPrivilege, useGetProjectUserPrivilegeDetails, @@ -43,14 +43,13 @@ import { } from "@app/hooks/api"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule"; import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState"; import { formRolePermission2API, + isConditionalSubjects, PROJECT_PERMISSION_OBJECT, projectRoleFormSchema, - rolePermission2Form -} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; + rolePermission2Form} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection"; type Props = { @@ -88,7 +87,6 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ }: Props) => { const isCreate = !privilegeId; const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: privilegeDetails, isLoading } = useGetProjectUserPrivilegeDetails( privilegeId || "" @@ -167,6 +165,30 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ } }; + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + const privilegeTemporaryAccess = form.watch("temporaryAccess"); const isTemporary = privilegeTemporaryAccess?.isTemporary; const isExpired = @@ -229,24 +251,39 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ > Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + +
diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index defd328638..362c156563 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { useRouter } from "next/router"; +import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp, @@ -349,7 +350,9 @@ export const IdentityTab = withProjectPermission( {(isAllowed) => ( ; - -type Props = { - identityProjectMember: IdentityMembership; - onOpenUpgradeModal: (title: string) => void; -}; -export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const { permission } = useProjectPermission(); - const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity - ); - - const roleForm = useForm({ - resolver: zodResolver(roleFormSchema), - values: { - roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ - slug: customRoleSlug || role, - temporaryAccess: dto.isTemporary - ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } - : { - isTemporary: dto.isTemporary - } - })) - } - }); - const selectedRoleList = useFieldArray({ - name: "roles", - control: roleForm.control - }); - - const formRoleField = roleForm.watch("roles"); - - const updateMembershipRole = useUpdateIdentityWorkspaceRole(); - - const handleRoleUpdate = async (data: TRoleForm) => { - if (updateMembershipRole.isLoading) return; - - const sanitizedRoles = data.roles.map((el) => { - const { isTemporary } = el.temporaryAccess; - if (!isTemporary) { - return { role: el.slug, isTemporary: false as const }; - } - return { - role: el.slug, - isTemporary: true as const, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: el.temporaryAccess.temporaryRange, - temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime - }; - }); - - const hasCustomRoleSelected = sanitizedRoles.some( - (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) - ); - - if (hasCustomRoleSelected && subscription && !subscription?.rbac) { - onOpenUpgradeModal( - "You can assign custom roles to members if you upgrade your Infisical plan." - ); - return; - } - - try { - await updateMembershipRole.mutateAsync({ - workspaceId, - identityId: identityProjectMember.identity.id, - roles: sanitizedRoles - }); - createNotification({ text: "Successfully updated roles", type: "success" }); - roleForm.reset(undefined, { keepValues: true }); - } catch (err) { - createNotification({ text: "Failed to update role", type: "error" }); - } - }; - - if (isRolesLoading) - return ( -
- -
- ); - - return ( -
-
Roles
-

Select one of the pre-defined or custom roles.

-
-
-
- {selectedRoleList.fields.map(({ id }, index) => { - const { temporaryAccess } = formRoleField[index]; - const isTemporary = temporaryAccess?.isTemporary; - const isExpired = - temporaryAccess.isTemporary && - new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); - - return ( -
- ( - - )} - /> - - -
- - - -
-
- -
-
- Configure timed access -
- {isExpired && Expired} - ( - } - isError={Boolean(error?.message)} - errorText={error?.message} - > - - - )} - /> -
- - {temporaryAccess.isTemporary && ( - - )} -
-
-
-
- { - if (selectedRoleList.fields.length > 1) { - selectedRoleList.remove(index); - } - }} - > - - -
- ); - })} -
-
- - {(isAllowed) => ( - - )} - - -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx deleted file mode 100644 index 3640984cdf..0000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Link from "next/link"; - -import { Alert, AlertDescription } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; - -import { IdentityRbacSection } from "./IdentityRbacSection"; - -type Props = { - identityProjectMember: IdentityMembership; - onOpenUpgradeModal: (title: string) => void; -}; -export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { - const { currentWorkspace } = useWorkspace(); - - return ( -
- - - - - - Click here to access them now - - - - -
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx deleted file mode 100644 index f59675cb3d..0000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentityRoleForm } from "./IdentityRoleForm"; diff --git a/frontend/src/views/Project/RolePage/components/RoleModal.tsx b/frontend/src/views/Project/RolePage/components/RoleModal.tsx index 5a87b4a612..cf8cab03bf 100644 --- a/frontend/src/views/Project/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Project/RolePage/components/RoleModal.tsx @@ -13,12 +13,13 @@ import { useUpdateProjectRole } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z .object({ name: z.string(), description: z.string(), - slug: z.string() + slug: slugSchema({ min: 1 }) }) .required(); diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx index e298281f8d..e7f4fc91aa 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx @@ -105,9 +105,14 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.extend({ + inverted: z.boolean().optional(), + conditions: ConditionSchema + }) + .array() + .default([]), [ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]), - [ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]), @@ -139,7 +144,8 @@ type TConditionalFields = | ProjectPermissionSub.Secrets | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports - | ProjectPermissionSub.DynamicSecrets; + | ProjectPermissionSub.DynamicSecrets + | ProjectPermissionSub.Identity; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -147,7 +153,8 @@ export const isConditionalSubjects = ( subject === (ProjectPermissionSub.Secrets as const) || subject === ProjectPermissionSub.DynamicSecrets || subject === ProjectPermissionSub.SecretImports || - subject === ProjectPermissionSub.SecretFolders; + subject === ProjectPermissionSub.SecretFolders || + subject === ProjectPermissionSub.Identity; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -483,17 +490,17 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove members", value: "delete" } ] }, - [ProjectPermissionSub.Groups]: { - title: "Group Management", + [ProjectPermissionSub.Identity]: { + title: "Machine Identity Management", actions: [ { label: "Read", value: "read" }, - { label: "Create", value: "create" }, + { label: "Add", value: "create" }, { label: "Modify", value: "edit" }, { label: "Remove", value: "delete" } ] }, - [ProjectPermissionSub.Identity]: { - title: "Machine Identity Management", + [ProjectPermissionSub.Groups]: { + title: "Group Management", actions: [ { label: "Read", value: "read" }, { label: "Create", value: "create" }, @@ -527,7 +534,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { ] }, [ProjectPermissionSub.Environments]: { - title: "Environments", + title: "Environment Management", actions: [ { label: "Read", value: "read" }, { label: "Create", value: "create" }, diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 1e00306be7..16397ac248 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -5,14 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Alert, Button, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { GeneralPermissionConditions } from "./components/GeneralPermissionConditions"; import { GeneralPermissionPolicies } from "./components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "./components/NewPermissionRule"; +import { IdentityManagementPermissionConditions } from "./components/IdentityManagementPermissionConditions"; import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; import { PermissionEmptyState } from "./PermissionEmptyState"; import { @@ -37,6 +42,10 @@ export const renderConditionalComponents = ( return ; if (isConditionalSubjects(subject)) { + if (subject === ProjectPermissionSub.Identity) { + return ; + } + return ; } @@ -45,7 +54,6 @@ export const renderConditionalComponents = ( export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: role, isLoading } = useGetProjectRoleBySlug( currentWorkspace?.id ?? "", @@ -83,6 +91,30 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? ""); + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + return (
{ > Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[ + a as keyof typeof PROJECT_PERMISSION_OBJECT + ].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + +
)}
-
{!isLoading && } {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx index 9d6e699cbd..15d819fc82 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx @@ -157,7 +157,7 @@ export const GeneralPermissionPolicies = { - items.insert(rootIndex, [ + items.insert(rootIndex + 1, [ { read: false, edit: false, create: false, delete: false } as any ]); }} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx new file mode 100644 index 0000000000..8a62c6ad91 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx @@ -0,0 +1,171 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { + PermissionConditionOperators, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; + +import { TFormSchema } from "../ProjectRoleModifySection.utils"; +import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const IdentityManagementPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + formState: { errors } + } = useFormContext(); + const permissionSubject = ProjectPermissionSub.Identity; + const items = useFieldArray({ + control, + name: `permissions.${permissionSubject}.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ When this policy should apply (always if no conditions are added). +

+
+ {items.fields.map((el, index) => { + const condition = + (watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }) || {}; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message} +
+ )} +
{}
+
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx index 21fad117af..9120f0364f 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx @@ -21,12 +21,13 @@ export const renderOperatorSelectItems = (type: string) => { if (type === "secretTags") { return Contains; } + return ( <> Equal Not Equal Glob Match - Contains + In ); }; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 93e906373d..42e576f06f 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -130,12 +130,14 @@ export const AccessApprovalRequest = ({ if (statusFilter === "open") return requests?.filter( (request) => + !request.policy.deletedAt && !request.isApproved && !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); if (statusFilter === "close") return requests?.filter( (request) => + request.policy.deletedAt || request.isApproved || request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); @@ -144,8 +146,6 @@ export const AccessApprovalRequest = ({ }, [requests, statusFilter, requestedByFilter, envFilter]); const generateRequestDetails = (request: TAccessApprovalRequest) => { - console.log(request); - const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; const isRejectedByAnyone = request.reviewers.some( ({ status }) => status === ApprovalStatus.REJECTED diff --git a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx index 53b00f34a5..db53d26ae3 100644 --- a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -1,4 +1,4 @@ -import { ClipboardEvent } from "react"; +import { ClipboardEvent, useRef } from "react"; import { Controller, useForm } from "react-hook-form"; import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -46,6 +46,7 @@ export const CreateSecretForm = ({ control, reset, setValue, + watch, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(typeSchema) }); const { closePopUp } = usePopUpAction(); @@ -59,6 +60,11 @@ export const CreateSecretForm = ({ canReadTags ? workspaceId : "" ); + const secretKeyInputRef = useRef(null); + const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key"); + + const secretKey = watch("key"); + const slugSchema = z.string().trim().toLowerCase().min(1); const createNewTag = async (slug: string) => { // TODO: Replace with slugSchema generic @@ -108,13 +114,23 @@ export const CreateSecretForm = ({ }; const handlePaste = (e: ClipboardEvent) => { - e.preventDefault(); const delimitters = [":", "="]; const pastedContent = e.clipboardData.getData("text"); const { key, value } = getKeyValue(pastedContent, delimitters); - setValue("key", key); - setValue("value", value); + const isWholeKeyHighlighted = + secretKeyInputRef.current && + secretKeyInputRef.current.selectionStart === 0 && + secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length; + + if (!secretKey || isWholeKeyHighlighted) { + e.preventDefault(); + + setValue("key", key); + if (value) { + setValue("value", value); + } + } }; return ( @@ -126,7 +142,12 @@ export const CreateSecretForm = ({ errorText={errors?.key?.message} > { + setSecretKeyHookRef(e); + // @ts-expect-error this is for multiple ref single component + secretKeyInputRef.current = e; + }} placeholder="Type your secret name" onPaste={handlePaste} autoCapitalization={autoCapitalize} diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 36e430205b..aabd7899e2 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -1,4 +1,4 @@ -import { ClipboardEvent } from "react"; +import { ClipboardEvent, useRef } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; @@ -46,6 +46,7 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { control, reset, setValue, + watch, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(typeSchema) }); @@ -61,6 +62,11 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { canReadTags ? workspaceId : "" ); + const secretKeyInputRef = useRef(null); + const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key"); + + const secretKey = watch("key"); + const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => { const promises = selectedEnv.map(async (env) => { const environment = env.slug; @@ -152,13 +158,23 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { }; const handlePaste = (e: ClipboardEvent) => { - e.preventDefault(); const delimitters = [":", "="]; const pastedContent = e.clipboardData.getData("text"); const { key, value } = getKeyValue(pastedContent, delimitters); - setValue("key", key); - setValue("value", value); + const isWholeKeyHighlighted = + secretKeyInputRef.current && + secretKeyInputRef.current.selectionStart === 0 && + secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length; + + if (!secretKey || isWholeKeyHighlighted) { + e.preventDefault(); + + setValue("key", key); + if (value) { + setValue("value", value); + } + } }; const createWsTag = useCreateWsTag(); @@ -189,7 +205,12 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { errorText={errors?.key?.message} > { + setSecretKeyHookRef(e); + // @ts-expect-error this is for multiple ref single component + secretKeyInputRef.current = e; + }} placeholder="Type your secret name" onPaste={handlePaste} autoCapitalization={currentWorkspace?.autoCapitalization} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx index 281061db4a..93c24586e0 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import axios from "axios"; import { z } from "zod"; @@ -15,6 +14,7 @@ import { useGetSlackIntegrationById, useUpdateSlackIntegration } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; type Props = { id?: string; @@ -22,13 +22,7 @@ type Props = { }; const slackFormSchema = z.object({ - slug: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + slug: slugSchema({ min: 1, field: "Alias" }), description: z.string().optional() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx index 0e703798e0..7cb6d5d09e 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx @@ -31,7 +31,7 @@ type Props = { }; const formSchema = z.object({ - slug: slugSchema, + slug: slugSchema(), name: z.string().trim().min(1), permissions: projectRoleFormSchema.shape.permissions }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index 677a7c773e..b72d12c73e 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -32,7 +32,7 @@ const formSchema = z.object({ environments: z .object({ name: z.string().trim().min(1), - slug: slugSchema + slug: slugSchema({ min: 1, max: 32 }) }) .array() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 1f65df7957..e601e0319f 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -1,6 +1,5 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,17 +17,10 @@ import { useCreateProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional() }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 68bbeb8406..00f160e756 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["createEnv"]>; @@ -15,26 +15,20 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup +const schema = z.object({ + environmentName: z .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() + .min(1, { message: "Environment Name field must be at least 1 character" }), + environmentSlug: slugSchema() }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useCreateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema) + resolver: zodResolver(schema) }); const onFormSubmit = async ({ environmentName, environmentSlug }: FormData) => { @@ -112,7 +106,11 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle Create -
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx index c6b2152ccb..ad11c2381b 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useUpdateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["updateEnv"]>; @@ -15,25 +15,18 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - name: yup.string().label("Environment Name").required(), - slug: yup - .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() +const schema = z.object({ + name: z.string(), + slug: slugSchema({ min: 1 }) }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useUpdateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema), + resolver: zodResolver(schema), values: popUp.updateEnv.data as FormData }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx index 75f6b69bfb..96c667050a 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -8,11 +7,10 @@ import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@ap import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z.object({ - slug: z.string().refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }) + slug: slugSchema({ min: 1, field: "Tag Slug" }) }); export type FormData = z.infer; diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 3e0d6ab721..9d300eaf45 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -282,6 +282,20 @@ spec: description: 'The Kubernetes Secret type (experimental feature). More info: https://kubernetes.io/docs/concepts/configuration/secret/#secret-types' type: string + template: + description: The template to transform the secret data + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the top + level of your template. Secrets defined in the template will + take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index 65da2498ca..1af2faf202 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -147,6 +147,20 @@ type MangedKubeSecretConfig struct { // +kubebuilder:validation:Optional // +kubebuilder:default:=Orphan CreationPolicy string `json:"creationPolicy"` + + // The template to transform the secret data + // +kubebuilder:validation:Optional + Template *InfisicalSecretTemplate `json:"template,omitempty"` +} + +type InfisicalSecretTemplate struct { + // This injects all retrieved secrets into the top level of your template. + // Secrets defined in the template will take precedence over the injected ones. + // +kubebuilder:validation:Optional + IncludeAllSecrets bool `json:"includeAllSecrets"` + // The template key values + // +kubebuilder:validation:Optional + Data map[string]string `json:"data,omitempty"` } type CaReference struct { diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index dd242910c7..41e4d3f20e 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -133,7 +133,7 @@ func (in *InfisicalSecret) DeepCopyInto(out *InfisicalSecret) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -192,7 +192,7 @@ func (in *InfisicalSecretSpec) DeepCopyInto(out *InfisicalSecretSpec) { *out = *in out.TokenSecretReference = in.TokenSecretReference out.Authentication = in.Authentication - out.ManagedSecretReference = in.ManagedSecretReference + in.ManagedSecretReference.DeepCopyInto(&out.ManagedSecretReference) out.TLS = in.TLS } @@ -228,6 +228,28 @@ func (in *InfisicalSecretStatus) DeepCopy() *InfisicalSecretStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InfisicalSecretTemplate) DeepCopyInto(out *InfisicalSecretTemplate) { + *out = *in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalSecretTemplate. +func (in *InfisicalSecretTemplate) DeepCopy() *InfisicalSecretTemplate { + if in == nil { + return nil + } + out := new(InfisicalSecretTemplate) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeSecretReference) DeepCopyInto(out *KubeSecretReference) { *out = *in @@ -293,6 +315,11 @@ func (in *MachineIdentityScopeInWorkspace) DeepCopy() *MachineIdentityScopeInWor // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MangedKubeSecretConfig) DeepCopyInto(out *MangedKubeSecretConfig) { *out = *in + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(InfisicalSecretTemplate) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MangedKubeSecretConfig. diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 633b484600..78027f9294 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -283,6 +283,20 @@ spec: description: 'The Kubernetes Secret type (experimental feature). More info: https://kubernetes.io/docs/concepts/configuration/secret/#secret-types' type: string + template: + description: The template to transform the secret data + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the top + level of your template. Secrets defined in the template + will take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/k8-operator/config/samples/sample-with-template.yml b/k8-operator/config/samples/sample-with-template.yml new file mode 100644 index 0000000000..9d9d86ab58 --- /dev/null +++ b/k8-operator/config/samples/sample-with-template.yml @@ -0,0 +1,113 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" +spec: + hostAPI: https://app.infisical.com/api + resyncInterval: 10 + # tls: + # caRef: + # secretName: custom-ca-certificate + # secretNamespace: default + # key: ca.crt + authentication: + # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. + # If you have multiple authentication methods defined, it may cause issues. + + # (Deprecated) Service Token Auth + serviceToken: + serviceTokenSecretReference: + secretName: service-token + secretNamespace: default + secretsScope: + envSlug: + secretsPath: + recursive: true + + # Universal Auth + universalAuth: + secretsScope: + projectSlug: new-ob-em + envSlug: dev # "dev", "staging", "prod", etc.. + secretsPath: "/" # Root is "/" + recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false + credentialsRef: + secretName: universal-auth-credentials + secretNamespace: default + + # Native Kubernetes Auth + kubernetesAuth: + identityId: + serviceAccountTokenPath: "/path/to/your/service-account/token" # Optional, defaults to /var/run/secrets/kubernetes.io/serviceaccount/token + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # AWS IAM Auth + awsIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # Azure Auth + azureAuth: + identityId: + resource: https://management.azure.com/&client_id=your_client_id # This field is optional, and will default to "https://management.azure.com/" if nothing is provided. + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP ID Token Auth + gcpIdTokenAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP IAM Auth + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: "/path/to-service-account-key-file-path.json" + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + managedSecretReference: + secretName: managed-secret + secretNamespace: default + template: + includeAllSecrets: true + data: + SSH_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" + creationPolicy: "Orphan" ## Owner | Orphan + # secretType: kubernetes.io/dockerconfigjson + + # # To be depreciated soon + # tokenSecretReference: + # secretName: service-token + # secretNamespace: default diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index a66b4d7993..cdf2a4a265 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -1,10 +1,12 @@ package controllers import ( + "bytes" "context" "errors" "fmt" "strings" + "text/template" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" @@ -228,9 +230,36 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { plainProcessedSecrets := make(map[string][]byte) secretType := infisicalSecret.Spec.ManagedSecretReference.SecretType + managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template - for _, secret := range secretsFromAPI { - plainProcessedSecrets[secret.Key] = []byte(secret.Value) // plain process + if managedTemplateData == nil || managedTemplateData.IncludeAllSecrets { + for _, secret := range secretsFromAPI { + plainProcessedSecrets[secret.Key] = []byte(secret.Value) // plain process + } + } + + if managedTemplateData != nil { + secretKeyValue := make(map[string]model.SecretTemplateOptions) + for _, secret := range secretsFromAPI { + secretKeyValue[secret.Key] = model.SecretTemplateOptions{ + Value: secret.Value, + SecretPath: secret.SecretPath, + } + } + + for templateKey, userTemplate := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTemplate) + if err != nil { + return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, secretKeyValue) + if err != nil { + return fmt.Errorf("unable to execute template: %s [err=%v]", templateKey, err) + } + plainProcessedSecrets[templateKey] = buf.Bytes() + } } // copy labels and annotations from InfisicalSecret CRD @@ -285,10 +314,38 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context return nil } -func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context.Context, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { +func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { + managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template + plainProcessedSecrets := make(map[string][]byte) - for _, secret := range secretsFromAPI { - plainProcessedSecrets[secret.Key] = []byte(secret.Value) + if managedTemplateData == nil || managedTemplateData.IncludeAllSecrets { + for _, secret := range secretsFromAPI { + plainProcessedSecrets[secret.Key] = []byte(secret.Value) + } + } + + if managedTemplateData != nil { + secretKeyValue := make(map[string]model.SecretTemplateOptions) + for _, secret := range secretsFromAPI { + secretKeyValue[secret.Key] = model.SecretTemplateOptions{ + Value: secret.Value, + SecretPath: secret.SecretPath, + } + } + + for templateKey, userTemplate := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTemplate) + if err != nil { + return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, secretKeyValue) + if err != nil { + return fmt.Errorf("unable to execute template: %s [err=%v]", templateKey, err) + } + plainProcessedSecrets[templateKey] = buf.Bytes() + } } // Initialize the Annotations map if it's nil @@ -434,7 +491,7 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context if managedKubeSecret == nil { return r.CreateInfisicalManagedKubeSecret(ctx, infisicalSecret, plainTextSecretsFromApi, updateDetails.ETag) } else { - return r.UpdateInfisicalManagedKubeSecret(ctx, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) + return r.UpdateInfisicalManagedKubeSecret(ctx, infisicalSecret, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) } } diff --git a/k8-operator/packages/model/model.go b/k8-operator/packages/model/model.go index 3d16f3a848..e3328061cc 100644 --- a/k8-operator/packages/model/model.go +++ b/k8-operator/packages/model/model.go @@ -17,8 +17,14 @@ type RequestUpdateUpdateDetails struct { } type SingleEnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - ID string `json:"_id"` + Key string `json:"key"` + Value string `json:"value"` + SecretPath string `json:"secretPath"` + Type string `json:"type"` + ID string `json:"id"` +} + +type SecretTemplateOptions struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` } diff --git a/k8-operator/packages/util/secrets.go b/k8-operator/packages/util/secrets.go index 9fb79c1def..b3325a701b 100644 --- a/k8-operator/packages/util/secrets.go +++ b/k8-operator/packages/util/secrets.go @@ -69,10 +69,11 @@ func GetPlainTextSecretsViaMachineIdentity(infisicalClient infisical.InfisicalCl for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) } @@ -120,10 +121,11 @@ func GetPlainTextSecretsViaServiceToken(infisicalClient infisical.InfisicalClien for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) } @@ -183,10 +185,11 @@ func GetPlainTextSecretsViaServiceAccount(infisicalClient infisical.InfisicalCli for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) }