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

- - - - - Deploy to DO - -

-

Infisical is released under the MIT license. diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index c694979db..0028381bd 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -10,12 +10,15 @@ export const mockQueue = (): TQueueServiceFactory => { queue: async (name, jobData) => { job[name] = jobData; }, + queuePg: async () => {}, + initialize: async () => {}, shutdown: async () => undefined, stopRepeatableJob: async () => true, start: (name, jobFn) => { queues[name] = jobFn; workers[name] = jobFn; }, + startPg: async () => {}, listen: (name, event) => { events[name] = event; }, diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 866b0f45f..58f2bffeb 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); + 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 2113d21a6..2fba00120 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", + "@google-cloud/kms": "^4.5.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/auth-app": "^7.1.1", "@octokit/plugin-retry": "^5.0.5", @@ -92,6 +93,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", @@ -5598,6 +5600,18 @@ "yaml": "^2.2.2" } }, + "node_modules/@google-cloud/kms": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz", + "integrity": "sha512-i2vC0DI7bdfEhQszqASTw0KVvbB7HsO2CwTBod423NawAu7FWi+gVVa7NLfXVNGJaZZayFfci2Hu+om/HmyEjQ==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", @@ -12259,14 +12273,6 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "node_modules/buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", - "engines": { - "node": ">=4" - } - }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -15086,6 +15092,44 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/google-gax": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz", + "integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/google-gax/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/googleapis": { "version": "137.1.0", "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", @@ -18185,11 +18229,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18408,15 +18447,13 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pg": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", - "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "dependencies": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.6.2", - "pg-pool": "^3.6.1", - "pg-protocol": "^1.6.0", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -18435,6 +18472,19 @@ } } }, + "node_modules/pg-boss": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.1.5.tgz", + "integrity": "sha512-H87NL6c7N6nTCSCePh16EaSQVSFevNXWdJuzY6PZz4rw+W/nuMKPfI/vYyXS0AdT1g1Q3S3EgeOYOHcB7ZVToQ==", + "dependencies": { + "cron-parser": "^4.9.0", + "pg": "^8.13.0", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pg-cloudflare": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", @@ -18471,17 +18521,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", - "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", - "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-query-stream": { "version": "4.5.3", @@ -18510,9 +18560,9 @@ } }, "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pgpass": { "version": "1.0.5", @@ -19223,6 +19273,18 @@ "node": ">=6" } }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/protobufjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", @@ -20111,6 +20173,20 @@ "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -22130,7 +22206,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/backend/package.json b/backend/package.json index 1aabdde2e..0dafc475c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -136,6 +136,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", + "@google-cloud/kms": "^4.5.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/auth-app": "^7.1.1", "@octokit/plugin-retry": "^5.0.5", @@ -200,6 +201,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", diff --git a/backend/src/@types/fastify-request-context.d.ts b/backend/src/@types/fastify-request-context.d.ts index caef4d5b2..fc8d94e07 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 c2e9f2293..2c6e13272 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"; @@ -89,6 +91,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 @@ -117,6 +123,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 000000000..c7fb6fe39 --- /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 f4c525a4f..3650face9 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 94aeba050..06ae3e5c4 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 7dbb62fc2..4aa26eb36 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 4b1566c55..1d24c0578 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/external-kms-router.ts b/backend/src/ee/routes/v1/external-kms-router.ts index 4e43d6ed9..a48e28e3d 100644 --- a/backend/src/ee/routes/v1/external-kms-router.ts +++ b/backend/src/ee/routes/v1/external-kms-router.ts @@ -4,9 +4,15 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ExternalKmsAwsSchema, + ExternalKmsGcpCredentialSchema, + ExternalKmsGcpSchema, ExternalKmsInputSchema, - ExternalKmsInputUpdateSchema + ExternalKmsInputUpdateSchema, + KmsGcpKeyFetchAuthType, + KmsProviders, + TExternalKmsGcpCredentialSchema } from "@app/ee/services/external-kms/providers/model"; +import { NotFoundError } from "@app/lib/errors"; 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"; @@ -44,7 +50,8 @@ const sanitizedExternalSchemaForGetById = KmsKeysSchema.extend({ statusDetails: true, provider: true }).extend({ - providerInput: ExternalKmsAwsSchema + // for GCP, we don't return the credential object as it is sensitive data that should not be exposed + providerInput: z.union([ExternalKmsAwsSchema, ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })]) }) }); @@ -286,4 +293,67 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { return { externalKms }; } }); + + server.route({ + method: "POST", + url: "/gcp/keys", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential), + region: z.string().trim().min(1), + credential: ExternalKmsGcpCredentialSchema + }), + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms), + region: z.string().trim().min(1), + kmsId: z.string().trim().min(1) + }) + ]), + response: { + 200: z.object({ + keys: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { region, authMethod } = req.body; + let credentialJson: TExternalKmsGcpCredentialSchema | undefined; + + if (authMethod === KmsGcpKeyFetchAuthType.Credential) { + credentialJson = req.body.credential; + } else if (authMethod === KmsGcpKeyFetchAuthType.Kms) { + const externalKms = await server.services.externalKms.findById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.body.kmsId + }); + + if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) { + throw new NotFoundError({ message: "KMS not found or not of type GCP" }); + } + + credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema; + } + + if (!credentialJson) { + throw new NotFoundError({ + message: "Something went wrong while fetching the GCP credential, please check inputs and try again" + }); + } + + const results = await server.services.externalKms.fetchGcpKeys({ + credential: credentialJson, + gcpRegion: region + }); + + return results; + } + }); }; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 780e5ec00..67f955ecb 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 d342f95ce..1eadb4051 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 e675121e9..cd25c5be5 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 232f4b0b5..30f31c545 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 ba2c0aa9f..0fa35ab1d 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 5b115ab4e..60f93d65d 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 5fbf784f6..e1c56583c 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 e58a6335b..bb3e179dd 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 5df03f68d..7934c3f90 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 70511ce87..0152104c6 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 220701410..e14451498 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 ee7cf2572..24436e695 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 8784d05e2..c1ccedff7 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 14accff41..b8475c446 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 83a2fafa6..e312c3886 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,6 +1,7 @@ import { RawAxiosRequestHeaders } from "axios"; import { SecretKeyEncoding } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -20,27 +21,130 @@ type TAuditLogQueueServiceFactoryDep = { licenseService: Pick; }; -export type TAuditLogQueueServiceFactory = ReturnType; +export type TAuditLogQueueServiceFactory = Awaited>; // keep this timeout 5s it must be fast because else the queue will take time to finish // audit log is a crowded queue thus needs to be fast export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000; -export const auditLogQueueServiceFactory = ({ + +export const auditLogQueueServiceFactory = async ({ auditLogDAL, queueService, projectDAL, licenseService, auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { + const appCfg = getConfig(); + const pushToLog = async (data: TCreateAuditLogDTO) => { - await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { - removeOnFail: { - count: 3 - }, - removeOnComplete: true - }); + if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) { + await queueService.queuePg(QueueJobs.AuditLog, data, { + retryLimit: 10, + retryBackoff: true + }); + } else { + await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { + removeOnFail: { + count: 3 + }, + removeOnComplete: true + }); + } }; + 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; + } + + 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; + + 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 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" }; + + 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) + }); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + } + queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; let { orgId } = 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 ac7188c47..adc3debaa 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -63,6 +63,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", @@ -371,6 +372,13 @@ interface AuthorizeIntegrationEvent { }; } +interface UpdateIntegrationAuthEvent { + type: EventType.UPDATE_INTEGRATION_AUTH; + metadata: { + integration: string; + }; +} + interface UnauthorizeIntegrationEvent { type: EventType.UNAUTHORIZE_INTEGRATION; metadata: { @@ -1805,6 +1813,7 @@ export type Event = | DeleteSecretBatchEvent | GetWorkspaceKeyEvent | AuthorizeIntegrationEvent + | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index 5fd218f19..534a5c8b2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -127,7 +127,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin }; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -211,7 +211,7 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 9e876f616..17f644601 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -9,7 +9,7 @@ const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/"; const MSFT_LOGIN_URL = "https://login.microsoftonline.com"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -122,7 +122,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return users; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index c030a6813..b2f1f8c35 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -9,7 +9,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 50ab2c694..e91363629 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -95,7 +95,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 95d7e590f..6cb414d10 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -8,7 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index 5a64e0f7b..84dec4d68 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 9647ec6d8..32264ecfa 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -11,7 +11,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -141,7 +141,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 92ba1d4f9..306b8c59c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -10,7 +10,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index 1b4376f43..3550b146d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -12,7 +12,7 @@ import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; const noop = () => {}; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 835761211..6c9bffd0a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -14,7 +14,7 @@ const generatePassword = (provider: SqlProviders) => { // oracle has limit of 48 password length const size = provider === SqlProviders.Oracle ? 30 : 48; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index c3b774afd..8dd80ce2c 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -20,7 +20,8 @@ import { TUpdateExternalKmsDTO } from "./external-kms-types"; import { AwsKmsProviderFactory } from "./providers/aws-kms"; -import { ExternalKmsAwsSchema, KmsProviders } from "./providers/model"; +import { GcpKmsProviderFactory } from "./providers/gcp-kms"; +import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model"; type TExternalKmsServiceFactoryDep = { externalKmsDAL: TExternalKmsDALFactory; @@ -78,6 +79,13 @@ export const externalKmsServiceFactory = ({ await externalKms.validateConnection(); } break; + case KmsProviders.Gcp: + { + const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs }); + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(provider.inputs); + } + break; default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -88,7 +96,7 @@ export const externalKmsServiceFactory = ({ }); const { cipherTextBlob: encryptedProviderInputs } = orgDataKeyEncryptor({ - plainText: Buffer.from(sanitizedProviderInput, "utf8") + plainText: Buffer.from(sanitizedProviderInput) }); const externalKms = await externalKmsDAL.transaction(async (tx) => { @@ -162,7 +170,7 @@ export const externalKmsServiceFactory = ({ case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput }); @@ -170,6 +178,17 @@ export const externalKmsServiceFactory = ({ sanitizedProviderInput = JSON.stringify(updatedProviderInput); } break; + case KmsProviders.Gcp: + { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; + const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput }); + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } + break; default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -178,7 +197,7 @@ export const externalKmsServiceFactory = ({ let encryptedProviderInputs: Buffer | undefined; if (sanitizedProviderInput) { const { cipherTextBlob } = orgDataKeyEncryptor({ - plainText: Buffer.from(sanitizedProviderInput, "utf8") + plainText: Buffer.from(sanitizedProviderInput) }); encryptedProviderInputs = cipherTextBlob; } @@ -271,10 +290,17 @@ export const externalKmsServiceFactory = ({ switch (externalKmsDoc.provider) { case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + + return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; + } default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -312,21 +338,34 @@ export const externalKmsServiceFactory = ({ switch (externalKmsDoc.provider) { case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + + return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; + } default: throw new BadRequestError({ message: "external kms provided is invalid" }); } }; + const fetchGcpKeys = async ({ credential, gcpRegion }: Pick) => { + const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } }); + return externalKms.getKeysList(); + }; + return { create, updateById, deleteById, list, findById, - findByName + findByName, + fetchGcpKeys }; }; diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts new file mode 100644 index 000000000..b3b61694b --- /dev/null +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -0,0 +1,113 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { ExternalKmsGcpSchema, TExternalKmsGcpClientSchema, TExternalKmsProviderFns } from "./model"; + +const getGcpKmsClient = async ({ credential, gcpRegion }: TExternalKmsGcpClientSchema) => { + const gcpKmsClient = new KeyManagementServiceClient({ + credentials: credential + }); + const projectId = credential.project_id; + const locationName = gcpKmsClient.locationPath(projectId, gcpRegion); + + return { + gcpKmsClient, + locationName + }; +}; + +type GcpKmsProviderArgs = { + inputs: unknown; +}; +type TGcpKmsProviderFactoryReturn = TExternalKmsProviderFns & { + getKeysList: () => Promise<{ keys: string[] }>; +}; + +export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Promise => { + const { credential, gcpRegion, keyName } = await ExternalKmsGcpSchema.parseAsync(inputs); + const { gcpKmsClient, locationName } = await getGcpKmsClient({ + credential, + gcpRegion + }); + + const validateConnection = async () => { + try { + await gcpKmsClient.listKeyRings({ + parent: locationName + }); + return true; + } catch (error) { + throw new BadRequestError({ + message: "Cannot connect to GCP KMS" + }); + } + }; + + // Used when adding the KMS to fetch the list of keys in specified region + const getKeysList = async () => { + try { + const [keyRings] = await gcpKmsClient.listKeyRings({ + parent: locationName + }); + + const validKeyRings = keyRings + .filter( + (keyRing): keyRing is { name: string } => + keyRing !== null && typeof keyRing === "object" && "name" in keyRing && typeof keyRing.name === "string" + ) + .map((keyRing) => keyRing.name); + const keyList: string[] = []; + const keyListPromises = validKeyRings.map((keyRingName) => + gcpKmsClient + .listCryptoKeys({ + parent: keyRingName + }) + .then(([cryptoKeys]) => + cryptoKeys + .filter( + (key): key is { name: string } => + key !== null && typeof key === "object" && "name" in key && typeof key.name === "string" + ) + .map((key) => key.name) + ) + ); + + const cryptoKeyLists = await Promise.all(keyListPromises); + keyList.push(...cryptoKeyLists.flat()); + return { keys: keyList }; + } catch (error) { + logger.error(error, "Could not validate GCP KMS connection and credentials"); + throw new BadRequestError({ + message: "Could not validate GCP KMS connection and credentials", + error + }); + } + }; + + const encrypt = async (data: Buffer) => { + const encryptedText = await gcpKmsClient.encrypt({ + name: keyName, + plaintext: data + }); + if (!encryptedText[0].ciphertext) throw new Error("encryption failed"); + return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) }; + }; + + const decrypt = async (encryptedBlob: Buffer) => { + const decryptedText = await gcpKmsClient.decrypt({ + name: keyName, + ciphertext: encryptedBlob + }); + if (!decryptedText[0].plaintext) throw new Error("decryption failed"); + return { data: Buffer.from(decryptedText[0].plaintext) }; + }; + + return { + validateConnection, + getKeysList, + encrypt, + decrypt + }; +}; diff --git a/backend/src/ee/services/external-kms/providers/model.ts b/backend/src/ee/services/external-kms/providers/model.ts index 5a87e0c98..436b39423 100644 --- a/backend/src/ee/services/external-kms/providers/model.ts +++ b/backend/src/ee/services/external-kms/providers/model.ts @@ -1,13 +1,23 @@ import { z } from "zod"; export enum KmsProviders { - Aws = "aws" + Aws = "aws", + Gcp = "gcp" } export enum KmsAwsCredentialType { AssumeRole = "assume-role", AccessKey = "access-key" } +// Google uses snake_case for their enum values and we need to match that +export enum KmsGcpCredentialType { + ServiceAccount = "service_account" +} + +export enum KmsGcpKeyFetchAuthType { + Credential = "credential", + Kms = "kmsId" +} export const ExternalKmsAwsSchema = z.object({ credential: z @@ -42,14 +52,44 @@ export const ExternalKmsAwsSchema = z.object({ }); export type TExternalKmsAwsSchema = z.infer; +export const ExternalKmsGcpCredentialSchema = z.object({ + type: z.literal(KmsGcpCredentialType.ServiceAccount), + project_id: z.string().min(1), + private_key_id: z.string().min(1), + private_key: z.string().min(1), + client_email: z.string().min(1), + client_id: z.string().min(1), + auth_uri: z.string().min(1), + token_uri: z.string().min(1), + auth_provider_x509_cert_url: z.string().min(1), + client_x509_cert_url: z.string().min(1), + universe_domain: z.string().min(1) +}); + +export type TExternalKmsGcpCredentialSchema = z.infer; + +export const ExternalKmsGcpSchema = z.object({ + credential: ExternalKmsGcpCredentialSchema.describe("GCP Service Account JSON credential to connect"), + gcpRegion: z.string().trim().describe("GCP region where the KMS key is located"), + keyName: z.string().trim().describe("GCP key name") +}); +export type TExternalKmsGcpSchema = z.infer; + +const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({ + credential: ExternalKmsGcpCredentialSchema +}); +export type TExternalKmsGcpClientSchema = z.infer; + // The root schema of the JSON export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }) + z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema }) ]); export type TExternalKmsInputSchema = z.infer; export const ExternalKmsInputUpdateSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }) + z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }), + z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema.partial() }) ]); export type TExternalKmsInputUpdateSchema = z.infer; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f6113..fc38a2a9b 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 7e7139a6b..68c48524b 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 a6eb4782b..9424075ca 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 26a694a4a..b1c40a879 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 4811eb52a..127de1383 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 145ec0d7f..47142054e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -85,6 +85,10 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type IdentityManagementSubjectFields = { + identityId: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionActions, @@ -124,7 +128,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] @@ -219,6 +226,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."), @@ -268,12 +290,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( @@ -401,6 +417,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 ]); @@ -445,6 +467,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 ]); @@ -736,26 +768,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 bb77660aa..6644b14b8 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 cb3452685..4e7bf6d15 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 803b9464c..f842359bc 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 a39f44fd6..e1c75b3f9 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 c4902dc27..ee6eed665 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.", @@ -1043,6 +1045,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.", @@ -1099,11 +1104,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 a9014a72b..66c5f3d98 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -178,7 +178,10 @@ const envSchema = z HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), HSM_KEY_LABEL: zpStr(z.string().optional()), - HSM_SLOT: z.coerce.number().optional().default(0) + HSM_SLOT: z.coerce.number().optional().default(0), + + 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 5563499e5..9676496f7 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 7f62d6b1e..850298f89 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"; @@ -55,13 +56,21 @@ const run = async () => { } const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL); + + 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 457eebcc1..051fe9cbd 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,5 +1,6 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; import Redis from "ioredis"; +import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; @@ -7,6 +8,8 @@ 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, TIntegrationSyncPayload, @@ -184,17 +187,48 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string) => { +export const queueServiceFactory = ( + redisUrl: string, + { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } +) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, Queue >; + + const pgBoss = new PgBoss({ + connectionString: dbConnectionUrl, + archiveCompletedAfterSeconds: 60, + archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried + deleteAfterSeconds: 30, + ssl: dbRootCert + ? { + rejectUnauthorized: true, + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + } + : false + }); + + const queueContainerPg = {} as Record; + const workerContainer = {} as Record< QueueName, Worker >; + const initialize = async () => { + 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"); + }); + } + }; + const start = ( name: T, jobFn: (job: Job, token?: string) => Promise, @@ -215,6 +249,27 @@ export const queueServiceFactory = (redisUrl: string) => { }); }; + const startPg = async ( + jobName: QueueJobs, + jobsFn: (jobs: PgBoss.Job[]) => Promise, + options: WorkOptions & { + workerCount: number; + } + ) => { + if (queueContainerPg[jobName]) { + throw new Error(`${jobName} queue is already initialized`); + } + + await pgBoss.createQueue(jobName); + queueContainerPg[jobName] = true; + + await Promise.all( + Array.from({ length: options.workerCount }).map(() => + pgBoss.work(jobName, options, jobsFn) + ) + ); + }; + const listen = < T extends QueueName, U extends keyof WorkerListener @@ -238,6 +293,18 @@ export const queueServiceFactory = (redisUrl: string) => { await q.add(job, data, opts); }; + const queuePg = async ( + job: TQueueJobTypes[T]["name"], + data: TQueueJobTypes[T]["payload"], + opts?: PgBoss.SendOptions & { jobId?: string } + ) => { + await pgBoss.send({ + name: job, + data, + options: opts + }); + }; + const stopRepeatableJob = async ( name: T, job: TQueueJobTypes[T]["name"], @@ -274,5 +341,17 @@ export const queueServiceFactory = (redisUrl: string) => { await Promise.all(Object.values(workerContainer).map((worker) => worker.close())); }; - return { start, listen, queue, shutdown, stopRepeatableJob, stopRepeatableJobByJobId, clearQueue, stopJobById }; + return { + initialize, + start, + listen, + queue, + shutdown, + stopRepeatableJob, + stopRepeatableJobByJobId, + clearQueue, + stopJobById, + startPg, + queuePg + }; }; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 83c34e5a7..52fc989cf 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 000000000..ed97cb7d0 --- /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 0cbf30f09..ac4803c98 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -27,6 +27,7 @@ enum HttpStatusCodes { NotFound = 404, Unauthorized = 401, Forbidden = 403, + UnprocessableContent = 422, // eslint-disable-next-line @typescript-eslint/no-shadow InternalServerError = 500, GatewayTimeout = 504, @@ -39,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.Unauthorized).send({ - requestId: req.id, - statusCode: HttpStatusCodes.Unauthorized, + void res.status(HttpStatusCodes.UnprocessableContent).send({ + 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}`, @@ -87,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 @@ -127,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 709d47abd..5dccb38d3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -407,13 +407,14 @@ export const registerRoutes = async ( permissionService }); - const auditLogQueue = auditLogQueueServiceFactory({ + const auditLogQueue = await auditLogQueueServiceFactory({ auditLogDAL, queueService, projectDAL, licenseService, auditLogStreamDAL }); + const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const auditLogStreamService = auditLogStreamServiceFactory({ licenseService, @@ -426,7 +427,8 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL, licenseService, - userDAL + userDAL, + secretApprovalRequestDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -1025,7 +1027,10 @@ export const registerRoutes = async ( projectEnvDAL, projectMembershipDAL, projectDAL, - userDAL + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + accessApprovalRequestReviewerDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 3fbbc60e3..69a648d9e 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -30,32 +30,39 @@ 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.any(), + 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(), error: z.string() }), + // Zod errors return a message of varying shapes and sizes, so z.any() is used here + 422: z.object({ + 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 18d13e67f..e3982f3d6 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 032deda7d..67db5de6f 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 575544cc7..5e652283c 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: [] + } + ], + querystring: 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.query.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 40141e2c0..059d24463 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 07f795779..1327faeb1 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 c5ded83e4..705016696 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 7d696999e..ed9837084 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 0601e2d1f..f05aa18f0 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 9b3ef6528..a4570389f 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/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 2ab3621ed..425a7e149 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 { @@ -15,6 +14,7 @@ import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-ce import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; 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"; @@ -30,14 +30,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({ @@ -165,21 +157,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) @@ -247,7 +227,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 @@ -281,7 +261,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 @@ -314,7 +294,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), @@ -357,7 +337,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), @@ -398,7 +378,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 7f9cf920e..a2524a0b9 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 be1a8d53c..42a3f038b 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 80e8d6c36..3ffa6959a 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 1db10405d..a990b1ca6 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 a27c4f6ac..f662affd8 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/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 007d33e61..c41783860 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -4,8 +4,10 @@ import { z } from "zod"; import { KmsKeysSchema, TKmsRootConfig } from "@app/db/schemas"; import { AwsKmsProviderFactory } from "@app/ee/services/external-kms/providers/aws-kms"; +import { GcpKmsProviderFactory } from "@app/ee/services/external-kms/providers/gcp-kms"; import { ExternalKmsAwsSchema, + ExternalKmsGcpSchema, KmsProviders, TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; @@ -291,6 +293,16 @@ export const kmsServiceFactory = ({ }); break; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString("utf8")) + ); + + externalKms = await GcpKmsProviderFactory({ + inputs: decryptedProviderInput + }); + break; + } default: throw new Error("Invalid KMS provider."); } @@ -353,6 +365,16 @@ export const kmsServiceFactory = ({ }); break; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString("utf8")) + ); + + externalKms = await GcpKmsProviderFactory({ + inputs: decryptedProviderInput + }); + break; + } default: throw new Error("Invalid KMS provider."); } diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index c2ba53430..e7742e4f7 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -12,7 +12,6 @@ import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-cer import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -382,20 +381,6 @@ export const projectServiceFactory = ({ }); } - // Get the role permission for the identity - const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( - OrgMembershipRole.Member, - organization.id - ); - - // Identity has to be at least a member in order to create projects - const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPrivilege) - throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" - }); - const isCustomRole = Boolean(customRole); - const identityProjectMembership = await identityProjectDAL.create( { identityId: actorId, @@ -407,8 +392,7 @@ export const projectServiceFactory = ({ await identityProjectMembershipRoleDAL.create( { projectMembershipId: identityProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, - customRoleId: customRole?.id + role: ProjectMembershipRole.Admin }, tx ); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 3d75fa4f8..84a8584ee 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -932,8 +932,12 @@ export const secretQueueFactory = ({ ); const message = - (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || - "Unknown error occurred."; + // eslint-disable-next-line no-nested-ternary + (err instanceof AxiosError + ? err?.response?.data + ? JSON.stringify(err?.response?.data) + : err?.message + : (err as Error)?.message) || "Unknown error occurred."; await auditLogService.createAuditLog({ projectId, diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index 3ee16ee37..c3ac9556d 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -8,9 +8,9 @@

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.

- \ 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 39a9b74ba..b82b8b2c2 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -6,10 +6,10 @@

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.

- \ No newline at end of file + diff --git a/docs/documentation/platform/kms-configuration/aws-kms.mdx b/docs/documentation/platform/kms-configuration/aws-kms.mdx index f9fa54b4d..598a3a32b 100644 --- a/docs/documentation/platform/kms-configuration/aws-kms.mdx +++ b/docs/documentation/platform/kms-configuration/aws-kms.mdx @@ -74,22 +74,22 @@ Next, you will need to follow the steps listed below to add AWS KMS for your org - ![Open encryption org settings](../../../images/platform/kms/aws/encryption-org-settings.png) + ![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png) - ![Add encryption org settings](../../../images/platform/kms/aws/encryption-org-settings-add.png) + ![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png) Click the 'Add' button to begin adding a new external KMS. - ![Select Encryption Provider](../../../images/platform/kms/aws/encryption-modal-provider-select.png) + ![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png) Choose 'AWS KMS' from the list of encryption providers. - Selecting AWS as the provider will require you input the following fields. + Selecting AWS as the provider will require you input the following fields. - - Name for referencing the AWS KMS key within the organization. - + + Name for referencing the AWS KMS key within the organization. + Short description of the AWS KMS key. diff --git a/docs/documentation/platform/kms-configuration/gcp-kms.mdx b/docs/documentation/platform/kms-configuration/gcp-kms.mdx new file mode 100644 index 000000000..5682814fe --- /dev/null +++ b/docs/documentation/platform/kms-configuration/gcp-kms.mdx @@ -0,0 +1,132 @@ +--- +title: "GCP Key Management Service" +description: "Learn how to manage encryption using GCP KMS" +--- + +To enhance the security of your Infisical projects, you can now encrypt your secrets using an external Key Management Service (KMS). +When external KMS is configured for your project, all encryption and decryption operations will be handled by the chosen KMS. +This guide will walk you through the steps needed to configure external KMS support with Google Cloud KMS. + +## Prerequisites + +Before you begin, you'll first need to set up a GCP Service Account, add a KMS key and set the required permissions. + + + + 1. Navigate to the [Create Service Account](https://console.cloud.google.com/iam-admin/serviceaccounts/create) page in your GCP Console. + ![GCP Service Account Creation](/images/platform/kms/gcp/service-account-form.png) + + 2. Give the service account a suitable **name** and **description**. Then click **Create and Continue**. + 3. Under **Grant this service account access to project**, click **Select a role** and select the + **Cloud KMS Viewer** and **Cloud KMS CryptoKey Encrypter/Decrypter*** roles, then click **Continue**. + ![GCP Service Account Permissions](/images/platform/kms/gcp/service-account-permissions.png) + 3. You can skip the **Grant users access to this service account** options. + 4. Click Done. + 5. You should see the service account in the list of service accounts. Click it to view the service account details. + 6. Select the **Keys** tab, click **Add Key**, select **Create new key**, select **JSON** as the key type, then click **Create**. + 7. You will be prompted to download a JSON file that we will need later on. + + Remember to keep the JSON file in a secure location. It will be used to authenticate your GCP service account. + + Once you have successfully set up GCP KMS with Infisical, you should permanently delete the JSON file. + + + + + 1. Navigate to the [KMS](https://console.cloud.google.com/security/kms) page in your GCP Console. + + If you have not used GCP KMS before, you will be redirected to the **Cloud Key Management Service (KMS) API** page. + + Click **Enable** to enable the KMS API, then continue the steps below. + + It may take a few minutes for the API to be enabled and KMS section of the Cloud Console to become viewable. + + + 2. In the KMS section, click **Create Key Ring**. + ![GCP Create Key Ring](/images/platform/kms/gcp/keyring-create.png) + + 3. Give the key ring a **Name** and select a **Region**, then click **Create**. + + We don't currently support multi-region key rings. + + + 4. On the "Create Key" page, give the key a **Name** and set the **Protection Level** based on your requirements (or use default *Software*), then click **Continue**. + + 5. Under **Key Material**, select **Generated Key**, then click **Continue**. + + 6. Under **Purpose**, select **Symmetric encrypt/decrypt**, then click **Continue**. + + 7. For **Key Rotation Period**, select **Never (manual rotation)**, then click **Continue** followed by **Create**. + + 8. You should see the key in the list of keys. We're now ready to set it up in Infisical. + + + + +## Setup GCP KMS in the Organization Settings + +Next, you will need to follow the steps listed below to add GCP KMS for your organization. + + + + ![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png) + + + ![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png) + Click the 'Add' button to begin adding a new external KMS. + + + ![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png) + Choose 'GCP KMS' from the list of encryption providers. + + + + ![GCP Create KMS Modal](/images/platform/kms/gcp/gcp-add-modal-filled.png) + Selecting GCP as the provider will require you input the following fields. + + + Name for referencing the GCP KMS key within the organization. + + + + Short description of the GCP KMS key. + + + + The GCP region where the GCP KMS key ring is located. + + + + Upload the JSON file you downloaded earlier when creating the GCP service account. + + + + This field will be populated with the list of GCP KMS keys in the selected region. Select the key you created earlier. + + + + + Save your configuration to apply the settings. + + + +You now have a GCP KMS Key configured at the organization level. You can assign these GCP KMS keys to existing Infisical projects by visiting the 'Project Settings' page. + +## Assign GCP KMS Key to an Existing Project + +To assign the GCP KMS key you added to your organization, follow the steps below. + + + + ![Open encryption project + settings](../../../images/platform/kms/gcp/project-settings.png) + + + ![Select encryption project + settings](../../../images/platform/kms/gcp/select-gcp-kms-in-project.png) + Choose the GCP KMS key you configured earlier. + + + Once you have selected the KMS of choice, click save. + + diff --git a/docs/documentation/platform/kms-configuration/overview.mdx b/docs/documentation/platform/kms-configuration/overview.mdx index 327481bc4..159d71dd3 100644 --- a/docs/documentation/platform/kms-configuration/overview.mdx +++ b/docs/documentation/platform/kms-configuration/overview.mdx @@ -25,4 +25,4 @@ For existing projects, you can configure the KMS from the Project Settings page. ## External KMS -Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption. \ No newline at end of file +Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) or [GCP Key Management Service](./gcp-kms) for managing encryption. diff --git a/docs/images/platform/kms/aws/encryption-modal-provider-select.png b/docs/images/platform/kms/aws/encryption-modal-provider-select.png deleted file mode 100644 index 704043a74..000000000 Binary files a/docs/images/platform/kms/aws/encryption-modal-provider-select.png and /dev/null differ diff --git a/docs/images/platform/kms/encryption-modal-provider-select.png b/docs/images/platform/kms/encryption-modal-provider-select.png new file mode 100644 index 000000000..5bc696021 Binary files /dev/null and b/docs/images/platform/kms/encryption-modal-provider-select.png differ diff --git a/docs/images/platform/kms/aws/encryption-org-settings-add.png b/docs/images/platform/kms/encryption-org-settings-add.png similarity index 100% rename from docs/images/platform/kms/aws/encryption-org-settings-add.png rename to docs/images/platform/kms/encryption-org-settings-add.png diff --git a/docs/images/platform/kms/aws/encryption-org-settings.png b/docs/images/platform/kms/encryption-org-settings.png similarity index 100% rename from docs/images/platform/kms/aws/encryption-org-settings.png rename to docs/images/platform/kms/encryption-org-settings.png diff --git a/docs/images/platform/kms/gcp/gcp-add-modal-filled.png b/docs/images/platform/kms/gcp/gcp-add-modal-filled.png new file mode 100644 index 000000000..c6d4b0725 Binary files /dev/null and b/docs/images/platform/kms/gcp/gcp-add-modal-filled.png differ diff --git a/docs/images/platform/kms/gcp/keyring-create.png b/docs/images/platform/kms/gcp/keyring-create.png new file mode 100644 index 000000000..c03097cc1 Binary files /dev/null and b/docs/images/platform/kms/gcp/keyring-create.png differ diff --git a/docs/images/platform/kms/gcp/project-settings.png b/docs/images/platform/kms/gcp/project-settings.png new file mode 100644 index 000000000..915115204 Binary files /dev/null and b/docs/images/platform/kms/gcp/project-settings.png differ diff --git a/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png b/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png new file mode 100644 index 000000000..18f24d304 Binary files /dev/null and b/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png differ diff --git a/docs/images/platform/kms/gcp/service-account-form.png b/docs/images/platform/kms/gcp/service-account-form.png new file mode 100644 index 000000000..eea0dc324 Binary files /dev/null and b/docs/images/platform/kms/gcp/service-account-form.png differ diff --git a/docs/images/platform/kms/gcp/service-account-permissions.png b/docs/images/platform/kms/gcp/service-account-permissions.png new file mode 100644 index 000000000..d528199dc Binary files /dev/null and b/docs/images/platform/kms/gcp/service-account-permissions.png differ diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 8ea24d65f..a925f6c4b 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -162,6 +162,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 ``` @@ -674,6 +678,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/docs/mint.json b/docs/mint.json index 7df2e1062..9c28137fa 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,7 +32,10 @@ "thumbsRating": true }, "api": { - "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + "baseUrl": [ + "https://app.infisical.com", + "http://localhost:8080" + ] }, "topbarLinks": [ { @@ -73,7 +76,9 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": ["documentation/guides/local-development"] + "pages": [ + "documentation/guides/local-development" + ] }, { "group": "Guides", @@ -127,7 +132,8 @@ "pages": [ "documentation/platform/kms-configuration/overview", "documentation/platform/kms-configuration/aws-kms", - "documentation/platform/kms-configuration/aws-hsm" + "documentation/platform/kms-configuration/aws-hsm", + "documentation/platform/kms-configuration/gcp-kms" ] }, { @@ -461,20 +467,24 @@ }, { "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] + "pages": [ + "integrations/build-tools/gradle" + ] }, { "group": "", - "pages": ["sdks/overview"] + "pages": [ + "sdks/overview" + ] }, { "group": "SDK's", "pages": [ "sdks/languages/node", "sdks/languages/python", + "sdks/languages/java", "sdks/languages/go", "sdks/languages/ruby", - "sdks/languages/java", "sdks/languages/csharp" ] }, @@ -485,7 +495,9 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": ["api-reference/overview/examples/integration"] + "pages": [ + "api-reference/overview/examples/integration" + ] } ] }, @@ -760,11 +772,15 @@ }, { "group": "Service Tokens", - "pages": ["api-reference/endpoints/service-tokens/get"] + "pages": [ + "api-reference/endpoints/service-tokens/get" + ] }, { "group": "Audit Logs", - "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + "pages": [ + "api-reference/endpoints/audit-logs/export-audit-log" + ] } ] }, @@ -863,7 +879,9 @@ }, { "group": "", - "pages": ["changelog/overview"] + "pages": [ + "changelog/overview" + ] }, { "group": "Contributing", @@ -887,7 +905,9 @@ }, { "group": "Contributing to SDK", - "pages": ["contributing/sdk/developing"] + "pages": [ + "contributing/sdk/developing" + ] } ] } @@ -911,13 +931,22 @@ { "title": "PRODUCT", "links": [ - { "label": "Secret Management", "url": "https://infisical.com/" }, - { "label": "Secret Scanning", "url": "https://infisical.com/radar" }, + { + "label": "Secret Management", + "url": "https://infisical.com/" + }, + { + "label": "Secret Scanning", + "url": "https://infisical.com/radar" + }, { "label": "Share Secrets", "url": "https://app.infisical.com/share-secret" }, - { "label": "Pricing", "url": "https://infisical.com/pricing" }, + { + "label": "Pricing", + "url": "https://infisical.com/pricing" + }, { "label": "Security", "url": "https://infisical.com/docs/internals/security" @@ -1061,4 +1090,4 @@ } ] } -} +} \ No newline at end of file diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 3a712322e..dc07b146d 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -1,9 +1,12 @@ --- title: "Infisical Java SDK" sidebarTitle: "Java" +url: "https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-nodejs-sdk" icon: "java" --- +{ +/* If you're working with Java, the official [Infisical Java SDK](https://github.com/Infisical/sdk/tree/main/languages/java) package is the easiest way to fetch and work with secrets for your application. - [Maven Package](https://github.com/Infisical/sdk/packages/2019741) @@ -568,4 +571,5 @@ String decryptedString = client.decryptSymmetric(decryptOptions); #### Returns (string) -`Plaintext` (string): The decrypted plaintext. \ No newline at end of file +`Plaintext` (string): The decrypted plaintext. +*/} \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 11d34bb38..a58be0688 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -16,7 +16,7 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Python application on demand - + Manage secrets for your Java application on demand diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 91b8d4bc3..64380ae9f 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 9490ffc6d..7492fcb54 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/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 23b4eebaa..fdc35b9af 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -1,18 +1,60 @@ import { ReactNode } from "react"; import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify"; +import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { twMerge } from "tailwind-merge"; + +import { CopyButton } from "../v2/CopyButton"; export type TNotification = { title?: string; text: ReactNode; children?: ReactNode; + callToAction?: ReactNode; + copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[]; }; -export const NotificationContent = ({ title, text, children }: TNotification) => { +export const NotificationContent = ({ + title, + text, + children, + callToAction, + copyActions +}: TNotification) => { return (
{title &&
{title}
}
{text}
{children &&
{children}
} + {(callToAction || copyActions) && ( +
+ {callToAction} + + {copyActions && ( +
+ {copyActions.map((action) => ( +
+ {action.label && ( + {action.label} + )} + +
+ ))} +
+ )} +
+ )}
); }; diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index a2fde465d..23389cbca 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/CopyButton/CopyButton.tsx b/frontend/src/components/v2/CopyButton/CopyButton.tsx new file mode 100644 index 000000000..ff1161ca9 --- /dev/null +++ b/frontend/src/components/v2/CopyButton/CopyButton.tsx @@ -0,0 +1,55 @@ +import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { useTimedReset } from "@app/hooks"; + +import { IconButton } from "../IconButton"; +import { Tooltip } from "../Tooltip"; + +export type CopyButtonProps = { + value: string; + size?: "xs" | "sm" | "md" | "lg"; + variant?: "solid" | "outline" | "plain" | "star" | "outline_bg"; + color?: string; + name?: string; + icon?: IconDefinition; +}; + +export const CopyButton = ({ + value, + size = "sm", + variant = "solid", + color, + name, + icon = faCopy +}: CopyButtonProps) => { + const [copyText, isCopying, setCopyText] = useTimedReset({ + initialState: name ? `Copy ${name}` : "Copy to clipboard" + }); + + async function handleCopyText() { + setCopyText("Copied"); + navigator.clipboard.writeText(value); + } + + return ( +
+ + { + handleCopyText(); + }} + > + + + +
+ ); +}; + +CopyButton.displayName = "CopyButton"; diff --git a/frontend/src/components/v2/CopyButton/index.tsx b/frontend/src/components/v2/CopyButton/index.tsx new file mode 100644 index 000000000..9a7bc5991 --- /dev/null +++ b/frontend/src/components/v2/CopyButton/index.tsx @@ -0,0 +1,2 @@ +export type { CopyButtonProps } from "./CopyButton"; +export { CopyButton } from "./CopyButton"; diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index ad51f565d..dd8ba330d 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -40,32 +40,39 @@ export const FilterableSelect = ({ ...props.components }} classNames={{ - container: () => "w-full font-inter", - control: ({ isFocused }) => + container: ({ isDisabled }) => + twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"), + control: ({ isFocused, isDisabled }) => twMerge( - isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", - "border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer" + isFocused ? "border-primary-400/50" : "border-mineshaft-600 ", + `border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${ + isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer" + } ` ), - placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5", - input: () => "pl-1 py-0.5", + placeholder: () => + `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, + input: () => "pl-1", valueContainer: () => - `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, + `px-1 max-h-[8.2rem] ${ + isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]" + } gap-1`, singleValue: () => "leading-7 ml-1", - multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5", + multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5", multiValueLabel: () => "leading-6 text-sm", multiValueRemove: () => "hover:text-red text-bunker-400", indicatorsContainer: () => "p-1 gap-1", clearIndicator: () => "p-1 hover:text-red text-bunker-400", indicatorSeparator: () => "bg-bunker-400", dropdownIndicator: () => "text-bunker-200 p-1", + menuList: () => "flex flex-col gap-1", menu: () => - "mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", + "my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm", option: ({ isFocused, isSelected }) => twMerge( isFocused && "bg-mineshaft-700 active:bg-mineshaft-600", isSelected && "text-mineshaft-200", - "hover:cursor-pointer mb-1 rounded text-xs px-3 py-2" + "hover:cursor-pointer rounded text-xs px-3 py-2" ), noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" }} diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 51eed6396..2d0e8b1a9 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -54,7 +54,7 @@ export const Pagination = ({ )} > {startAdornment} -
+
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/components/v2/Select/components/index.tsx b/frontend/src/components/v2/Select/components/index.tsx index 520f61a33..8d0e57d87 100644 --- a/frontend/src/components/v2/Select/components/index.tsx +++ b/frontend/src/components/v2/Select/components/index.tsx @@ -36,10 +36,12 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => { export const Option = ({ isSelected, children, ...props }: OptionProps) => { return ( - {children} - {isSelected && ( - - )} +
+

{children}

+ {isSelected && ( + + )} +
); }; diff --git a/frontend/src/components/v2/Tooltip/Tooltip.tsx b/frontend/src/components/v2/Tooltip/Tooltip.tsx index e02b9fc38..abfebc5b4 100644 --- a/frontend/src/components/v2/Tooltip/Tooltip.tsx +++ b/frontend/src/components/v2/Tooltip/Tooltip.tsx @@ -13,6 +13,7 @@ export type TooltipProps = Omit // just render children if tooltip content is empty @@ -43,7 +45,7 @@ export const Tooltip = ({ sideOffset={5} {...props} className={twMerge( - `z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md + `z-50 max-w-[15rem] select-none border border-mineshaft-600 bg-mineshaft-800 font-light text-bunker-200 shadow-md data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade @@ -51,6 +53,8 @@ export const Tooltip = ({ `, isDisabled && "!hidden", center && "text-center", + size === "sm" && "rounded-sm py-1 px-2 text-xs", + size === "md" && "rounded-md py-2 px-4 text-sm", className )} > diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx index 8f2cf79e8..1662b07ff 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 e3fe9732b..b0c3aa463 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", @@ -154,7 +158,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/members.ts b/frontend/src/helpers/members.ts new file mode 100644 index 000000000..871ef3ec6 --- /dev/null +++ b/frontend/src/helpers/members.ts @@ -0,0 +1,12 @@ +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +export const getMemberLabel = (member: TWorkspaceUser) => { + const { + inviteEmail, + user: { firstName, lastName, username, email } + } = member; + + return firstName || lastName + ? `${firstName ?? ""} ${lastName ?? ""}`.trim() + : username || email || inviteEmail; +}; diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index de6291a13..4e26e1b15 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -1,4 +1,4 @@ -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types"; enum OrgMembershipRole { Admin = "admin", @@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => { export const isCustomProjectRole = (slug: string) => !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); + +export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) => + isCustomOrgRole(roleIdOrSlug) + ? roles.find((r) => r.id === roleIdOrSlug) + : roles.find((r) => r.slug === roleIdOrSlug); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 6df257590..bd6173d91 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 404592908..a75767108 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 1db55d739..0b0c44d7b 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/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index adff8bb0e..3aa599716 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -177,11 +177,21 @@ export const useGetProjectSecretsOverview = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secret details", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -270,11 +280,21 @@ export const useGetProjectSecretsDetails = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secret details", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -355,11 +375,21 @@ export const useGetProjectSecretsQuickSearch = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secrets deep search", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4..c23a55832 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 445ae10bc..2f5c5984c 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 b239b0a61..dc3791db7 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 3f69b9a0e..6bc82b39e 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/index.tsx b/frontend/src/hooks/api/kms/index.tsx index 84b238a3b..906368bd3 100644 --- a/frontend/src/hooks/api/kms/index.tsx +++ b/frontend/src/hooks/api/kms/index.tsx @@ -1,5 +1,6 @@ export { useAddExternalKms, + useExternalKmsFetchGcpKeys, useLoadProjectKmsBackup, useRemoveExternalKms, useUpdateExternalKms, diff --git a/frontend/src/hooks/api/kms/mutations.tsx b/frontend/src/hooks/api/kms/mutations.tsx index f0c623ceb..b87c495c9 100644 --- a/frontend/src/hooks/api/kms/mutations.tsx +++ b/frontend/src/hooks/api/kms/mutations.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { kmsKeys } from "./queries"; -import { AddExternalKmsType, KmsType } from "./types"; +import { + AddExternalKmsType, + ExternalKmsGcpSchemaType, + KmsGcpKeyFetchAuthType, + KmsType, + UpdateExternalKmsType +} from "./types"; export const useAddExternalKms = (orgId: string) => { const queryClient = useQueryClient(); @@ -33,7 +39,7 @@ export const useUpdateExternalKms = (orgId: string) => { provider }: { kmsId: string; - } & AddExternalKmsType) => { + } & UpdateExternalKmsType) => { const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, { name, description, @@ -96,3 +102,44 @@ export const useLoadProjectKmsBackup = (projectId: string) => { } }); }; + +export const useExternalKmsFetchGcpKeys = (orgId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + gcpRegion, + ...rest + }: Pick & + ( + | (Pick & { + [KmsGcpKeyFetchAuthType.Kms]?: never; + }) + | { + [KmsGcpKeyFetchAuthType.Kms]: string; + [KmsGcpKeyFetchAuthType.Credential]?: never; + } + )): Promise<{ keys: string[] }> => { + const { + [KmsGcpKeyFetchAuthType.Credential]: credential, + [KmsGcpKeyFetchAuthType.Kms]: kmsId + } = rest; + + if ((credential && kmsId) || (!credential && !kmsId)) { + throw new Error( + `Either '${KmsGcpKeyFetchAuthType.Credential}' or '${KmsGcpKeyFetchAuthType.Kms}' must be provided, but not both.` + ); + } + + const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", { + authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms, + region: gcpRegion, + ...rest + }); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(kmsKeys.getExternalKmsList(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 513b59e2a..73b821b1a 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; @@ -35,7 +37,8 @@ export enum KmsType { } export enum ExternalKmsProvider { - AWS = "aws" + Aws = "aws", + Gcp = "gcp" } export const INTERNAL_KMS_KEY_ID = "internal"; @@ -44,6 +47,10 @@ export enum KmsAwsCredentialType { AssumeRole = "assume-role", AccessKey = "access-key" } +// Google uses snake_case for their enum values and we need to match that +export enum KmsGcpCredentialType { + ServiceAccount = "service_account" +} export const ExternalKmsAwsSchema = z.object({ credential: z @@ -83,11 +90,54 @@ export const ExternalKmsAwsSchema = z.object({ ) }); +export const ExternalKmsGcpCredentialSchema = z.object({ + type: z.literal(KmsGcpCredentialType.ServiceAccount), + project_id: z.string().min(1), + private_key_id: z.string().min(1), + private_key: z.string().min(1), + client_email: z.string().min(1), + client_id: z.string().min(1), + auth_uri: z.string().min(1), + token_uri: z.string().min(1), + auth_provider_x509_cert_url: z.string().min(1), + client_x509_cert_url: z.string().min(1), + universe_domain: z.string().min(1) +}); + +export type ExternalKmsGcpCredentialSchemaType = z.infer; + +export const ExternalKmsGcpSchema = z.object({ + credential: ExternalKmsGcpCredentialSchema.describe( + "GCP Service Account JSON credential to connect" + ), + gcpRegion: z.string().min(1).trim().describe("GCP region where the KMS key is located"), + keyName: z.string().min(1).trim().describe("GCP key name") +}); +export type ExternalKmsGcpSchemaType = z.infer; + export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(ExternalKmsProvider.AWS), inputs: ExternalKmsAwsSchema }) + z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema }) ]); export const AddExternalKmsSchema = z.object({ + name: slugSchema({ min: 1, field: "Alias" }), + description: z.string().trim().optional(), + provider: ExternalKmsInputSchema +}); + +export type AddExternalKmsType = z.infer; + +// we need separate schema for update because the credential field is not required on GCP +export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ + type: z.literal(ExternalKmsProvider.Gcp), + inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true }) + }) +]); + +export const UpdateExternalKmsSchema = z.object({ name: z .string() .trim() @@ -96,7 +146,52 @@ export const AddExternalKmsSchema = z.object({ message: "Alias must be a valid slug" }), description: z.string().trim().optional(), - provider: ExternalKmsInputSchema + provider: ExternalKmsUpdateInputSchema }); -export type AddExternalKmsType = z.infer; +export type UpdateExternalKmsType = z.infer; + +const GCP_CREDENTIAL_MAX_FILE_SIZE = 8 * 1024; // 8KB +const GCP_CREDENTIAL_ACCEPTED_FILE_TYPES = ["application/json"]; + +const AddExternalKmsGcpFormSchemaStandardInputs = z.object({ + keyObject: z + .object({ label: z.string().trim(), value: z.string().trim() }) + .describe("GCP key name"), + gcpRegion: z.object({ label: z.string().trim(), value: z.string().trim() }).describe("GCP Region") +}); + +export const AddExternalKmsGcpFormSchema = z.discriminatedUnion("formType", [ + z + .object({ + formType: z.literal("newGcpKms"), + // `FileList` is a browser-only (window-specific) type, so we need to handle it differently on the server to avoid SSR errors + credentialFile: + typeof window === "undefined" + ? z.any() + : z + .instanceof(FileList) + .refine((files) => files?.length === 1, "Image is required.") + .refine( + (files) => files?.[0]?.size <= GCP_CREDENTIAL_MAX_FILE_SIZE, + "Max file size is 8KB." + ) + .refine( + (files) => GCP_CREDENTIAL_ACCEPTED_FILE_TYPES.includes(files?.[0]?.type), + "Only .json files are accepted." + ) + }) + .merge(AddExternalKmsGcpFormSchemaStandardInputs) + .merge(AddExternalKmsSchema.pick({ name: true, description: true })), + z + .object({ formType: z.literal("updateGcpKms") }) + .merge(AddExternalKmsGcpFormSchemaStandardInputs) + .merge(AddExternalKmsSchema.pick({ name: true, description: true })) +]); + +export type AddExternalKmsGcpFormSchemaType = z.infer; + +export enum KmsGcpKeyFetchAuthType { + Credential = "credential", + Kms = "kmsId" +} diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index b3b3a4164..a803ff50b 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -117,11 +117,21 @@ export const useGetProjectSecrets = ({ queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secrets", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -148,15 +158,24 @@ export const useGetProjectSecretsAllEnv = ({ enabled: Boolean(workspaceId && environment), onError: (error: unknown) => { if (axios.isAxiosError(error) && !isErrorHandled) { - const serverResponse = error.response?.data as { message: string }; - if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) { + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; + if (message !== ERROR_NOT_ALLOWED_READ_SECRETS) { createNotification({ title: "Error fetching secrets", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } - setIsErrorHandled.on(); } }, diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 909e03a98..c03358b42 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: 401; + 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/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx index 2768abfed..777d65b71 100644 --- a/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { components, MenuProps, OptionProps } from "react-select"; import { faStar } from "@fortawesome/free-regular-svg-icons"; -import { faEye, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons"; +import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -93,7 +93,7 @@ const ProjectOption = ({ >
{isSelected && ( - + )}

{children}

{data.isFavorite ? ( diff --git a/frontend/src/lib/schemas/slugSchema.ts b/frontend/src/lib/schemas/slugSchema.ts index df74b99e3..ed97cb7d0 100644 --- a/frontend/src/lib/schemas/slugSchema.ts +++ b/frontend/src/lib/schemas/slugSchema.ts @@ -1,12 +1,23 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -export const slugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => 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 ad82a218c..cea246f2f 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 5fcb4f0dd..4a512ff60 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 380aad08e..d6c80c2c1 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 a9d2766a4..1f9f17afa 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 000000000..e193d9bd5 --- /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/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 45fc7f3d2..9e39fd389 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -876,7 +876,7 @@ const OrganizationPage = () => { -

Please check the input and try again.

-

Request ID: {serverResponse.requestId}

-
- ), - children: ( + text: "Please check the input and try again.", + callToAction: ( + )} +
+
+

{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 3b1df9bdd..a4e6bb586 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 2be3d1f3e..000000000 --- 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 2fbcca15d..2708226f6 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 000000000..79c0e26f3 --- /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 000000000..ea2ecd888 --- /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 000000000..d9567592c --- /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 000000000..acde15760 --- /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 e7f38318a..ab81aa445 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 000000000..39187f3cb --- /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 000000000..624cc7241 --- /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 000000000..08de6c724 --- /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 000000000..2423fd6d5 --- /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 000000000..943a6574e --- /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 000000000..70c696609 --- /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 000000000..003c47910 --- /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 000000000..3dec23a1c --- /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/OrgGroupModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx index 4ea4516de..b09c88763 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx @@ -6,14 +6,14 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, + FilterableSelect, FormControl, Input, Modal, - ModalContent, - Select, - SelectItem + 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"; @@ -23,7 +23,7 @@ const GroupFormSchema = z.object({ .string() .min(5, "Slug must be at least 5 characters long") .max(36, "Slug must be 36 characters or fewer"), - role: z.string() + role: z.object({ name: z.string(), slug: z.string() }) }); export type TGroupFormData = z.infer; @@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr reset({ name: group.name, slug: group.slug, - role: group?.customRole?.slug ?? group.role + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) }); } else { reset({ name: "", slug: "", - role: roles[0].slug + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) }); } }, [popUp?.group?.data, roles]); @@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr id: group.groupId, name, slug, - role: role || undefined + role: role.slug || undefined }); } else { await createMutateAsync({ name, slug, organizationId: currentOrg.id, - role: role || undefined + role: role.slug || undefined }); } handlePopUpToggle("group", false); @@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr reset(); }} > - +
( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> 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 f72adf61f..9c3949150 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 + + )} + { if (identity) { reset({ name: identity.name, - role: identity?.customRole?.slug ?? identity.role, + role: identity.customRole ?? findOrgMembershipRole(roles, identity.role), metadata: identity.metadata }); } else { reset({ name: "", - role: roles[0].slug + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) }); } }, [popUp?.identity?.data, roles]); @@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { await updateMutateAsync({ identityId: identity.identityId, name, - role: role || undefined, + role: role.slug || undefined, organizationId: orgId, metadata }); @@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, - role: role || undefined, + role: role.slug || undefined, organizationId: orgId, metadata }); @@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset(); }} > - + { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 74aa5d7c2..38faf53f1 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -15,7 +15,7 @@ import { TextArea } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { isCustomOrgRole } from "@app/helpers/roles"; +import { findOrgMembershipRole } from "@app/helpers/roles"; import { useAddUsersToOrg, useFetchServerStatus, @@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({ ) .default([]), projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), - organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG) + organizationRole: z.object({ name: z.string(), slug: z.string() }) }); type TAddMemberForm = z.infer; @@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({ useEffect(() => { if (organizationRoles) { reset({ - organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!) - ? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug! - : currentOrg?.defaultMembershipRole + organizationRole: findOrgMembershipRole( + organizationRoles, + currentOrg?.defaultMembershipRole! + ) }); } }, [organizationRoles]); const onAddMembers = async ({ emails, - organizationRoleSlug, + organizationRole, projects: selectedProjects, projectRoleSlug }: TAddMemberForm) => { @@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({ const { data } = await addUsersMutateAsync({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), - organizationRoleSlug, + organizationRoleSlug: organizationRole.slug, projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] })) }); @@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({ ( + name="organizationRole" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
- -
+ option.slug} + getOptionLabel={(option) => option.name} + value={value} + onChange={onChange} + />
)} /> diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index cc5217693..fece4e40d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -296,7 +296,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro status, isActive }) => { - const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-"; + const name = + u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-"; const email = u?.email || inviteEmail; const username = u?.username ?? inviteEmail ?? "-"; return ( diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index e41e99e56..567349dab 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -9,12 +9,13 @@ import { Button, FormControl, Input, Modal, ModalContent } from "@app/components import { useOrganization } from "@app/context"; import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } 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/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index ad0f66d6e..81b5ff058 100644 --- a/frontend/src/views/Org/UserPage/UserPage.tsx +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -123,7 +123,7 @@ export const UserPage = withPermission(

{membership.user.firstName || membership.user.lastName - ? `${membership.user.firstName} ${membership.user.lastName}` + ? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim() : "-"}

{userId !== membership.user.id && ( @@ -148,7 +148,8 @@ export const UserPage = withPermission( onClick={() => handlePopUpOpen("orgMembership", { membershipId: membership.id, - role: membership.role + role: membership.role, + roleId: membership.roleId }) } disabled={!isAllowed} diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx index d439c7ecd..863a2745f 100644 --- a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => handlePopUpOpen("orgMembership", { membershipId: membership.id, role: membership.role, + roleId: membership.roleId, metadata: membership.metadata }); }} @@ -117,7 +118,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>

Name

{membership.user.firstName || membership.user.lastName - ? `${membership.user.firstName} ${membership.user.lastName}` + ? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim() : "-"}

diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index 57c8cebb2..289553ba8 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { SingleValue } from "react-select"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -8,21 +9,21 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, + FilterableSelect, FormControl, FormLabel, IconButton, Input, Modal, - ModalContent, - Select, - SelectItem + ModalContent } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; +import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - role: z.string(), + role: z.object({ name: z.string(), slug: z.string() }), metadata: z .object({ key: z.string().trim().min(1), @@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; - const { data: roles } = useGetOrgRoles(orgId); + const { data: roles = [] } = useGetOrgRoles(orgId); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); @@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg const popUpData = popUp?.orgMembership?.data as { membershipId: string; role: string; + roleId?: string; metadata: { key: string; value: string }[]; }; @@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg if (popUpData) { reset({ - role: popUpData.role, + role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role), metadata: popUpData.metadata }); } else { reset({ - role: roles[0].slug + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!) }); } }, [popUp?.orgMembership?.data, roles]); @@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg await updateOrgMembership({ organizationId: orgId, membershipId: popUpData.membershipId, - role, + role: role.slug, metadata }); @@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg reset(); }} > - + ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + value={value} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 15299da26..af136d7ff 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -1,6 +1,27 @@ -import { faFolder } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faMagnifyingGlass, + faSearch, + faUser +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrgUser } from "@app/hooks/api/types"; import { useListUserGroupMemberships } from "@app/hooks/api/users/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -12,31 +33,106 @@ type Props = { handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; }; +enum UserGroupsOrderBy { + Name = "name" +} + export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { - const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username); + const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships( + orgMembership.user.username + ); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 }); + + const filteredGroupMemberships = useMemo( + () => + groupMemberships + .filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase())) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase()); + }), + [groupMemberships, orderDirection, search] + ); + + useResetPageHelper({ + totalCount: filteredGroupMemberships.length, + offset, + setPage + }); return ( - - - - - - - - - {groups?.map((group) => ( - - ))} - -
Name -
- {!isLoading && !groups?.length && ( - - )} -
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search groups..." + /> + + + + + + + + + {filteredGroupMemberships.slice(offset, perPage * page).map((group) => ( + + ))} + +
+
+ Name + + + +
+
+
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} +
+
); }; diff --git a/frontend/src/views/Project/IdentityDetailsPage/IdentityDetailPage.tsx b/frontend/src/views/Project/IdentityDetailsPage/IdentityDetailPage.tsx index 95aed14c5..d16bd64ae 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/IdentityDetailPage.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/IdentityDetailPage.tsx @@ -1,4 +1,5 @@ import { useRouter } from "next/router"; +import { subject } from "@casl/ability"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; @@ -94,7 +95,9 @@ export const IdentityDetailsPage = withProjectPermission(
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index f69cd1478..db1888a22 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 975ddb5c7..db900b1b2 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 300114228..1fa98628e 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 4b6bd9f39..8735b047d 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 e5a6adf4d..4118429b2 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/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 3ece05497..ef1c89e58 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddGroupToWorkspace, @@ -16,8 +16,8 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - id: z.string(), - role: z.string() + group: z.object({ id: z.string(), name: z.string() }), + role: z.object({ slug: z.string(), name: z.string() }) }); export type FormData = z.infer; @@ -27,7 +27,9 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; }; -export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { +// TODO: update backend to support adding multiple roles at once + +const Content = ({ popUp, handlePopUpToggle }: Props) => { const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); @@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { resolver: zodResolver(schema) }); - const onFormSubmit = async ({ id, role }: FormData) => { + const onFormSubmit = async ({ group, role }: FormData) => { try { await addGroupToWorkspaceMutateAsync({ projectId: currentWorkspace?.id || "", - groupId: id, - role: role || undefined + groupId: group.id, + role: role.slug || undefined }); reset(); @@ -82,95 +84,84 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { } }; + return filteredGroupMembershipOrgs.length ? ( +
+ ( + + option.id} + getOptionLabel={(option) => option.name} + options={filteredGroupMembershipOrgs} + placeholder="Select group..." + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + options={roles} + placeholder="Select role..." + /> + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ ); +}; + +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("group", isOpen); - reset(); - }} + onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)} > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} + +
); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index defd32863..362c15656 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 3640984cd..000000000 --- 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 f59675cb3..000000000 --- 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/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index fd0b13172..ed8271973 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -2,24 +2,11 @@ import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - FilterableSelect, - FormControl, - Modal, - ModalContent -} from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddUsersToOrg, @@ -33,7 +20,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; const addMemberFormSchema = z.object({ orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1), - projectRoleSlugs: z.array(z.string().trim().min(1)).min(1) + projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1) }); type TAddMemberForm = z.infer; @@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(addMemberFormSchema), - defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] } + defaultValues: { orgMemberships: [], projectRoleSlugs: [] } }); const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); @@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { { slug: currentWorkspace.slug, id: currentWorkspace.id, - projectRoleSlug: projectRoleSlugs + projectRoleSlug: projectRoleSlugs.map((role) => role.slug) } ] }); @@ -172,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - - - {roles && roles.length > 0 ? ( -
- {/* eslint-disable-next-line no-nested-ternary */} - {selectedRoleSlugs.length === 1 - ? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name - : selectedRoleSlugs.length === 0 - ? "Select at least one role" - : `${selectedRoleSlugs.length} roles selected`} - -
- ) : ( -
- No roles found -
- )} -
- - {roles && roles.length > 0 ? ( - roles.map((role) => { - const isSelected = selectedRoleSlugs.includes(role.slug); - - return ( - roles.length > 1 && event.preventDefault()} - onClick={() => { - if (selectedRoleSlugs.includes(String(role.slug))) { - field.onChange( - selectedRoleSlugs.filter( - (roleSlug: string) => roleSlug !== String(role.slug) - ) - ); - } else { - field.onChange([...selectedRoleSlugs, role.slug]); - } - }} - key={`role-slug-${role.slug}`} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {role.name} - - ); - }) - ) : ( -
- )} - - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Project/RolePage/components/RoleModal.tsx b/frontend/src/views/Project/RolePage/components/RoleModal.tsx index 5a87b4a61..cf8cab03b 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 3c1310de2..d345736f0 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([]), @@ -144,7 +149,8 @@ type TConditionalFields = | ProjectPermissionSub.Secrets | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports - | ProjectPermissionSub.DynamicSecrets; + | ProjectPermissionSub.DynamicSecrets + | ProjectPermissionSub.Identity; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -152,7 +158,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 = []; @@ -491,17 +498,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" }, @@ -535,7 +542,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 1e00306be..16397ac24 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 9d6e699cb..15d819fc8 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 000000000..8a62c6ad9 --- /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 21fad117a..9120f0364 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 93e906373..42e576f06 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/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 936e6b981..d2a4e3788 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { leftIcon={} isDisabled={!isAllowed} > - Create policy + Create Policy )} @@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { Name Environment Secret Path - Eligible Approvers - Eligible Group Approvers + Eligible Approvers + Eligible Group Approvers Approval Required @@ -256,9 +256,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { {!!currentWorkspace && filteredPolicies?.map((policy) => ( data.approvers, { - path: ["approvers"], - message: "At least one approver should be provided." + .superRefine((data, ctx) => { + if (!(data.groupApprovers.length || data.userApprovers.length)) { + ctx.addIssue({ + path: ["userApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + ctx.addIssue({ + path: ["groupApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + } }); type TFormSchema = z.infer; @@ -84,8 +92,15 @@ export const AccessPolicyForm = ({ values: editValues ? { ...editValues, - environment: editValues.environment.slug, - approvers: editValues?.approvers || [], + environment: editValues.environment, + userApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [], + groupApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [], approvals: editValues?.approvals } : undefined @@ -110,18 +125,27 @@ export const AccessPolicyForm = ({ const approversRequired = watch("approvals") || 1; - const handleCreatePolicy = async (data: TFormSchema) => { + const handleCreatePolicy = async ({ + environment, + groupApprovers, + userApprovers, + ...data + }: TFormSchema) => { if (!projectId) return; try { if (data.policyType === PolicyType.ChangePolicy) { await createSecretApprovalPolicy({ ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, workspaceId: currentWorkspace?.id || "" }); } else { await createAccessApprovalPolicy({ ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, projectSlug }); } @@ -139,7 +163,12 @@ export const AccessPolicyForm = ({ } }; - const handleUpdatePolicy = async (data: TFormSchema) => { + const handleUpdatePolicy = async ({ + environment, + userApprovers, + groupApprovers, + ...data + }: TFormSchema) => { if (!projectId || !projectSlug) return; if (!editValues?.id) return; @@ -148,12 +177,15 @@ export const AccessPolicyForm = ({ await updateSecretApprovalPolicy({ id: editValues?.id, ...data, + approvers: [...userApprovers, ...groupApprovers], workspaceId: currentWorkspace?.id || "" }); } else { await updateAccessApprovalPolicy({ id: editValues?.id, ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, projectSlug }); } @@ -179,150 +211,178 @@ export const AccessPolicyForm = ({ } }; + const memberOptions = useMemo( + () => + members.map((member) => ({ + id: member.user.id, + type: ApproverType.User + })), + [members] + ); + + const groupOptions = useMemo( + () => + groups?.map(({ group }) => ({ + id: group.id, + type: ApproverType.Group + })), + [groups] + ); + return ( - +
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - onChange(val as PolicyType)} + className="w-full border border-mineshaft-500" + > + {Object.values(PolicyType).map((policyType) => { + return ( + + {policyDetails[policyType].name} + + ); + })} + + + )} + /> + ( + - {environments.map((sourceEnvironment) => ( - - {sourceEnvironment.name} - - ))} - - - )} - /> - ( - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> - ( - - {field.value === EnforcementLevel.Hard - ? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.` - : `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`} -
- } - > - field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + - {Object.values(EnforcementLevel).map((level) => { - return ( - - {level} - - ); - })} - - - )} - /> + + + )} + /> + ( + +

+ Determines the level of enforcement for required approvers of a request: +

+

+ Hard enforcement requires at least{" "} + {approversRequired} approver(s) to + approve the request.` +

+

+ Soft enforcement At least{" "} + {approversRequired} approver(s) must + approve the request; however, the requester can bypass approval + requirements in emergencies. +

+ + } + > + +
+ )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + + + )} + /> +

Approvers

@@ -331,127 +391,53 @@ export const AccessPolicyForm = ({

( - - - e.type === ApproverType.User).length - ? `${value.filter((e) => e.type === ApproverType.User).length} selected` - : "None" - } - className="text-left" - /> - - - - Select members that are allowed to approve requests - - {members.map(({ user }) => { - const { id: userId } = user; - const isChecked = - value?.filter( - (el: { id: string; type: ApproverType }) => - el.id === userId && el.type === ApproverType.User - ).length > 0; - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter( - (el: { id: string; type: ApproverType }) => - el.id !== userId && el.type !== ApproverType.User - ) - : [...(value || []), { id: userId, type: ApproverType.User }] - ); - }} - key={`create-policy-members-${userId}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + + if (!member) return option.id; + + return getMemberLabel(member); + }} + value={value} + onChange={onChange} + /> )} /> ( - - - e.type === ApproverType.Group).length - ? `${ - value?.filter((e) => e.type === ApproverType.Group).length - } selected` - : "None" - } - className="text-left" - /> - - - - Select groups that are allowed to approve requests - - {groups && - groups.map(({ group }) => { - const { id } = group; - const isChecked = - value?.filter( - (el: { id: string; type: ApproverType }) => - el.id === id && el.type === ApproverType.Group - ).length > 0; - - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter( - (el: { id: string; type: ApproverType }) => - el.id !== id && el.type !== ApproverType.Group - ) - : [...(value || []), { id, type: ApproverType.Group }] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {group.name} - - ); - })} - - + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> )} /> diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 4b13029df..865e92994 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -8,19 +8,19 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuTrigger, - Input, Td, + Tooltip, Tr } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; import { policyDetails } from "@app/helpers/policies"; -import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; -import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types"; +import { Approver } from "@app/hooks/api/accessApproval/types"; import { TGroupMembership } from "@app/hooks/api/groups/types"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; +import { ApproverType } from "@app/hooks/api/secretApproval/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; @@ -35,14 +35,12 @@ interface IPolicy { updatedAt: Date; policyType: PolicyType; enforcementLevel: EnforcementLevel; -}; +} type Props = { policy: IPolicy; members?: TWorkspaceUser[]; groups?: TGroupMembership[]; - projectSlug: string; - workspaceId: string; onEdit: () => void; onDelete: () => void; }; @@ -51,175 +49,58 @@ export const ApprovalPolicyRow = ({ policy, members = [], groups = [], - projectSlug, - workspaceId, onEdit, onDelete }: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - const [selectedGroupApprovers, setSelectedGroupApprovers] = useState(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); - const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); - const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; + const labels = useMemo(() => { + const usersInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id); - const { permission } = useProjectPermission(); + const groupsInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map((approver) => approver.id); + + const memberLabels = usersInPolicy?.length + ? members + .filter((member) => usersInPolicy?.includes(member.user.id)) + .map((member) => getMemberLabel(member)) + .join(", ") + : null; + + const groupLabels = groupsInPolicy?.length + ? groups + .filter(({ group }) => groupsInPolicy?.includes(group.id)) + .map(({ group }) => group.name) + .join(", ") + : null; + + return { + members: memberLabels, + groups: groupLabels + }; + }, [policy, members, groups]); return ( {policy.name} {policy.environment.slug} {policy.secretPath || "*"} - - { - if (!isOpen) { - if (policy.policyType === PolicyType.AccessPolicy) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - } - ); - } else { - updateSecretApprovalPolicy( - { - workspaceId, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - } - ); - } - } else { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - }} + + - - - - - - Select members that are allowed to approve changes - - {members?.map(({ user }) => { - const userId = user.id; - const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0; - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }] - ); - }} - key={`create-policy-members-${userId}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - +

{labels.members ?? "-"}

+ - - { - if (!isOpen) { - if (policy.policyType === PolicyType.AccessPolicy) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - }, - ); - } else { - updateSecretApprovalPolicy( - { - workspaceId, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - } - ); - } - } else { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - }} + + - - - - - - Select groups that are allowed to approve requests - - {groups && groups.map(({ group }) => { - const { id } = group; - const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0; - return ( - { - evt.preventDefault(); - setSelectedGroupApprovers( - isChecked - ? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group) - : [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }] - ); - }} - key={`create-policy-groups-${id}`} - iconPos="right" - icon={isChecked && } - > - {group.name} - - ); - })} - - +

{labels.groups ?? "-"}

+ {policy.approvals} @@ -229,12 +110,12 @@ export const ApprovalPolicyRow = ({ - -
+ +
- + @@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({ ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.name} + getOptionValue={(option) => option.slug} + placeholder="Select environment..." + value={value} + onChange={onChange} + /> )} /> @@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({ defaultValue="/" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx index fb5355298..ac6efe5cd 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx @@ -1,14 +1,7 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; -import { - faClone, - faFileImport, - faKey, - faSearch, - faSquareCheck, - faSquareXmark -} from "@fortawesome/free-solid-svg-icons"; +import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -16,17 +9,13 @@ import { z } from "zod"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, - Checkbox, - EmptyState, + FilterableSelect, FormControl, IconButton, - Input, Modal, ModalContent, ModalTrigger, - Select, - SelectItem, - Skeleton, + Switch, Tooltip } from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; @@ -35,14 +24,17 @@ import { useDebounce } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; const formSchema = z.object({ - environment: z.string().trim(), + environment: z.object({ name: z.string(), slug: z.string() }), secretPath: z .string() .trim() .transform((val) => typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val ), - secrets: z.record(z.string().optional().nullable()) + secrets: z + .object({ key: z.string(), value: z.string().optional() }) + .array() + .min(1, "Select one or more secrets to copy") }); type TFormSchema = z.infer; @@ -68,7 +60,6 @@ export const CopySecretsFromBoard = ({ onToggle, onParsedEnv }: Props) => { - const [searchFilter, setSearchFilter] = useState(""); const [shouldIncludeValues, setShouldIncludeValues] = useState(true); const { @@ -80,7 +71,7 @@ export const CopySecretsFromBoard = ({ formState: { isDirty } } = useForm({ resolver: zodResolver(formSchema), - defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + defaultValues: { secretPath: "/", environment: environments?.[0] } }); const envCopySecPath = watch("secretPath"); @@ -89,7 +80,7 @@ export const CopySecretsFromBoard = ({ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ workspaceId, - environment: selectedEnvSlug, + environment: selectedEnvSlug.slug, secretPath: debouncedEnvCopySecretPath, options: { enabled: @@ -101,29 +92,22 @@ export const CopySecretsFromBoard = ({ }); useEffect(() => { - setValue("secrets", {}); - setSearchFilter(""); - }, [debouncedEnvCopySecretPath]); + setValue("secrets", []); + }, [debouncedEnvCopySecretPath, selectedEnvSlug]); const handleSecSelectAll = () => { if (secrets) { - setValue( - "secrets", - secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), - { shouldDirty: true } - ); + setValue("secrets", secrets, { shouldDirty: true }); } }; const handleFormSubmit = async (data: TFormSchema) => { const secretsToBePulled: Record = {}; - Object.keys(data.secrets || {}).forEach((key) => { - if (data.secrets[key]) { - secretsToBePulled[key] = { - value: (shouldIncludeValues && data.secrets[key]) || "", - comments: [""] - }; - } + data.secrets.forEach(({ key, value }) => { + secretsToBePulled[key] = { + value: (shouldIncludeValues && value) || "", + comments: [""] + }; }); onParsedEnv(secretsToBePulled); onToggle(false); @@ -136,7 +120,6 @@ export const CopySecretsFromBoard = ({ onOpenChange={(state) => { onToggle(state); reset(); - setSearchFilter(""); }} > @@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
( - + onChange={onChange} + options={environments} + placeholder="Select environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> )} /> @@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({ )} @@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
Secrets
-
- +
+ ( + + option.key} + getOptionLabel={(option) => option.key} + /> + + )} + /> + + } - onChange={(evt) => setSearchFilter(evt.target.value)} - /> - - - - - - - reset()} - > - - - -
+ onClick={handleSecSelectAll} + > + + +
- {!isSecretsLoading && !secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} - - {secrets - ?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase())) - ?.map(({ id, key, value: secVal }) => ( - ( - onChange(isChecked ? secVal : "")} - > - {key} - - )} - /> - ))} -
-
- + setShouldIncludeValues(isChecked as boolean)} > Include secret values - +
+ Choose visible environments + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; + + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" + > +
{name}
+
+ ); + })} )} @@ -1128,7 +1128,6 @@ export const SecretOverviewPage = () => { > handlePopUpClose("addSecretsInAllEnvs")} /> diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 0716309d3..36e430205 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -1,13 +1,13 @@ import { ClipboardEvent } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; -import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, FormControl, FormLabel, Input, Tooltip } from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2"; import { CreatableSelect } from "@app/components/v2/CreatableSelect"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { @@ -17,20 +17,14 @@ import { useWorkspace } from "@app/context"; import { getKeyValue } from "@app/helpers/parseEnvVar"; -import { - useCreateFolder, - useCreateSecretV3, - useCreateWsTag, - useGetWsTags, - useUpdateSecretV3 -} from "@app/hooks/api"; -import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; +import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; +import { SecretType } from "@app/hooks/api/types"; const typeSchema = z .object({ key: z.string().trim().min(1, "Key is required"), value: z.string().optional(), - environments: z.record(z.boolean().optional()), + environments: z.object({ name: z.string(), slug: z.string() }).array(), tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional() }) .refine((data) => data.key !== undefined, { @@ -41,22 +35,19 @@ type TFormSchema = z.infer; type Props = { secretPath?: string; - getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; // modal props onClose: () => void; }; -export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: Props) => { +export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { const { register, handleSubmit, control, reset, - watch, setValue, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(typeSchema) }); - const newSecretKey = watch("key"); const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); @@ -65,22 +56,13 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: const environments = currentWorkspace?.environments || []; const { mutateAsync: createSecretV3 } = useCreateSecretV3(); - const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags( canReadTags ? workspaceId : "" ); const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => { - const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]); - const isEnvironmentsSelected = environmentsSelected.length; - - if (!isEnvironmentsSelected) { - createNotification({ type: "error", text: "Select at least one environment" }); - return; - } - - const promises = environmentsSelected.map(async (env) => { + const promises = selectedEnv.map(async (env) => { const environment = env.slug; // create folder if not existing if (secretPath !== "/") { @@ -106,21 +88,7 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: } } - const isEdit = getSecretByKey(environment, key) !== undefined; - if (isEdit) { - return { - ...(await updateSecretV3({ - environment, - workspaceId, - secretPath, - secretKey: key, - secretValue: value || "", - type: SecretType.Shared, - tagIds: tags?.map((el) => el.value) - })), - environment - }; - } + // TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination return { ...(await createSecretV3({ @@ -278,54 +246,33 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: )} /> - -
- {environments - .filter((environmentSlug) => - permission.can( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { - environment: environmentSlug.slug, - secretPath, - secretName: "*", - secretTags: ["*"] - }) - ) - ) - .map((env) => { - return ( - ( - - - - {env.name} - - - {getSecretByKey(env.slug, newSecretKey) && ( - - - - )} - - - - )} - /> - ); - })} -
+ ( + + + permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: environment.slug, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ) + )} + value={value} + onChange={onChange} + placeholder="Select environments to create secret in..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> + + )} + name="environments" + />
+ +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx index d1de3a2e5..1bdf2757d 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx @@ -1,4 +1,4 @@ -import { faAws } from "@fortawesome/free-brands-svg-icons"; +import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faEllipsis, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -117,9 +117,12 @@ export const OrgEncryptionTab = withPermission( externalKmsList?.map((kms) => ( - {kms.externalKms.provider === ExternalKmsProvider.AWS && ( + {kms.externalKms.provider === ExternalKmsProvider.Aws && ( )} + {kms.externalKms.provider === ExternalKmsProvider.Gcp && ( + + )}
{kms.externalKms.provider.toUpperCase()}
{kms.name} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx index 80e171167..bc2e85558 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx @@ -3,6 +3,7 @@ import { useGetExternalKmsById } from "@app/hooks/api"; import { ExternalKmsProvider } from "@app/hooks/api/kms/types"; import { AwsKmsForm } from "./AwsKmsForm"; +import { GcpKmsForm } from "./GcpKmsForm"; type Props = { isOpen: boolean; @@ -14,15 +15,22 @@ export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) => const { data: externalKms, isLoading } = useGetExternalKmsById(kmsId); return ( - + {isLoading && } - {externalKms?.external?.provider === ExternalKmsProvider.AWS && ( + {externalKms?.external?.provider === ExternalKmsProvider.Aws && ( onOpenChange(false)} onCompleted={() => onOpenChange(false)} /> )} + {externalKms?.external?.provider === ExternalKmsProvider.Gcp && ( + onOpenChange(false)} + onCompleted={() => onOpenChange(false)} + /> + )} ); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx index 281061db4..93c24586e 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 0e703798e..7cb6d5d09 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 677a7c773..b72d12c73 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 1f65df795..e601e0319 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 68bbeb840..00f160e75 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 c6b2152cc..ad11c2381 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 75f6b69bf..96c667050 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/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index d1ba06835..26be8eb17 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -19,7 +19,6 @@ import { SecretTagsTable } from "./SecretTagsTable"; type DeleteModalData = { name: string; id: string }; export const SecretTagsSection = (): JSX.Element => { - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ "CreateSecretTag", "deleteTagConfirmation" @@ -65,7 +64,7 @@ export const SecretTagsSection = (): JSX.Element => { }} isDisabled={!isAllowed} > - Create tag + Create Tag )} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index cc68b0700..b6793ea1d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -1,10 +1,20 @@ -import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faMagnifyingGlass, + faSearch, + faTag, + faTrashCan +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, + Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -15,7 +25,9 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetWsTags } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -31,59 +43,124 @@ type Props = { ) => void; }; +enum TagsOrderBy { + Slug = "slug" +} + export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWsTags(currentWorkspace?.id ?? ""); + const { data: tags = [], isLoading } = useGetWsTags(currentWorkspace?.id ?? ""); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(TagsOrderBy.Slug, { initPerPage: 10 }); + + const filteredTags = useMemo( + () => + tags + .filter((tag) => tag.slug.toLowerCase().includes(search.trim().toLowerCase())) + .sort((a, b) => { + const [tagOne, tagTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + return tagOne.slug.toLowerCase().localeCompare(tagTwo.slug.toLowerCase()); + }), + [tags, orderDirection, search] + ); + + useResetPageHelper({ + totalCount: filteredTags.length, + offset, + setPage + }); return ( - - - - - - - - - {isLoading && } - {!isLoading && - data && - data.map(({ id, slug }) => ( - - - - - ))} - {!isLoading && data && data?.length === 0 && ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search tags..." + /> + +
Slug -
{slug} - - {(isAllowed) => ( - - handlePopUpOpen("deleteTagConfirmation", { - name: slug, - id - }) - } - colorSchema="danger" - ariaLabel="update" - isDisabled={!isAllowed} - > - - - )} - -
+ - + + - )} - -
- - +
+ Slug + + + +
+
-
+ + + {isLoading && } + {!isLoading && + filteredTags.slice(offset, perPage * page).map(({ id, slug }) => ( + + {slug} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteTagConfirmation", { + name: slug, + id + }) + } + size="xs" + colorSchema="danger" + ariaLabel="update" + variant="plain" + isDisabled={!isAllowed} + > + + + )} + + + + ))} + + + {Boolean(filteredTags.length) && ( + + )} + {!isLoading && !filteredTags?.length && ( + + )} + +
); }; diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 8ff17cdaa..f212ce4eb 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.7.4 +version: v0.7.5 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.7.4" +appVersion: "v0.7.5" diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 3e0d6ab72..9d300eaf4 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/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index c2ad28f2b..dc342c5ac 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.7.4 + tag: v0.7.5 resources: limits: cpu: 500m diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index 65da2498c..1af2faf20 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 dd242910c..41e4d3f20 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 633b48460..78027f929 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 000000000..9d9d86ab5 --- /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 a66b4d799..cdf2a4a26 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 3d16f3a84..e3328061c 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 9fb79c1de..b3325a701 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, }) }