diff --git a/.env.example b/.env.example index 53e36449a..05a888db0 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ REDIS_URL=redis://redis:6379 # Required SITE_URL=http://localhost:8080 -# Mail/SMTP +# Mail/SMTP SMTP_HOST= SMTP_PORT= SMTP_FROM_ADDRESS= @@ -132,3 +132,6 @@ DATADOG_PROFILING_ENABLED= DATADOG_ENV= DATADOG_SERVICE= DATADOG_HOSTNAME= + +# kubernetes +KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 3fe0fbe21..1c16b00b3 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -83,7 +83,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} goreleaser: - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8-cores needs: [cli-integration-tests] steps: - uses: actions/checkout@v3 diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index c799aaf23..278fca695 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -19,7 +19,7 @@ WORKDIR /app # Copy dependencies COPY --from=frontend-dependencies /app/node_modules ./node_modules -# Copy all files +# Copy all files COPY /frontend . ENV NODE_ENV production @@ -32,7 +32,7 @@ ENV VITE_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION ENV VITE_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ARG CAPTCHA_SITE_KEY -ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY +ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -134,7 +134,7 @@ RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-li # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.41.2 \ + && apt-get update && apt-get install -y infisical=0.41.89 \ && rm -rf /var/lib/apt/lists/* RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user @@ -155,7 +155,7 @@ ENV INTERCOM_ID=$INTERCOM_ID ARG CAPTCHA_SITE_KEY ENV CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY -WORKDIR / +WORKDIR / COPY --from=backend-runner /app /backend @@ -166,9 +166,9 @@ ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ENV PORT 8080 ENV HOST=0.0.0.0 -ENV HTTPS_ENABLED false +ENV HTTPS_ENABLED false ENV NODE_ENV production -ENV STANDALONE_BUILD true +ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ ENV NODE_OPTIONS="--max-old-space-size=1024" diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 45295dec8..e07c8a9da 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -20,7 +20,7 @@ WORKDIR /app # Copy dependencies COPY --from=frontend-dependencies /app/node_modules ./node_modules -# Copy all files +# Copy all files COPY /frontend . ENV NODE_ENV production @@ -33,7 +33,8 @@ ENV VITE_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION ENV VITE_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ARG CAPTCHA_SITE_KEY -ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY +ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY +ENV NODE_OPTIONS="--max-old-space-size=8192" # Build RUN npm run build @@ -77,6 +78,7 @@ RUN npm ci --only-production COPY /backend . COPY --chown=non-root-user:nodejs standalone-entrypoint.sh standalone-entrypoint.sh RUN npm i -D tsconfig-paths +ENV NODE_OPTIONS="--max-old-space-size=8192" RUN npm run build # Production stage @@ -128,7 +130,7 @@ RUN apt-get update && apt-get install -y \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.41.2 \ + && apt-get update && apt-get install -y infisical=0.41.89 \ && rm -rf /var/lib/apt/lists/* WORKDIR / @@ -164,9 +166,9 @@ ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ENV PORT 8080 ENV HOST=0.0.0.0 -ENV HTTPS_ENABLED false +ENV HTTPS_ENABLED false ENV NODE_ENV production -ENV STANDALONE_BUILD true +ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV NODE_OPTIONS="--max-old-space-size=1024" diff --git a/backend/Dockerfile b/backend/Dockerfile index 79333cb92..bca974f26 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y \ make \ g++ \ openssh-client \ - openssl + openssl # Install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apt-get install -y \ @@ -55,10 +55,10 @@ COPY --from=build /app . # Install Infisical CLI RUN apt-get install -y curl bash && \ curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ - apt-get update && apt-get install -y infisical=0.41.2 git + apt-get update && apt-get install -y infisical=0.41.89 git -HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ - CMD node healthcheck.js +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ + CMD node healthcheck.js ENV HOST=0.0.0.0 diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 75c561ac1..de5648797 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -57,7 +57,7 @@ RUN mkdir -p /etc/softhsm2/tokens && \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.2 + apt-get install -y infisical=0.41.89 WORKDIR /app diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index 0afb330e5..8513c982b 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -52,7 +52,7 @@ RUN apt-get install -y opensc RUN mkdir -p /etc/softhsm2/tokens && \ softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - + WORKDIR /openssl-build RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && tar -xf openssl-3.1.2.tar.gz \ @@ -66,7 +66,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.2 + apt-get install -y infisical=0.41.89 WORKDIR /app diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index 3ce8148aa..b1ecf7d65 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -110,7 +110,8 @@ export const initAuditLogDbConnection = ({ }, migrations: { tableName: "infisical_migrations" - } + }, + pool: { min: 0, max: 10 } }); // we add these overrides so that auditLogDb and the primary DB are interchangeable diff --git a/backend/src/db/knexfile.ts b/backend/src/db/knexfile.ts index 0e51e94e2..82a8adea0 100644 --- a/backend/src/db/knexfile.ts +++ b/backend/src/db/knexfile.ts @@ -4,6 +4,7 @@ import "ts-node/register"; import dotenv from "dotenv"; import type { Knex } from "knex"; import path from "path"; +import { initLogger } from "@app/lib/logger"; // Update with your config settings. . dotenv.config({ @@ -13,6 +14,8 @@ dotenv.config({ path: path.join(__dirname, "../../../.env") }); +initLogger(); + export default { development: { client: "postgres", diff --git a/backend/src/db/migrations/20250627010508_env-overrides.ts b/backend/src/db/migrations/20250627010508_env-overrides.ts new file mode 100644 index 000000000..535360a80 --- /dev/null +++ b/backend/src/db/migrations/20250627010508_env-overrides.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.binary("encryptedEnvOverrides").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("encryptedEnvOverrides"); + }); + } +} diff --git a/backend/src/db/migrations/20250630212553_user-latest-invite.ts b/backend/src/db/migrations/20250630212553_user-latest-invite.ts new file mode 100644 index 000000000..6d8c01248 --- /dev/null +++ b/backend/src/db/migrations/20250630212553_user-latest-invite.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (!hasColumn) { + t.datetime("lastInvitedAt").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (hasColumn) { + t.dropColumn("lastInvitedAt"); + } + }); +} diff --git a/backend/src/db/migrations/20250707162850_last-invited-at-default.ts b/backend/src/db/migrations/20250707162850_last-invited-at-default.ts new file mode 100644 index 000000000..bfb7b85d6 --- /dev/null +++ b/backend/src/db/migrations/20250707162850_last-invited-at-default.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + if (hasColumn) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + t.datetime("lastInvitedAt").nullable().defaultTo(knex.fn.now()).alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + if (hasColumn) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + t.datetime("lastInvitedAt").nullable().alter(); + }); + } +} diff --git a/backend/src/db/migrations/20250710022434_add-index-for-access-token.ts b/backend/src/db/migrations/20250710022434_add-index-for-access-token.ts new file mode 100644 index 000000000..162535c28 --- /dev/null +++ b/backend/src/db/migrations/20250710022434_add-index-for-access-token.ts @@ -0,0 +1,46 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const MIGRATION_TIMEOUT = 30 * 60 * 1000; // 30 minutes + +export async function up(knex: Knex): Promise { + const result = await knex.raw("SHOW statement_timeout"); + const originalTimeout = result.rows[0].statement_timeout; + + try { + await knex.raw(`SET statement_timeout = ${MIGRATION_TIMEOUT}`); + + // iat means IdentityAccessToken + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_iat_identity_id + ON ${TableName.IdentityAccessToken} ("identityId") + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_iat_ua_client_secret_id + ON ${TableName.IdentityAccessToken} ("identityUAClientSecretId") + `); + } finally { + await knex.raw(`SET statement_timeout = '${originalTimeout}'`); + } +} + +export async function down(knex: Knex): Promise { + const result = await knex.raw("SHOW statement_timeout"); + const originalTimeout = result.rows[0].statement_timeout; + + try { + await knex.raw(`SET statement_timeout = ${MIGRATION_TIMEOUT}`); + + await knex.raw(` + DROP INDEX IF EXISTS idx_iat_identity_id + `); + + await knex.raw(` + DROP INDEX IF EXISTS idx_iat_ua_client_secret_id + `); + } finally { + await knex.raw(`SET statement_timeout = '${originalTimeout}'`); + } +} diff --git a/backend/src/db/migrations/20250710115149_required-path-on-approval-policies.ts b/backend/src/db/migrations/20250710115149_required-path-on-approval-policies.ts new file mode 100644 index 000000000..b5f5abef7 --- /dev/null +++ b/backend/src/db/migrations/20250710115149_required-path-on-approval-policies.ts @@ -0,0 +1,55 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const existingSecretApprovalPolicies = await knex(TableName.SecretApprovalPolicy) + .whereNull("secretPath") + .orWhere("secretPath", ""); + + const existingAccessApprovalPolicies = await knex(TableName.AccessApprovalPolicy) + .whereNull("secretPath") + .orWhere("secretPath", ""); + + // update all the secret approval policies secretPath to be "/**" + if (existingSecretApprovalPolicies.length) { + await knex(TableName.SecretApprovalPolicy) + .whereIn( + "id", + existingSecretApprovalPolicies.map((el) => el.id) + ) + .update({ + secretPath: "/**" + }); + } + + // update all the access approval policies secretPath to be "/**" + if (existingAccessApprovalPolicies.length) { + await knex(TableName.AccessApprovalPolicy) + .whereIn( + "id", + existingAccessApprovalPolicies.map((el) => el.id) + ) + .update({ + secretPath: "/**" + }); + } + + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (table) => { + table.string("secretPath").notNullable().alter(); + }); + + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (table) => { + table.string("secretPath").notNullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (table) => { + table.string("secretPath").nullable().alter(); + }); + + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (table) => { + table.string("secretPath").nullable().alter(); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index 19a98675f..ea57c54d2 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -11,7 +11,7 @@ export const AccessApprovalPoliciesSchema = z.object({ id: z.string().uuid(), name: z.string(), approvals: z.number().default(1), - secretPath: z.string().nullable().optional(), + secretPath: z.string(), envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index e77b6e9c9..939033c71 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -18,7 +18,8 @@ export const OrgMembershipsSchema = z.object({ orgId: z.string().uuid(), roleId: z.string().uuid().nullable().optional(), projectFavorites: z.string().array().nullable().optional(), - isActive: z.boolean().default(true) + isActive: z.boolean().default(true), + lastInvitedAt: z.date().nullable().optional() }); export type TOrgMemberships = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 8b9174456..0273e617c 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const SecretApprovalPoliciesSchema = z.object({ id: z.string().uuid(), name: z.string(), - secretPath: z.string().nullable().optional(), + secretPath: z.string(), approvals: z.number().default(1), envId: z.string().uuid(), createdAt: z.date(), diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index de4975b20..b5e160096 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -34,7 +34,8 @@ export const SuperAdminSchema = z.object({ encryptedGitHubAppConnectionClientSecret: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionSlug: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionId: zodBuffer.nullable().optional(), - encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional() + encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional(), + encryptedEnvOverrides: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 74545579c..177f5e1fd 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -2,6 +2,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { removeTrailingSlash } from "@app/lib/fn"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -19,7 +20,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi body: z.object({ projectSlug: z.string().trim(), name: z.string().optional(), - secretPath: z.string().trim().default("/"), + secretPath: z.string().trim().min(1, { message: "Secret path cannot be empty" }).transform(removeTrailingSlash), environment: z.string(), approvers: z .discriminatedUnion("type", [ @@ -174,8 +175,9 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi secretPath: z .string() .trim() + .min(1, { message: "Secret path cannot be empty" }) .optional() - .transform((val) => (val === "" ? "/" : val)), + .transform((val) => (val ? removeTrailingSlash(val) : val)), approvers: z .discriminatedUnion("type", [ z.object({ diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 57c5736df..7c520e2ab 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import { LdapGroupMapsSchema } from "@app/db/schemas"; import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; +import { ApiDocsTags, LdapSso } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -132,10 +133,18 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { config: { rateLimit: readLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.LdapSso], + description: "Get LDAP config", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - organizationId: z.string().trim() + organizationId: z.string().trim().describe(LdapSso.GET_CONFIG.organizationId) }), response: { 200: z.object({ @@ -172,23 +181,32 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.LdapSso], + description: "Create LDAP config", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - organizationId: z.string().trim(), - isActive: z.boolean(), - url: z.string().trim(), - bindDN: z.string().trim(), - bindPass: z.string().trim(), - uniqueUserAttribute: z.string().trim().default("uidNumber"), - searchBase: z.string().trim(), - searchFilter: z.string().trim().default("(uid={{username}})"), - groupSearchBase: z.string().trim(), + organizationId: z.string().trim().describe(LdapSso.CREATE_CONFIG.organizationId), + isActive: z.boolean().describe(LdapSso.CREATE_CONFIG.isActive), + url: z.string().trim().describe(LdapSso.CREATE_CONFIG.url), + bindDN: z.string().trim().describe(LdapSso.CREATE_CONFIG.bindDN), + bindPass: z.string().trim().describe(LdapSso.CREATE_CONFIG.bindPass), + uniqueUserAttribute: z.string().trim().default("uidNumber").describe(LdapSso.CREATE_CONFIG.uniqueUserAttribute), + searchBase: z.string().trim().describe(LdapSso.CREATE_CONFIG.searchBase), + searchFilter: z.string().trim().default("(uid={{username}})").describe(LdapSso.CREATE_CONFIG.searchFilter), + groupSearchBase: z.string().trim().describe(LdapSso.CREATE_CONFIG.groupSearchBase), groupSearchFilter: z .string() .trim() - .default("(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"), - caCert: z.string().trim().default("") + .default("(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))") + .describe(LdapSso.CREATE_CONFIG.groupSearchFilter), + caCert: z.string().trim().default("").describe(LdapSso.CREATE_CONFIG.caCert) }), response: { 200: SanitizedLdapConfigSchema @@ -214,23 +232,31 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.LdapSso], + description: "Update LDAP config", + security: [ + { + bearerAuth: [] + } + ], body: z .object({ - isActive: z.boolean(), - url: z.string().trim(), - bindDN: z.string().trim(), - bindPass: z.string().trim(), - uniqueUserAttribute: z.string().trim(), - searchBase: z.string().trim(), - searchFilter: z.string().trim(), - groupSearchBase: z.string().trim(), - groupSearchFilter: z.string().trim(), - caCert: z.string().trim() + isActive: z.boolean().describe(LdapSso.UPDATE_CONFIG.isActive), + url: z.string().trim().describe(LdapSso.UPDATE_CONFIG.url), + bindDN: z.string().trim().describe(LdapSso.UPDATE_CONFIG.bindDN), + bindPass: z.string().trim().describe(LdapSso.UPDATE_CONFIG.bindPass), + uniqueUserAttribute: z.string().trim().describe(LdapSso.UPDATE_CONFIG.uniqueUserAttribute), + searchBase: z.string().trim().describe(LdapSso.UPDATE_CONFIG.searchBase), + searchFilter: z.string().trim().describe(LdapSso.UPDATE_CONFIG.searchFilter), + groupSearchBase: z.string().trim().describe(LdapSso.UPDATE_CONFIG.groupSearchBase), + groupSearchFilter: z.string().trim().describe(LdapSso.UPDATE_CONFIG.groupSearchFilter), + caCert: z.string().trim().describe(LdapSso.UPDATE_CONFIG.caCert) }) .partial() - .merge(z.object({ organizationId: z.string() })), + .merge(z.object({ organizationId: z.string().trim().describe(LdapSso.UPDATE_CONFIG.organizationId) })), response: { 200: SanitizedLdapConfigSchema } diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 1bfc4d696..59c05272d 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas"; import { OIDCConfigurationType, OIDCJWTSignatureAlgorithm } from "@app/ee/services/oidc/oidc-config-types"; +import { ApiDocsTags, OidcSSo } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -153,10 +154,18 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { config: { rateLimit: readLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.OidcSso], + description: "Get OIDC config", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - orgSlug: z.string().trim() + organizationId: z.string().trim().describe(OidcSSo.GET_CONFIG.organizationId) }), response: { 200: SanitizedOidcConfigSchema.pick({ @@ -180,9 +189,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { orgSlug } = req.query; const oidc = await server.services.oidc.getOidc({ - orgSlug, + organizationId: req.query.organizationId, type: "external", actor: req.permission.type, actorId: req.permission.id, @@ -200,8 +208,16 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.OidcSso], + description: "Update OIDC config", + security: [ + { + bearerAuth: [] + } + ], body: z .object({ allowedEmailDomains: z @@ -216,22 +232,26 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { .split(",") .map((id) => id.trim()) .join(", "); - }), - discoveryURL: z.string().trim(), - configurationType: z.nativeEnum(OIDCConfigurationType), - issuer: z.string().trim(), - authorizationEndpoint: z.string().trim(), - jwksUri: z.string().trim(), - tokenEndpoint: z.string().trim(), - userinfoEndpoint: z.string().trim(), - clientId: z.string().trim(), - clientSecret: z.string().trim(), - isActive: z.boolean(), - manageGroupMemberships: z.boolean().optional(), - jwtSignatureAlgorithm: z.nativeEnum(OIDCJWTSignatureAlgorithm).optional() + }) + .describe(OidcSSo.UPDATE_CONFIG.allowedEmailDomains), + discoveryURL: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.discoveryURL), + configurationType: z.nativeEnum(OIDCConfigurationType).describe(OidcSSo.UPDATE_CONFIG.configurationType), + issuer: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.issuer), + authorizationEndpoint: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.authorizationEndpoint), + jwksUri: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.jwksUri), + tokenEndpoint: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.tokenEndpoint), + userinfoEndpoint: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.userinfoEndpoint), + clientId: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.clientId), + clientSecret: z.string().trim().describe(OidcSSo.UPDATE_CONFIG.clientSecret), + isActive: z.boolean().describe(OidcSSo.UPDATE_CONFIG.isActive), + manageGroupMemberships: z.boolean().optional().describe(OidcSSo.UPDATE_CONFIG.manageGroupMemberships), + jwtSignatureAlgorithm: z + .nativeEnum(OIDCJWTSignatureAlgorithm) + .optional() + .describe(OidcSSo.UPDATE_CONFIG.jwtSignatureAlgorithm) }) .partial() - .merge(z.object({ orgSlug: z.string() })), + .merge(z.object({ organizationId: z.string().describe(OidcSSo.UPDATE_CONFIG.organizationId) })), response: { 200: SanitizedOidcConfigSchema.pick({ id: true, @@ -267,8 +287,16 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.OidcSso], + description: "Create OIDC config", + security: [ + { + bearerAuth: [] + } + ], body: z .object({ allowedEmailDomains: z @@ -283,23 +311,34 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { .split(",") .map((id) => id.trim()) .join(", "); - }), - configurationType: z.nativeEnum(OIDCConfigurationType), - issuer: z.string().trim().optional().default(""), - discoveryURL: z.string().trim().optional().default(""), - authorizationEndpoint: z.string().trim().optional().default(""), - jwksUri: z.string().trim().optional().default(""), - tokenEndpoint: z.string().trim().optional().default(""), - userinfoEndpoint: z.string().trim().optional().default(""), - clientId: z.string().trim(), - clientSecret: z.string().trim(), - isActive: z.boolean(), - orgSlug: z.string().trim(), - manageGroupMemberships: z.boolean().optional().default(false), + }) + .describe(OidcSSo.CREATE_CONFIG.allowedEmailDomains), + configurationType: z.nativeEnum(OIDCConfigurationType).describe(OidcSSo.CREATE_CONFIG.configurationType), + issuer: z.string().trim().optional().default("").describe(OidcSSo.CREATE_CONFIG.issuer), + discoveryURL: z.string().trim().optional().default("").describe(OidcSSo.CREATE_CONFIG.discoveryURL), + authorizationEndpoint: z + .string() + .trim() + .optional() + .default("") + .describe(OidcSSo.CREATE_CONFIG.authorizationEndpoint), + jwksUri: z.string().trim().optional().default("").describe(OidcSSo.CREATE_CONFIG.jwksUri), + tokenEndpoint: z.string().trim().optional().default("").describe(OidcSSo.CREATE_CONFIG.tokenEndpoint), + userinfoEndpoint: z.string().trim().optional().default("").describe(OidcSSo.CREATE_CONFIG.userinfoEndpoint), + clientId: z.string().trim().describe(OidcSSo.CREATE_CONFIG.clientId), + clientSecret: z.string().trim().describe(OidcSSo.CREATE_CONFIG.clientSecret), + isActive: z.boolean().describe(OidcSSo.CREATE_CONFIG.isActive), + organizationId: z.string().trim().describe(OidcSSo.CREATE_CONFIG.organizationId), + manageGroupMemberships: z + .boolean() + .optional() + .default(false) + .describe(OidcSSo.CREATE_CONFIG.manageGroupMemberships), jwtSignatureAlgorithm: z .nativeEnum(OIDCJWTSignatureAlgorithm) .optional() .default(OIDCJWTSignatureAlgorithm.RS256) + .describe(OidcSSo.CREATE_CONFIG.jwtSignatureAlgorithm) }) .superRefine((data, ctx) => { if (data.configurationType === OIDCConfigurationType.CUSTOM) { diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index ab9d1be6c..8d8cc4817 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -111,15 +111,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { params: z.object({ workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId) }), - querystring: z.object({ - eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), - userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), - startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), - endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), - offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), - limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), - actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) - }), + querystring: z + .object({ + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }) + .superRefine((el, ctx) => { + if (el.endDate && el.startDate) { + const startDate = new Date(el.startDate); + const endDate = new Date(el.endDate); + const maxAllowedDate = new Date(startDate); + maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); + if (endDate < startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); + } + if (endDate > maxAllowedDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "Dates must be within 3 months" + }); + } + } + }), response: { 200: z.object({ auditLogs: AuditLogsSchema.omit({ @@ -161,7 +184,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { filter: { ...req.query, projectId: req.params.workspaceId, - endDate: req.query.endDate, + endDate: req.query.endDate || new Date().toISOString(), startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActorId: req.query.actor, eventType: req.query.eventType ? [req.query.eventType] : undefined diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index c8395d608..f8e371d01 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -13,6 +13,7 @@ import { FastifyRequest } from "fastify"; import { z } from "zod"; import { SamlProviders, TGetSamlCfgDTO } from "@app/ee/services/saml-config/saml-config-types"; +import { ApiDocsTags, SamlSso } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -149,8 +150,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { firstName, lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, - authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string, - orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string, + authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider, + orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, metadata: userMetadata }); cb(null, { isUserCompleted, providerAuthToken }); @@ -262,25 +263,31 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { config: { rateLimit: readLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SamlSso], + description: "Get SAML config", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - organizationId: z.string().trim() + organizationId: z.string().trim().describe(SamlSso.GET_CONFIG.organizationId) }), response: { - 200: z - .object({ - id: z.string(), - organization: z.string(), - orgId: z.string(), - authProvider: z.string(), - isActive: z.boolean(), - entryPoint: z.string(), - issuer: z.string(), - cert: z.string(), - lastUsed: z.date().nullable().optional() - }) - .optional() + 200: z.object({ + id: z.string(), + organization: z.string(), + orgId: z.string(), + authProvider: z.string(), + isActive: z.boolean(), + entryPoint: z.string(), + issuer: z.string(), + cert: z.string(), + lastUsed: z.date().nullable().optional() + }) } }, handler: async (req) => { @@ -302,15 +309,23 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SamlSso], + description: "Create SAML config", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - organizationId: z.string(), - authProvider: z.nativeEnum(SamlProviders), - isActive: z.boolean(), - entryPoint: z.string(), - issuer: z.string(), - cert: z.string() + organizationId: z.string().trim().describe(SamlSso.CREATE_CONFIG.organizationId), + authProvider: z.nativeEnum(SamlProviders).describe(SamlSso.CREATE_CONFIG.authProvider), + isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive), + entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint), + issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer), + cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert) }), response: { 200: SanitizedSamlConfigSchema @@ -341,18 +356,26 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SamlSso], + description: "Update SAML config", + security: [ + { + bearerAuth: [] + } + ], body: z .object({ - authProvider: z.nativeEnum(SamlProviders), - isActive: z.boolean(), - entryPoint: z.string(), - issuer: z.string(), - cert: z.string() + authProvider: z.nativeEnum(SamlProviders).describe(SamlSso.UPDATE_CONFIG.authProvider), + isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive), + entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint), + issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer), + cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert) }) .partial() - .merge(z.object({ organizationId: z.string() })), + .merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })), response: { 200: SanitizedSamlConfigSchema } diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index ebe1345b3..46b2544b2 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -23,10 +23,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi environment: z.string(), secretPath: z .string() - .optional() - .nullable() - .default("/") - .transform((val) => (val ? removeTrailingSlash(val) : val)), + .min(1, { message: "Secret path cannot be empty" }) + .transform((val) => removeTrailingSlash(val)), approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), @@ -100,10 +98,10 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi approvals: z.number().min(1).default(1), secretPath: z .string() + .trim() + .min(1, { message: "Secret path cannot be empty" }) .optional() - .nullable() - .transform((val) => (val ? removeTrailingSlash(val) : val)) - .transform((val) => (val === "" ? "/" : val)), + .transform((val) => (val ? removeTrailingSlash(val) : undefined)), enforcementLevel: z.nativeEnum(EnforcementLevel).optional(), allowedSelfApprovals: z.boolean().default(true) }), 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 d53124e52..66f6708a0 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -141,14 +141,39 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { approval } = await server.services.secretApprovalRequest.mergeSecretApprovalRequest({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - approvalId: req.params.id, - bypassReason: req.body.bypassReason + const { approval, projectId, secretMutationEvents } = + await server.services.secretApprovalRequest.mergeSecretApprovalRequest({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + approvalId: req.params.id, + bypassReason: req.body.bypassReason + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.SECRET_APPROVAL_MERGED, + metadata: { + mergedBy: req.permission.id, + secretApprovalRequestSlug: approval.slug, + secretApprovalRequestId: approval.id + } + } }); + + for await (const event of secretMutationEvents) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event + }); + } + return { approval }; } }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/bitbucket-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/bitbucket-secret-scanning-router.ts new file mode 100644 index 000000000..21fd4119b --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/bitbucket-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + BitbucketDataSourceSchema, + CreateBitbucketDataSourceSchema, + UpdateBitbucketDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/bitbucket"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerBitbucketSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.Bitbucket, + server, + responseSchema: BitbucketDataSourceSchema, + createSchema: CreateBitbucketDataSourceSchema, + updateSchema: UpdateBitbucketDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts index 703529947..2258f9c82 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -1,5 +1,6 @@ import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { registerBitbucketSecretScanningRouter } from "./bitbucket-secret-scanning-router"; import { registerGitHubSecretScanningRouter } from "./github-secret-scanning-router"; export * from "./secret-scanning-v2-router"; @@ -8,5 +9,6 @@ export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< SecretScanningDataSource, (server: FastifyZodProvider) => Promise > = { - [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter + [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter, + [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter }; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 929a60df5..0a672437d 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { SecretScanningConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { BitbucketDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; import { SecretScanningFindingStatus, @@ -21,7 +22,10 @@ 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"; -const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [GitHubDataSourceListItemSchema]); +const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [ + GitHubDataSourceListItemSchema, + BitbucketDataSourceListItemSchema +]); export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { server.route({ 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 9fa48ca15..995534f8f 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 @@ -53,7 +53,7 @@ export interface TAccessApprovalPolicyDALFactory envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -93,7 +93,7 @@ export interface TAccessApprovalPolicyDALFactory envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -116,7 +116,7 @@ export interface TAccessApprovalPolicyDALFactory envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; }>; findLastValidPolicy: ( @@ -138,7 +138,7 @@ export interface TAccessApprovalPolicyDALFactory envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; } | undefined @@ -190,7 +190,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; }>; deleteAccessApprovalPolicy: ({ @@ -214,7 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -252,7 +252,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; }>; getAccessApprovalPolicyByProjectSlug: ({ @@ -286,7 +286,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -337,7 +337,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; 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 5976f5fff..fa487d0b7 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 @@ -60,6 +60,26 @@ export const accessApprovalPolicyServiceFactory = ({ accessApprovalRequestReviewerDAL, orgMembershipDAL }: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => { + const $policyExists = async ({ + envId, + secretPath, + policyId + }: { + envId: string; + secretPath: string; + policyId?: string; + }) => { + const policy = await accessApprovalPolicyDAL + .findOne({ + envId, + secretPath, + deletedAt: null + }) + .catch(() => null); + + return policyId ? policy && policy.id !== policyId : Boolean(policy); + }; + const createAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["createAccessApprovalPolicy"] = async ({ name, actor, @@ -106,6 +126,12 @@ export const accessApprovalPolicyServiceFactory = ({ const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); if (!env) throw new NotFoundError({ message: `Environment with slug '${environment}' not found` }); + if (await $policyExists({ envId: env.id, secretPath })) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${environment}'` + }); + } + let approverUserIds = userApprovers; if (userApproverNames.length) { const approverUsersInDB = await userDAL.find({ @@ -279,7 +305,11 @@ export const accessApprovalPolicyServiceFactory = ({ ) as { username: string; sequence?: number }[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); - if (!accessApprovalPolicy) throw new BadRequestError({ message: "Approval policy not found" }); + if (!accessApprovalPolicy) { + throw new NotFoundError({ + message: `Access approval policy with ID '${policyId}' not found` + }); + } const currentApprovals = approvals || accessApprovalPolicy.approvals; if ( @@ -290,9 +320,18 @@ export const accessApprovalPolicyServiceFactory = ({ throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } - if (!accessApprovalPolicy) { - throw new NotFoundError({ message: `Secret approval policy with ID '${policyId}' not found` }); + if ( + await $policyExists({ + envId: accessApprovalPolicy.envId, + secretPath: secretPath || accessApprovalPolicy.secretPath, + policyId: accessApprovalPolicy.id + }) + ) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${accessApprovalPolicy.environment.slug}'` + }); } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 6806c7123..f3f195914 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -122,7 +122,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; }>; deleteAccessApprovalPolicy: ({ @@ -146,7 +146,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -218,7 +218,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; @@ -269,7 +269,7 @@ export interface TAccessApprovalPolicyServiceFactory { envId: string; enforcementLevel: string; allowedSelfApprovals: boolean; - secretPath?: string | null | undefined; + secretPath: string; deletedAt?: Date | null | undefined; environment: { id: string; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 874460b36..2df779795 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -30,10 +30,10 @@ type TFindQuery = { actor?: string; projectId?: string; environment?: string; - orgId?: string; + orgId: string; eventType?: string; - startDate?: string; - endDate?: string; + startDate: string; + endDate: string; userAgentType?: string; limit?: number; offset?: number; @@ -61,18 +61,15 @@ export const auditLogDALFactory = (db: TDbClient) => { }, tx ) => { - if (!orgId && !projectId) { - throw new Error("Either orgId or projectId must be provided"); - } - try { // Find statements const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog) + .where(`${TableName.AuditLog}.orgId`, orgId) + .whereRaw(`"${TableName.AuditLog}"."createdAt" >= ?::timestamptz`, [startDate]) + .andWhereRaw(`"${TableName.AuditLog}"."createdAt" < ?::timestamptz`, [endDate]) // eslint-disable-next-line func-names .where(function () { - if (orgId) { - void this.where(`${TableName.AuditLog}.orgId`, orgId); - } else if (projectId) { + if (projectId) { void this.where(`${TableName.AuditLog}.projectId`, projectId); } }); @@ -135,14 +132,6 @@ export const auditLogDALFactory = (db: TDbClient) => { void sqlQuery.whereIn("eventType", eventType); } - // Filter by date range - if (startDate) { - void sqlQuery.whereRaw(`"${TableName.AuditLog}"."createdAt" >= ?::timestamptz`, [startDate]); - } - if (endDate) { - void sqlQuery.whereRaw(`"${TableName.AuditLog}"."createdAt" <= ?::timestamptz`, [endDate]); - } - // we timeout long running queries to prevent DB resource issues (2 minutes) const docs = await sqlQuery.timeout(1000 * 120); @@ -174,6 +163,8 @@ export const auditLogDALFactory = (db: TDbClient) => { try { const findExpiredLogSubQuery = (tx || db)(TableName.AuditLog) .where("expiresAt", "<", today) + .where("createdAt", "<", today) // to use audit log partition + .orderBy(`${TableName.AuditLog}.createdAt`, "desc") .select("id") .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index bf00a499a..333847734 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -67,7 +67,8 @@ export const auditLogServiceFactory = ({ secretPath: filter.secretPath, secretKey: filter.secretKey, environment: filter.environment, - ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) + orgId: actorOrgId, + ...(filter.projectId ? { projectId: filter.projectId } : {}) }); return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ 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 661c7ee49..e54649cbe 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -56,8 +56,8 @@ export type TListProjectAuditLogDTO = { eventType?: EventType[]; offset?: number; limit: number; - endDate?: string; - startDate?: string; + endDate: string; + startDate: string; projectId?: string; environment?: string; auditLogActorId?: string; @@ -116,6 +116,15 @@ interface BaseAuthData { userAgentType?: UserAgentType; } +export enum SecretApprovalEvent { + Create = "create", + Update = "update", + Delete = "delete", + CreateMany = "create-many", + UpdateMany = "update-many", + DeleteMany = "delete-many" +} + export enum UserAgentType { WEB = "web", CLI = "cli", @@ -1709,6 +1718,17 @@ interface SecretApprovalRequest { committedBy: string; secretApprovalRequestSlug: string; secretApprovalRequestId: string; + eventType: SecretApprovalEvent; + secretKey?: string; + secretId?: string; + secrets?: { + secretKey?: string; + secretId?: string; + environment?: string; + secretPath?: string; + }[]; + environment: string; + secretPath: string; }; } diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index ec75bb2e4..329715941 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -21,7 +21,7 @@ import { randomUUID } from "crypto"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; @@ -81,6 +81,21 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return client; } + if (providerInputs.method === AwsIamAuthType.IRSA) { + // Allow instances to disable automatic service account token fetching (e.g. for shared cloud) + if (!appCfg.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN) { + throw new UnauthorizedError({ + message: "Failed to get AWS credentials via IRSA: KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN is not enabled." + }); + } + + // The SDK will automatically pick up credentials from the environment + const client = new IAMClient({ + region: providerInputs.region + }); + return client; + } + const client = new IAMClient({ region: providerInputs.region, credentials: { @@ -101,7 +116,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { .catch((err) => { const message = (err as Error)?.message; if ( - providerInputs.method === AwsIamAuthType.AssumeRole && + (providerInputs.method === AwsIamAuthType.AssumeRole || providerInputs.method === AwsIamAuthType.IRSA) && // assume role will throw an error asking to provider username, but if so this has access in aws correctly message.includes("Must specify userName when calling with non-User credentials") ) { diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index f6fa2a4a8..528ea414a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -28,7 +28,8 @@ export enum SqlProviders { export enum AwsIamAuthType { AssumeRole = "assume-role", - AccessKey = "access-key" + AccessKey = "access-key", + IRSA = "irsa" } export enum ElasticSearchAuthTypes { @@ -221,6 +222,16 @@ export const DynamicSecretAwsIamSchema = z.preprocess( userGroups: z.string().trim().optional(), policyArns: z.string().trim().optional(), tags: ResourceMetadataSchema.optional() + }), + z.object({ + method: z.literal(AwsIamAuthType.IRSA), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional(), + tags: ResourceMetadataSchema.optional() }) ]) ); diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index a5088bde5..1a3374035 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -107,34 +107,26 @@ export const oidcConfigServiceFactory = ({ kmsService }: TOidcConfigServiceFactoryDep) => { const getOidc = async (dto: TGetOidcCfgDTO) => { - const org = await orgDAL.findOne({ slug: dto.orgSlug }); - if (!org) { + const oidcCfg = await oidcConfigDAL.findOne({ + orgId: dto.organizationId + }); + if (!oidcCfg) { throw new NotFoundError({ - message: `Organization with slug '${dto.orgSlug}' not found`, - name: "OrgNotFound" + message: `OIDC configuration for organization with ID '${dto.organizationId}' not found` }); } + if (dto.type === "external") { const { permission } = await permissionService.getOrgPermission( dto.actor, dto.actorId, - org.id, + dto.organizationId, dto.actorAuthMethod, dto.actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); } - const oidcCfg = await oidcConfigDAL.findOne({ - orgId: org.id - }); - - if (!oidcCfg) { - throw new NotFoundError({ - message: `OIDC configuration for organization with slug '${dto.orgSlug}' not found` - }); - } - const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: oidcCfg.orgId @@ -465,7 +457,7 @@ export const oidcConfigServiceFactory = ({ }; const updateOidcCfg = async ({ - orgSlug, + organizationId, allowedEmailDomains, configurationType, discoveryURL, @@ -484,13 +476,11 @@ export const oidcConfigServiceFactory = ({ manageGroupMemberships, jwtSignatureAlgorithm }: TUpdateOidcCfgDTO) => { - const org = await orgDAL.findOne({ - slug: orgSlug - }); + const org = await orgDAL.findOne({ id: organizationId }); if (!org) { throw new NotFoundError({ - message: `Organization with slug '${orgSlug}' not found` + message: `Organization with ID '${organizationId}' not found` }); } @@ -555,7 +545,7 @@ export const oidcConfigServiceFactory = ({ }; const createOidcCfg = async ({ - orgSlug, + organizationId, allowedEmailDomains, configurationType, discoveryURL, @@ -574,12 +564,10 @@ export const oidcConfigServiceFactory = ({ manageGroupMemberships, jwtSignatureAlgorithm }: TCreateOidcCfgDTO) => { - const org = await orgDAL.findOne({ - slug: orgSlug - }); + const org = await orgDAL.findOne({ id: organizationId }); if (!org) { throw new NotFoundError({ - message: `Organization with slug '${orgSlug}' not found` + message: `Organization with ID '${organizationId}' not found` }); } @@ -639,7 +627,7 @@ export const oidcConfigServiceFactory = ({ const oidcCfg = await getOidc({ type: "internal", - orgSlug + organizationId: org.id }); if (!oidcCfg || !oidcCfg.isActive) { diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index c56427e63..f38f2172f 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -26,11 +26,11 @@ export type TOidcLoginDTO = { export type TGetOidcCfgDTO = | ({ type: "external"; - orgSlug: string; + organizationId: string; } & TGenericPermission) | { type: "internal"; - orgSlug: string; + organizationId: string; }; export type TCreateOidcCfgDTO = { @@ -45,7 +45,7 @@ export type TCreateOidcCfgDTO = { clientId: string; clientSecret: string; isActive: boolean; - orgSlug: string; + organizationId: string; manageGroupMemberships: boolean; jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm; } & TGenericPermission; @@ -62,7 +62,7 @@ export type TUpdateOidcCfgDTO = Partial<{ clientId: string; clientSecret: string; isActive: boolean; - orgSlug: string; + organizationId: string; manageGroupMemberships: boolean; jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm; }> & diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index c81fd518b..5430b0afc 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -148,10 +148,18 @@ export const samlConfigServiceFactory = ({ let samlConfig: TSamlConfigs | undefined; if (dto.type === "org") { samlConfig = await samlConfigDAL.findOne({ orgId: dto.orgId }); - if (!samlConfig) return; + if (!samlConfig) { + throw new NotFoundError({ + message: `SAML configuration for organization with ID '${dto.orgId}' not found` + }); + } } else if (dto.type === "orgSlug") { const org = await orgDAL.findOne({ slug: dto.orgSlug }); - if (!org) return; + if (!org) { + throw new NotFoundError({ + message: `Organization with slug '${dto.orgSlug}' not found` + }); + } samlConfig = await samlConfigDAL.findOne({ orgId: org.id }); } else if (dto.type === "ssoId") { // TODO: diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index a9bd8f485..f4ede04fa 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -61,20 +61,17 @@ export type TSamlLoginDTO = { export type TSamlConfigServiceFactory = { createSamlCfg: (arg: TCreateSamlCfgDTO) => Promise; updateSamlCfg: (arg: TUpdateSamlCfgDTO) => Promise; - getSaml: (arg: TGetSamlCfgDTO) => Promise< - | { - id: string; - organization: string; - orgId: string; - authProvider: string; - isActive: boolean; - entryPoint: string; - issuer: string; - cert: string; - lastUsed: Date | null | undefined; - } - | undefined - >; + getSaml: (arg: TGetSamlCfgDTO) => Promise<{ + id: string; + organization: string; + orgId: string; + authProvider: string; + isActive: boolean; + entryPoint: string; + issuer: string; + cert: string; + lastUsed: Date | null | undefined; + }>; samlLogin: (arg: TSamlLoginDTO) => Promise<{ isUserCompleted: boolean; providerAuthToken: string; 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 cb497aa7d..80127c071 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 @@ -55,6 +55,26 @@ export const secretApprovalPolicyServiceFactory = ({ licenseService, secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { + const $policyExists = async ({ + envId, + secretPath, + policyId + }: { + envId: string; + secretPath: string; + policyId?: string; + }) => { + const policy = await secretApprovalPolicyDAL + .findOne({ + envId, + secretPath, + deletedAt: null + }) + .catch(() => null); + + return policyId ? policy && policy.id !== policyId : Boolean(policy); + }; + const createSecretApprovalPolicy = async ({ name, actor, @@ -106,10 +126,17 @@ export const secretApprovalPolicyServiceFactory = ({ } const env = await projectEnvDAL.findOne({ slug: environment, projectId }); - if (!env) + if (!env) { throw new NotFoundError({ message: `Environment with slug '${environment}' not found in project with ID ${projectId}` }); + } + + if (await $policyExists({ envId: env.id, secretPath })) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${environment}'` + }); + } let groupBypassers: string[] = []; let bypasserUserIds: string[] = []; @@ -260,6 +287,18 @@ export const secretApprovalPolicyServiceFactory = ({ }); } + if ( + await $policyExists({ + envId: secretApprovalPolicy.envId, + secretPath: secretPath || secretApprovalPolicy.secretPath, + policyId: secretApprovalPolicy.id + }) + ) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${secretApprovalPolicy.environment.slug}'` + }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index ed074336c..ba5334e5c 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -4,7 +4,7 @@ import { ApproverType, BypasserType } from "../access-approval-policy/access-app export type TCreateSapDTO = { approvals: number; - secretPath?: string | null; + secretPath: string; environment: string; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; bypassers?: ( @@ -20,7 +20,7 @@ export type TCreateSapDTO = { export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; - secretPath?: string | null; + secretPath?: string; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; bypassers?: ( | { type: BypasserType.Group; id: string } 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 c0242f8e7..101885fc5 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 @@ -10,6 +10,7 @@ import { TSecretApprovalRequestsSecretsInsert, TSecretApprovalRequestsSecretsV2Insert } from "@app/db/schemas"; +import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -523,7 +524,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { policy, folderId, projectId, bypassers } = secretApprovalRequest; + const { policy, folderId, projectId, bypassers, environment } = secretApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this secret approval request has been deleted." @@ -957,7 +958,112 @@ export const secretApprovalRequestServiceFactory = ({ }); } - return mergeStatus; + const { created, updated, deleted } = mergeStatus.secrets; + + const secretMutationEvents: Event[] = []; + + if (created.length) { + if (created.length > 1) { + secretMutationEvents.push({ + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath: folder.path, + secrets: created.map((secret) => ({ + secretId: secret.id, + secretVersion: 1, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string, + // @ts-expect-error not present on v1 secrets + secretMetadata: secret.secretMetadata as ResourceMetadataDTO + })) + } + }); + } else { + const [secret] = created; + secretMutationEvents.push({ + type: EventType.CREATE_SECRET, + metadata: { + environment, + secretPath: folder.path, + secretId: secret.id, + secretVersion: 1, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string, + // @ts-expect-error not present on v1 secrets + secretMetadata: secret.secretMetadata as ResourceMetadataDTO + } + }); + } + } + + if (updated.length) { + if (updated.length > 1) { + secretMutationEvents.push({ + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath: folder.path, + secrets: updated.map((secret) => ({ + secretId: secret.id, + secretVersion: secret.version, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string, + // @ts-expect-error not present on v1 secrets + secretMetadata: secret.secretMetadata as ResourceMetadataDTO + })) + } + }); + } else { + const [secret] = updated; + secretMutationEvents.push({ + type: EventType.UPDATE_SECRET, + metadata: { + environment, + secretPath: folder.path, + secretId: secret.id, + secretVersion: secret.version, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string, + // @ts-expect-error not present on v1 secrets + secretMetadata: secret.secretMetadata as ResourceMetadataDTO + } + }); + } + } + + if (deleted.length) { + if (deleted.length > 1) { + secretMutationEvents.push({ + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath: folder.path, + secrets: deleted.map((secret) => ({ + secretId: secret.id, + secretVersion: secret.version, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string + })) + } + }); + } else { + const [secret] = deleted; + secretMutationEvents.push({ + type: EventType.DELETE_SECRET, + metadata: { + environment, + secretPath: folder.path, + secretId: secret.id, + secretVersion: secret.version, + // @ts-expect-error not present on v1 secrets + secretKey: secret.key as string + } + }); + } + } + + return { ...mergeStatus, projectId, secretMutationEvents }; }; // function to save secret change to secret approval diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-constants.ts new file mode 100644 index 000000000..80d22c64b --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "Bitbucket", + type: SecretScanningDataSource.Bitbucket, + connection: AppConnection.Bitbucket +}; diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts new file mode 100644 index 000000000..f51c75daa --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts @@ -0,0 +1,314 @@ +import { join } from "path"; + +import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryPostInitialization, + TSecretScanningFactoryTeardown +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { BasicRepositoryRegex } from "@app/lib/regex"; +import { + getBitbucketUser, + listBitbucketRepositories, + TBitbucketConnection +} from "@app/services/app-connection/bitbucket"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { + TBitbucketDataSourceCredentials, + TBitbucketDataSourceInput, + TBitbucketDataSourceWithConnection, + TQueueBitbucketResourceDiffScan +} from "./bitbucket-secret-scanning-types"; + +export const BitbucketSecretScanningFactory = () => { + const initialize: TSecretScanningFactoryInitialize< + TBitbucketDataSourceInput, + TBitbucketConnection, + TBitbucketDataSourceCredentials + > = async ({ connection, payload }, callback) => { + const cfg = getConfig(); + + const { email, apiToken } = connection.credentials; + const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`; + + const { data } = await request.post<{ uuid: string }>( + `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${encodeURIComponent(payload.config.workspaceSlug)}/hooks`, + { + description: "Infisical webhook for push events", + url: `${cfg.SITE_URL}/secret-scanning/webhooks/bitbucket`, + active: false, + events: ["repo:push"] + }, + { + headers: { + Authorization: authHeader, + Accept: "application/json" + } + } + ); + + return callback({ + credentials: { webhookId: data.uuid, webhookSecret: alphaNumericNanoId(64) } + }); + }; + + const postInitialization: TSecretScanningFactoryPostInitialization< + TBitbucketDataSourceInput, + TBitbucketConnection, + TBitbucketDataSourceCredentials + > = async ({ dataSourceId, credentials, connection, payload }) => { + const { email, apiToken } = connection.credentials; + const { webhookId, webhookSecret } = credentials; + + const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`; + + const cfg = getConfig(); + const newWebhookUrl = `${cfg.SITE_URL}/secret-scanning/webhooks/bitbucket?dataSourceId=${dataSourceId}`; + + await request.put( + `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${encodeURIComponent(payload.config.workspaceSlug)}/hooks/${webhookId}`, + { + description: "Infisical webhook for push events", + url: newWebhookUrl, + active: true, + events: ["repo:push"], + secret: webhookSecret + }, + { + headers: { + Authorization: authHeader, + Accept: "application/json" + } + } + ); + }; + + const teardown: TSecretScanningFactoryTeardown< + TBitbucketDataSourceWithConnection, + TBitbucketDataSourceCredentials + > = async ({ credentials, dataSource }) => { + const { + connection: { + credentials: { email, apiToken } + }, + config + } = dataSource; + const { webhookId } = credentials; + + const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`; + + try { + await request.delete( + `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${config.workspaceSlug}/hooks/${webhookId}`, + { + headers: { + Authorization: authHeader, + Accept: "application/json" + } + } + ); + } catch (err) { + logger.error(`teardown: Bitbucket - Failed to call delete on webhook [webhookId=${webhookId}]`); + } + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { + connection, + config: { includeRepos, workspaceSlug } + } = dataSource; + + const repos = await listBitbucketRepositories(connection, workspaceSlug); + + const filteredRepos: typeof repos = []; + if (includeRepos.includes("*")) { + filteredRepos.push(...repos); + } else { + filteredRepos.push(...repos.filter((repo) => includeRepos.includes(repo.full_name))); + } + + return filteredRepos.map(({ full_name, uuid }) => ({ + name: full_name, + externalId: uuid, + type: SecretScanningResource.Repository + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const { + connection: { + credentials: { apiToken, email } + } + } = dataSource; + + const repoPath = join(tempFolder, "repo.git"); + + if (!BasicRepositoryRegex.test(resourceName)) { + throw new Error("Invalid Bitbucket repository name"); + } + + const { username } = await getBitbucketUser({ email, apiToken }); + + await cloneRepository({ + cloneUrl: `https://${encodeURIComponent(username)}:${apiToken}@bitbucket.org/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueBitbucketResourceDiffScan["payload"] + > = ({ repository }) => { + return { + name: repository.full_name, + externalId: repository.uuid, + type: SecretScanningResource.Repository + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TBitbucketDataSourceWithConnection, + TQueueBitbucketResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const { + connection: { + credentials: { apiToken, email } + } + } = dataSource; + + const { push, repository } = payload; + + const allFindings: SecretMatch[] = []; + + const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`; + + for (const change of push.changes) { + for (const commit of change.commits) { + // eslint-disable-next-line no-await-in-loop + const { data: diffstat } = await request.get<{ + values: { + status: "added" | "modified" | "removed" | "renamed"; + new?: { path: string }; + old?: { path: string }; + }[]; + }>(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${repository.full_name}/diffstat/${commit.hash}`, { + headers: { + Authorization: authHeader, + Accept: "application/json" + } + }); + + // eslint-disable-next-line no-continue + if (!diffstat.values) continue; + + for (const file of diffstat.values) { + if ((file.status === "added" || file.status === "modified") && file.new?.path) { + const filePath = file.new.path; + + // eslint-disable-next-line no-await-in-loop + const { data: patch } = await request.get( + `https://api.bitbucket.org/2.0/repositories/${repository.full_name}/diff/${commit.hash}`, + { + params: { + path: filePath + }, + headers: { + Authorization: authHeader + }, + responseType: "text" + } + ); + + // eslint-disable-next-line no-continue + if (!patch) continue; + + // eslint-disable-next-line no-await-in-loop + const findings = await scanContentAndGetFindings(replaceNonChangesWithNewlines(`\n${patch}`), configPath); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(patch, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(patch, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + const authorName = commit.author.user?.display_name || commit.author.raw.split(" <")[0]; + const emailMatch = commit.author.raw.match(/<(.*)>/); + const authorEmail = emailMatch?.[1] ?? ""; + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: filePath, + Commit: commit.hash, + Author: authorName, + Email: authorEmail, + Message: commit.message, + Fingerprint: `${commit.hash}:${filePath}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.date, + Link: `https://bitbucket.org/${resourceName}/src/${commit.hash}/${filePath}#lines-${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + return { + initialize, + postInitialization, + listRawResources, + getFullScanPath, + getDiffScanResourcePayload, + getDiffScanFindingsPayload, + teardown + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-schemas.ts new file mode 100644 index 000000000..36d036f15 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-schemas.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { BasicRepositoryRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const BitbucketDataSourceConfigSchema = z.object({ + workspaceSlug: z + .string() + .min(1, "Workspace slug required") + .max(128) + .describe(SecretScanningDataSources.CONFIG.BITBUCKET.workspaceSlug), + includeRepos: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || BasicRepositoryRegex.test(value), "Invalid repository name format") + ) + .nonempty("One or more repositories required") + .max(100, "Cannot configure more than 100 repositories") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.BITBUCKET.includeRepos) +}); + +export const BitbucketDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.Bitbucket, + isConnectionRequired: true +}) + .extend({ + config: BitbucketDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "Bitbucket" + }) + ); + +export const CreateBitbucketDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.Bitbucket, + isConnectionRequired: true +}) + .extend({ + config: BitbucketDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "Bitbucket" + }) + ); + +export const UpdateBitbucketDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema( + SecretScanningDataSource.Bitbucket +) + .extend({ + config: BitbucketDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "Bitbucket" + }) + ); + +export const BitbucketDataSourceListItemSchema = z + .object({ + name: z.literal("Bitbucket"), + connection: z.literal(AppConnection.Bitbucket), + type: z.literal(SecretScanningDataSource.Bitbucket) + }) + .describe( + JSON.stringify({ + title: "Bitbucket" + }) + ); + +export const BitbucketFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Repository), + dataSourceType: z.literal(SecretScanningDataSource.Bitbucket), + details: GitRepositoryScanFindingDetailsSchema +}); + +export const BitbucketDataSourceCredentialsSchema = z.object({ + webhookId: z.string(), + webhookSecret: z.string() +}); diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-service.ts new file mode 100644 index 000000000..c5a0aedd6 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-service.ts @@ -0,0 +1,104 @@ +import crypto from "crypto"; + +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { + TBitbucketDataSource, + TBitbucketDataSourceCredentials, + TBitbucketPushEvent +} from "./bitbucket-secret-scanning-types"; + +export const bitbucketSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick, + kmsService: Pick +) => { + const handlePushEvent = async ( + payload: TBitbucketPushEvent & { dataSourceId: string; receivedSignature: string; bodyString: string } + ) => { + const { push, repository, bodyString, receivedSignature } = payload; + + if (!push?.changes?.length || !repository?.workspace?.uuid) { + logger.warn( + `secretScanningV2PushEvent: Bitbucket - Insufficient data [changes=${ + push?.changes?.length ?? 0 + }] [repository=${repository?.name}] [workspaceUuid=${repository?.workspace?.uuid}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + id: payload.dataSourceId, + type: SecretScanningDataSource.Bitbucket + })) as TBitbucketDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: Bitbucket - Could not find data source [workspaceUuid=${repository.workspace.uuid}]` + ); + return; + } + + const { + isAutoScanEnabled, + config: { includeRepos }, + encryptedCredentials, + projectId + } = dataSource; + + if (!encryptedCredentials) { + logger.info( + `secretScanningV2PushEvent: Bitbucket - Could not find encrypted credentials [dataSourceId=${dataSource.id}] [workspaceUuid=${repository.workspace.uuid}]` + ); + return; + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedCredentials = decryptor({ cipherTextBlob: encryptedCredentials }); + + const credentials = JSON.parse(decryptedCredentials.toString()) as TBitbucketDataSourceCredentials; + + const hmac = crypto.createHmac("sha256", credentials.webhookSecret); + hmac.update(bodyString); + const calculatedSignature = hmac.digest("hex"); + + if (calculatedSignature !== receivedSignature) { + logger.error( + `secretScanningV2PushEvent: Bitbucket - Invalid signature for webhook [dataSourceId=${dataSource.id}] [workspaceUuid=${repository.workspace.uuid}]` + ); + return; + } + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: Bitbucket - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [workspaceUuid=${repository.workspace.uuid}]` + ); + return; + } + + if (includeRepos.includes("*") || includeRepos.includes(repository.full_name)) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.Bitbucket, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: Bitbucket - ignoring due to repository not being present in config [workspaceUuid=${repository.workspace.uuid}] [dataSourceId=${dataSource.id}]` + ); + } + }; + + return { + handlePushEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types.ts new file mode 100644 index 000000000..03e3f8113 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TBitbucketConnection } from "@app/services/app-connection/bitbucket"; + +import { + BitbucketDataSourceCredentialsSchema, + BitbucketDataSourceListItemSchema, + BitbucketDataSourceSchema, + BitbucketFindingSchema, + CreateBitbucketDataSourceSchema +} from "./bitbucket-secret-scanning-schemas"; + +export type TBitbucketDataSource = z.infer; + +export type TBitbucketDataSourceInput = z.infer; + +export type TBitbucketDataSourceListItem = z.infer; + +export type TBitbucketDataSourceCredentials = z.infer; + +export type TBitbucketFinding = z.infer; + +export type TBitbucketDataSourceWithConnection = TBitbucketDataSource & { + connection: TBitbucketConnection; +}; + +export type TBitbucketPushEventRepository = { + full_name: string; + name: string; + workspace: { + slug: string; + uuid: string; + }; + uuid: string; +}; + +export type TBitbucketPushEventCommit = { + hash: string; + message: string; + author: { + raw: string; + user?: { + display_name: string; + uuid: string; + nickname: string; + }; + }; + date: string; +}; + +export type TBitbucketPushEventChange = { + new?: { + name: string; + type: string; + }; + old?: { + name: string; + type: string; + }; + created: boolean; + closed: boolean; + forced: boolean; + commits: TBitbucketPushEventCommit[]; +}; + +export type TBitbucketPushEvent = { + push: { + changes: TBitbucketPushEventChange[]; + }; + repository: TBitbucketPushEventRepository; + actor: { + display_name: string; + uuid: string; + nickname: string; + }; +}; + +export type TQueueBitbucketResourceDiffScan = { + dataSourceType: SecretScanningDataSource.Bitbucket; + payload: TBitbucketPushEvent & { dataSourceId: string }; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/index.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/index.ts new file mode 100644 index 000000000..5ac8262f3 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/index.ts @@ -0,0 +1,3 @@ +export * from "./bitbucket-secret-scanning-constants"; +export * from "./bitbucket-secret-scanning-schemas"; +export * from "./bitbucket-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts index 2dde97d7c..e09b8f88b 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -19,18 +19,23 @@ import { TSecretScanningFactoryGetFullScanPath, TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, - TSecretScanningFactoryPostInitialization + TSecretScanningFactoryPostInitialization, + TSecretScanningFactoryTeardown } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { titleCaseToCamelCase } from "@app/lib/fn"; -import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { BasicRepositoryRegex } from "@app/lib/regex"; import { listGitHubRadarRepositories, TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; -import { TGitHubDataSourceWithConnection, TQueueGitHubResourceDiffScan } from "./github-secret-scanning-types"; +import { + TGitHubDataSourceInput, + TGitHubDataSourceWithConnection, + TQueueGitHubResourceDiffScan +} from "./github-secret-scanning-types"; export const GitHubSecretScanningFactory = () => { - const initialize: TSecretScanningFactoryInitialize = async ( + const initialize: TSecretScanningFactoryInitialize = async ( { connection, secretScanningV2DAL }, callback ) => { @@ -51,10 +56,17 @@ export const GitHubSecretScanningFactory = () => { }); }; - const postInitialization: TSecretScanningFactoryPostInitialization = async () => { + const postInitialization: TSecretScanningFactoryPostInitialization< + TGitHubDataSourceInput, + TGitHubRadarConnection + > = async () => { // no post-initialization required }; + const teardown: TSecretScanningFactoryTeardown = async () => { + // no termination required + }; + const listRawResources: TSecretScanningFactoryListRawResources = async ( dataSource ) => { @@ -107,7 +119,7 @@ export const GitHubSecretScanningFactory = () => { const repoPath = join(tempFolder, "repo.git"); - if (!GitHubRepositoryRegex.test(resourceName)) { + if (!BasicRepositoryRegex.test(resourceName)) { throw new Error("Invalid GitHub repository name"); } @@ -225,6 +237,7 @@ export const GitHubSecretScanningFactory = () => { listRawResources, getFullScanPath, getDiffScanResourcePayload, - getDiffScanFindingsPayload + getDiffScanFindingsPayload, + teardown }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts index f1eec125c..e39830f22 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts @@ -12,7 +12,7 @@ import { GitRepositoryScanFindingDetailsSchema } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; import { SecretScanningDataSources } from "@app/lib/api-docs"; -import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { BasicRepositoryRegex } from "@app/lib/regex"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; export const GitHubDataSourceConfigSchema = z.object({ @@ -22,7 +22,7 @@ export const GitHubDataSourceConfigSchema = z.object({ .string() .min(1) .max(256) - .refine((value) => value === "*" || GitHubRepositoryRegex.test(value), "Invalid repository name format") + .refine((value) => value === "*" || BasicRepositoryRegex.test(value), "Invalid repository name format") ) .nonempty("One or more repositories required") .max(100, "Cannot configure more than 100 repositories") diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts index 082f3d760..40e5ea7dd 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -1,5 +1,6 @@ export enum SecretScanningDataSource { - GitHub = "github" + GitHub = "github", + Bitbucket = "bitbucket" } export enum SecretScanningScanStatus { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts index 109afe5f3..2ca1f4882 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -1,19 +1,23 @@ +import { BitbucketSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory"; import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; import { TQueueSecretScanningResourceDiffScan, TSecretScanningDataSourceCredentials, + TSecretScanningDataSourceInput, TSecretScanningDataSourceWithConnection, TSecretScanningFactory } from "./secret-scanning-v2-types"; type TSecretScanningFactoryImplementation = TSecretScanningFactory< TSecretScanningDataSourceWithConnection, - TSecretScanningDataSourceCredentials, - TQueueSecretScanningResourceDiffScan["payload"] + TQueueSecretScanningResourceDiffScan["payload"], + TSecretScanningDataSourceInput, + TSecretScanningDataSourceCredentials >; export const SECRET_SCANNING_FACTORY_MAP: Record = { - [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation + [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation, + [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index 64a0ba4ed..9489f4658 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -4,6 +4,7 @@ import RE2 from "re2"; import { readFindingsFile } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; import { titleCaseToCamelCase } from "@app/lib/fn"; @@ -11,7 +12,8 @@ import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secre import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListItem } from "./secret-scanning-v2-types"; const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { - [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION + [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, + [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION }; export const listSecretScanningDataSourceOptions = () => { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts index f41a2b5c2..c84d6056a 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -2,13 +2,16 @@ import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/se import { AppConnection } from "@app/services/app-connection/app-connection-enums"; export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { - [SecretScanningDataSource.GitHub]: "GitHub" + [SecretScanningDataSource.GitHub]: "GitHub", + [SecretScanningDataSource.Bitbucket]: "Bitbucket" }; export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { - [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar + [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket }; export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { - [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" } + [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" }, + [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" } }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 938d50a77..137034feb 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -37,7 +37,8 @@ import { TQueueSecretScanningDataSourceFullScan, TQueueSecretScanningResourceDiffScan, TQueueSecretScanningSendNotification, - TSecretScanningDataSourceWithConnection + TSecretScanningDataSourceWithConnection, + TSecretScanningFinding } from "./secret-scanning-v2-types"; type TSecretRotationV2QueueServiceFactoryDep = { @@ -318,7 +319,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 20, + workerCount: 2, pollingIntervalSeconds: 1 } ); @@ -459,13 +460,16 @@ export const secretScanningV2QueueServiceFactory = async ({ const newFindings = allFindings.filter((finding) => finding.scanId === scanId); if (newFindings.length) { + const finding = newFindings[0] as TSecretScanningFinding; await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { status: SecretScanningScanStatus.Completed, resourceName: resource.name, isDiffScan: true, dataSource, numberOfSecrets: newFindings.length, - scanId + scanId, + authorName: finding?.details?.author, + authorEmail: finding?.details?.email }); } @@ -539,7 +543,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 20, + workerCount: 2, pollingIntervalSeconds: 1 } ); @@ -582,8 +586,8 @@ export const secretScanningV2QueueServiceFactory = async ({ substitutions: payload.status === SecretScanningScanStatus.Completed ? { - authorName: "Jim", - authorEmail: "jim@infisical.com", + authorName: payload.authorName, + authorEmail: payload.authorEmail, resourceName, numberOfSecrets: payload.numberOfSecrets, isDiffScan: payload.isDiffScan, @@ -613,7 +617,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 5, + workerCount: 2, pollingIntervalSeconds: 1 } ); diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts index 832b73bda..730427a18 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts @@ -19,8 +19,7 @@ export const BaseSecretScanningDataSourceSchema = ({ // unique to provider type: true, connectionId: true, - config: true, - encryptedCredentials: true + config: true }).extend({ type: z.literal(type), connectionId: isConnectionRequired ? z.string().uuid() : z.null(), diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index f1f09506f..761059d2a 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -30,6 +30,8 @@ import { TFindSecretScanningDataSourceByNameDTO, TListSecretScanningDataSourcesByProjectId, TSecretScanningDataSource, + TSecretScanningDataSourceCredentials, + TSecretScanningDataSourceInput, TSecretScanningDataSourceWithConnection, TSecretScanningDataSourceWithDetails, TSecretScanningFinding, @@ -49,6 +51,7 @@ import { TAppConnection } from "@app/services/app-connection/app-connection-type import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { bitbucketSecretScanningService } from "./bitbucket/bitbucket-secret-scanning-service"; import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; @@ -256,7 +259,7 @@ export const secretScanningV2ServiceFactory = ({ try { const createdDataSource = await factory.initialize( { - payload, + payload: payload as TSecretScanningDataSourceInput, connection: connection as TSecretScanningDataSourceWithConnection["connection"], secretScanningV2DAL }, @@ -287,7 +290,7 @@ export const secretScanningV2ServiceFactory = ({ ); await factory.postInitialization({ - payload, + payload: payload as TSecretScanningDataSourceInput, connection: connection as TSecretScanningDataSourceWithConnection["connection"], dataSourceId: dataSource.id, credentials @@ -398,7 +401,6 @@ export const secretScanningV2ServiceFactory = ({ actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, - projectId: dataSource.projectId }); @@ -412,7 +414,36 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); - // TODO: clean up webhooks + const factory = SECRET_SCANNING_FACTORY_MAP[type](); + + let connection: TAppConnection | null = null; + if (dataSource.connection) { + connection = await decryptAppConnection(dataSource.connection, kmsService); + } + + let credentials: TSecretScanningDataSourceCredentials | undefined; + + if (dataSource.encryptedCredentials) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: dataSource.projectId + }); + + credentials = JSON.parse( + decryptor({ + cipherTextBlob: dataSource.encryptedCredentials + }).toString() + ) as TSecretScanningDataSourceCredentials; + } + + await factory.teardown({ + dataSource: { + ...dataSource, + // @ts-expect-error currently we don't have a null connection data source + connection + }, + credentials + }); await secretScanningV2DAL.dataSources.deleteById(dataSourceId); @@ -869,6 +900,7 @@ export const secretScanningV2ServiceFactory = ({ updateSecretScanningFindingById, findSecretScanningConfigByProjectId, upsertSecretScanningConfig, - github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue) + github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue), + bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts index 3ee5851d7..6bd8251b6 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -4,6 +4,15 @@ import { TSecretScanningResources, TSecretScanningScans } from "@app/db/schemas"; +import { + TBitbucketDataSource, + TBitbucketDataSourceCredentials, + TBitbucketDataSourceInput, + TBitbucketDataSourceListItem, + TBitbucketDataSourceWithConnection, + TBitbucketFinding, + TQueueBitbucketResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/bitbucket"; import { TGitHubDataSource, TGitHubDataSourceInput, @@ -19,7 +28,7 @@ import { SecretScanningScanStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; -export type TSecretScanningDataSource = TGitHubDataSource; +export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt?: Date | null; @@ -41,13 +50,17 @@ export type TSecretScanningScanWithDetails = TSecretScanningScans & { resourceName: string; }; -export type TSecretScanningDataSourceWithConnection = TGitHubDataSourceWithConnection; +export type TSecretScanningDataSourceWithConnection = + | TGitHubDataSourceWithConnection + | TBitbucketDataSourceWithConnection; -export type TSecretScanningDataSourceInput = TGitHubDataSourceInput; +export type TSecretScanningDataSourceInput = TGitHubDataSourceInput | TBitbucketDataSourceInput; -export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem; +export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem | TBitbucketDataSourceListItem; -export type TSecretScanningFinding = TGitHubFinding; +export type TSecretScanningDataSourceCredentials = TBitbucketDataSourceCredentials | undefined; + +export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding; export type TListSecretScanningDataSourcesByProjectId = { projectId: string; @@ -99,14 +112,21 @@ export type TQueueSecretScanningDataSourceFullScan = { scanId: string; }; -export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan; +export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan | TQueueBitbucketResourceDiffScan; export type TQueueSecretScanningSendNotification = { dataSource: TSecretScanningDataSources; resourceName: string; } & ( | { status: SecretScanningScanStatus.Failed; errorMessage: string } - | { status: SecretScanningScanStatus.Completed; numberOfSecrets: number; scanId: string; isDiffScan: boolean } + | { + status: SecretScanningScanStatus.Completed; + numberOfSecrets: number; + scanId: string; + isDiffScan: boolean; + authorName?: string; + authorEmail?: string; + } ); export type TCloneRepository = { @@ -138,11 +158,12 @@ export type TSecretScanningDataSourceRaw = NonNullable< >; export type TSecretScanningFactoryInitialize< + P extends TSecretScanningDataSourceInput, T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, C extends TSecretScanningDataSourceCredentials = undefined > = ( params: { - payload: TCreateSecretScanningDataSourceDTO; + payload: P; connection: T; secretScanningV2DAL: TSecretScanningV2DALFactory; }, @@ -150,24 +171,27 @@ export type TSecretScanningFactoryInitialize< ) => Promise; export type TSecretScanningFactoryPostInitialization< + P extends TSecretScanningDataSourceInput, T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, C extends TSecretScanningDataSourceCredentials = undefined -> = (params: { - payload: TCreateSecretScanningDataSourceDTO; - connection: T; - credentials: C; - dataSourceId: string; -}) => Promise; +> = (params: { payload: P; connection: T; credentials: C; dataSourceId: string }) => Promise; + +export type TSecretScanningFactoryTeardown< + T extends TSecretScanningDataSourceWithConnection, + C extends TSecretScanningDataSourceCredentials = undefined +> = (params: { dataSource: T; credentials: C }) => Promise; export type TSecretScanningFactory< T extends TSecretScanningDataSourceWithConnection, - C extends TSecretScanningDataSourceCredentials, - P extends TQueueSecretScanningResourceDiffScan["payload"] + P extends TQueueSecretScanningResourceDiffScan["payload"], + I extends TSecretScanningDataSourceInput, + C extends TSecretScanningDataSourceCredentials | undefined = undefined > = () => { listRawResources: TSecretScanningFactoryListRawResources; getFullScanPath: TSecretScanningFactoryGetFullScanPath; - initialize: TSecretScanningFactoryInitialize; - postInitialization: TSecretScanningFactoryPostInitialization; + initialize: TSecretScanningFactoryInitialize; + postInitialization: TSecretScanningFactoryPostInitialization; + teardown: TSecretScanningFactoryTeardown; getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; }; @@ -185,5 +209,3 @@ export type TUpsertSecretScanningConfigDTO = { projectId: string; content: string | null; }; - -export type TSecretScanningDataSourceCredentials = undefined; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts index 4f34791f8..671d4e16b 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -1,7 +1,22 @@ import { z } from "zod"; +import { BitbucketDataSourceSchema, BitbucketFindingSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; -export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [GitHubDataSourceSchema]); +export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ + GitHubDataSourceSchema, + BitbucketDataSourceSchema +]); -export const SecretScanningFindingSchema = z.discriminatedUnion("resourceType", [GitHubFindingSchema]); +export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType", [ + GitHubFindingSchema.describe( + JSON.stringify({ + title: "GitHub" + }) + ), + BitbucketFindingSchema.describe( + JSON.stringify({ + title: "Bitbucket" + }) + ) +]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 4405728b2..a61e542b1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -66,7 +66,10 @@ export enum ApiDocsTags { KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", KmsSigning = "KMS Signing", - SecretScanning = "Secret Scanning" + SecretScanning = "Secret Scanning", + OidcSso = "OIDC SSO", + SamlSso = "SAML SSO", + LdapSso = "LDAP SSO" } export const GROUPS = { @@ -2268,6 +2271,17 @@ export const AppConnections = { accessToken: "The Access Token used to access GitLab.", code: "The OAuth code to use to connect with GitLab.", accessTokenType: "The type of token used to connect with GitLab." + }, + BITBUCKET: { + email: "The email used to access Bitbucket.", + apiToken: "The API token used to access Bitbucket." + }, + ZABBIX: { + apiToken: "The API Token used to access Zabbix.", + instanceUrl: "The Zabbix instance URL to connect with." + }, + RAILWAY: { + apiToken: "The API token used to authenticate with Railway." } } }; @@ -2457,6 +2471,20 @@ export const SecretSyncs = { CLOUDFLARE_PAGES: { projectName: "The name of the Cloudflare Pages project to sync secrets to.", environment: "The environment of the Cloudflare Pages project to sync secrets to." + }, + ZABBIX: { + scope: "The Zabbix scope that secrets should be synced to.", + hostId: "The ID of the Zabbix host to sync secrets to.", + hostName: "The name of the Zabbix host to sync secrets to.", + macroType: "The type of macro to sync secrets to. (0: Text, 1: Secret)" + }, + RAILWAY: { + projectId: "The ID of the Railway project to sync secrets to.", + projectName: "The name of the Railway project to sync secrets to.", + environmentId: "The Railway environment to sync secrets to.", + environmentName: "The Railway environment to sync secrets to.", + serviceId: "The Railway service that secrets should be synced to.", + serviceName: "The Railway service that secrets should be synced to." } } }; @@ -2577,7 +2605,9 @@ export const SecretRotations = { export const SecretScanningDataSources = { LIST: (type?: SecretScanningDataSource) => ({ - projectId: `The ID of the project to list ${type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning"} Data Sources from.` + projectId: `The ID of the project to list ${ + type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning" + } Data Sources from.` }), GET_BY_ID: (type: SecretScanningDataSource) => ({ dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.` @@ -2628,6 +2658,10 @@ export const SecretScanningDataSources = { CONFIG: { GITHUB: { includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' + }, + BITBUCKET: { + workspaceSlug: "The workspace to scan.", + includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' } } }; @@ -2652,3 +2686,113 @@ export const SecretScanningConfigs = { content: "The contents of the Secret Scanning Configuration file." } }; + +export const OidcSSo = { + GET_CONFIG: { + organizationId: "The ID of the organization to get the OIDC config for." + }, + UPDATE_CONFIG: { + organizationId: "The ID of the organization to update the OIDC config for.", + allowedEmailDomains: + "A list of allowed email domains that users can use to authenticate with. This field is comma separated. Example: 'example.com,acme.com'", + discoveryURL: "The URL of the OIDC discovery endpoint.", + configurationType: "The configuration type to use for the OIDC configuration.", + issuer: + "The issuer for the OIDC configuration. This is only supported when the OIDC configuration type is set to 'custom'.", + authorizationEndpoint: + "The endpoint to use for OIDC authorization. This is only supported when the OIDC configuration type is set to 'custom'.", + jwksUri: "The URL of the OIDC JWKS endpoint.", + tokenEndpoint: "The token endpoint to use for OIDC token exchange.", + userinfoEndpoint: "The userinfo endpoint to get user information from the OIDC provider.", + clientId: "The client ID to use for OIDC authentication.", + clientSecret: "The client secret to use for OIDC authentication.", + isActive: "Whether to enable or disable this OIDC configuration.", + manageGroupMemberships: + "Whether to manage group memberships for the OIDC configuration. If enabled, users will automatically be assigned groups when they sign in, based on which groups they are a member of in the OIDC provider.", + jwtSignatureAlgorithm: "The algorithm to use for JWT signature verification." + }, + CREATE_CONFIG: { + organizationId: "The ID of the organization to create the OIDC config for.", + allowedEmailDomains: + "A list of allowed email domains that users can use to authenticate with. This field is comma separated.", + discoveryURL: "The URL of the OIDC discovery endpoint.", + configurationType: "The configuration type to use for the OIDC configuration.", + issuer: + "The issuer for the OIDC configuration. This is only supported when the OIDC configuration type is set to 'custom'.", + authorizationEndpoint: + "The authorization endpoint to use for OIDC authorization. This is only supported when the OIDC configuration type is set to 'custom'.", + jwksUri: "The URL of the OIDC JWKS endpoint.", + tokenEndpoint: "The token endpoint to use for OIDC token exchange.", + userinfoEndpoint: "The userinfo endpoint to get user information from the OIDC provider.", + clientId: "The client ID to use for OIDC authentication.", + clientSecret: "The client secret to use for OIDC authentication.", + isActive: "Whether to enable or disable this OIDC configuration.", + manageGroupMemberships: + "Whether to manage group memberships for the OIDC configuration. If enabled, users will automatically be assigned groups when they sign in, based on which groups they are a member of in the OIDC provider.", + jwtSignatureAlgorithm: "The algorithm to use for JWT signature verification." + } +}; + +export const SamlSso = { + GET_CONFIG: { + organizationId: "The ID of the organization to get the SAML config for." + }, + UPDATE_CONFIG: { + organizationId: "The ID of the organization to update the SAML config for.", + authProvider: "Authentication provider to use for SAML authentication.", + isActive: "Whether to enable or disable this SAML configuration.", + entryPoint: + "The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.", + issuer: "The SAML provider issuer URL or entity ID.", + cert: "The certificate to use for SAML authentication." + }, + CREATE_CONFIG: { + organizationId: "The ID of the organization to create the SAML config for.", + authProvider: "Authentication provider to use for SAML authentication.", + isActive: "Whether to enable or disable this SAML configuration.", + entryPoint: + "The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.", + issuer: "The SAML provider issuer URL or entity ID.", + cert: "The certificate to use for SAML authentication." + } +}; + +export const LdapSso = { + GET_CONFIG: { + organizationId: "The ID of the organization to get the LDAP config for." + }, + CREATE_CONFIG: { + organizationId: "The ID of the organization to create the LDAP config for.", + isActive: "Whether to enable or disable this LDAP configuration.", + url: "The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` (for connection over SSL/TLS), etc.", + bindDN: + "The distinguished name of the object to bind when performing the user search such as `cn=infisical,ou=Users,dc=acme,dc=com`", + bindPass: "The password to use along with Bind DN when performing the user search.", + searchBase: "The base DN to use for the user search such as `ou=Users,dc=acme,dc=com`", + uniqueUserAttribute: + "The attribute to use as the unique identifier of LDAP users such as `sAMAccountName`, `cn`, `uid`, `objectGUID`. If left blank, defaults to uidNumber", + searchFilter: + "The template used to construct the LDAP user search filter such as `(uid={{username}})` uses literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas.", + groupSearchBase: "LDAP search base to use for group membership search such as `ou=Groups,dc=acme,dc=com`", + groupSearchFilter: + "The template used when constructing the group membership query such as `(&(objectClass=posixGroup)(memberUid={{.Username}}))`. The template can access the following context variables: `[UserDN, UserName]`. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas.", + caCert: "The CA certificate to use when verifying the LDAP server certificate." + }, + UPDATE_CONFIG: { + organizationId: "The ID of the organization to update the LDAP config for.", + isActive: "Whether to enable or disable this LDAP configuration.", + url: "The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` (for connection over SSL/TLS), etc.", + bindDN: + "The distinguished name of object to bind when performing the user search such as `cn=infisical,ou=Users,dc=acme,dc=com`", + bindPass: "The password to use along with Bind DN when performing the user search.", + uniqueUserAttribute: + "The attribute to use as the unique identifier of LDAP users such as `sAMAccountName`, `cn`, `uid`, `objectGUID`. If left blank, defaults to uidNumber", + searchFilter: + "The template used to construct the LDAP user search filter such as `(uid={{username}})` uses literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas.", + searchBase: "The base DN to use for the user search such as `ou=Users,dc=acme,dc=com`", + groupSearchBase: "LDAP search base to use for group membership search such as `ou=Groups,dc=acme,dc=com`", + groupSearchFilter: + "The template used when constructing the group membership query such as `(&(objectClass=posixGroup)(memberUid={{.Username}}))`. The template can access the following context variables: `[UserDN, UserName]`. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas.", + caCert: "The CA certificate to use when verifying the LDAP server certificate." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8db5c16c4..38b34f488 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { QueueWorkerProfile } from "@app/lib/types"; +import { BadRequestError } from "../errors"; import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -27,6 +28,7 @@ const databaseReadReplicaSchema = z const envSchema = z .object({ INFISICAL_PLATFORM_VERSION: zpStr(z.string().optional()), + KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN: zodStrBool.default("false"), PORT: z.coerce.number().default(IS_PACKAGED ? 8080 : 4000), DISABLE_SECRET_SCANNING: z .enum(["true", "false"]) @@ -341,8 +343,11 @@ const envSchema = z export type TEnvConfig = Readonly>; let envCfg: TEnvConfig; +let originalEnvConfig: TEnvConfig; export const getConfig = () => envCfg; +export const getOriginalConfig = () => originalEnvConfig; + // cannot import singleton logger directly as it needs config to load various transport export const initEnvConfig = (logger?: CustomLogger) => { const parsedEnv = envSchema.safeParse(process.env); @@ -352,10 +357,217 @@ export const initEnvConfig = (logger?: CustomLogger) => { process.exit(-1); } - envCfg = Object.freeze(parsedEnv.data); + const config = Object.freeze(parsedEnv.data); + envCfg = config; + + if (!originalEnvConfig) { + originalEnvConfig = config; + } + return envCfg; }; +// A list of environment variables that can be overwritten +export const overwriteSchema: { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; description?: string }[]; + }; +} = { + aws: { + name: "AWS", + fields: [ + { + key: "INF_APP_CONNECTION_AWS_ACCESS_KEY_ID", + description: "The Access Key ID of your AWS account." + }, + { + key: "INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY", + description: "The Client Secret of your AWS application." + } + ] + }, + azure: { + name: "Azure", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + gcp: { + name: "GCP", + fields: [ + { + key: "INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL", + description: "The GCP Service Account JSON credentials." + } + ] + }, + github_app: { + name: "GitHub App", + fields: [ + { + key: "INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID", + description: "The Client ID of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET", + description: "The Client Secret of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_APP_SLUG", + description: "The Slug of your GitHub application. This is the one found in the URL." + }, + { + key: "INF_APP_CONNECTION_GITHUB_APP_ID", + description: "The App ID of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY", + description: "The Private Key of your GitHub application." + } + ] + }, + github_oauth: { + name: "GitHub OAuth", + fields: [ + { + key: "INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID", + description: "The Client ID of your GitHub OAuth application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET", + description: "The Client Secret of your GitHub OAuth application." + } + ] + }, + github_radar_app: { + name: "GitHub Radar App", + fields: [ + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID", + description: "The Client ID of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET", + description: "The Client Secret of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG", + description: "The Slug of your GitHub application. This is the one found in the URL." + }, + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_ID", + description: "The App ID of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY", + description: "The Private Key of your GitHub application." + }, + { + key: "INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET", + description: "The Webhook Secret of your GitHub application." + } + ] + }, + github_sso: { + name: "GitHub SSO", + fields: [ + { + key: "CLIENT_ID_GITHUB_LOGIN", + description: "The Client ID of your GitHub OAuth application." + }, + { + key: "CLIENT_SECRET_GITHUB_LOGIN", + description: "The Client Secret of your GitHub OAuth application." + } + ] + }, + gitlab_oauth: { + name: "GitLab OAuth", + fields: [ + { + key: "INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID", + description: "The Client ID of your GitLab OAuth application." + }, + { + key: "INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET", + description: "The Client Secret of your GitLab OAuth application." + } + ] + }, + gitlab_sso: { + name: "GitLab SSO", + fields: [ + { + key: "CLIENT_ID_GITLAB_LOGIN", + description: "The Client ID of your GitLab application." + }, + { + key: "CLIENT_SECRET_GITLAB_LOGIN", + description: "The Secret of your GitLab application." + }, + { + key: "CLIENT_GITLAB_LOGIN_URL", + description: + "The URL of your self-hosted instance of GitLab where the OAuth application is registered. If no URL is passed in, this will default to https://gitlab.com." + } + ] + }, + google_sso: { + name: "Google SSO", + fields: [ + { + key: "CLIENT_ID_GOOGLE_LOGIN", + description: "The Client ID of your GCP OAuth2 application." + }, + { + key: "CLIENT_SECRET_GOOGLE_LOGIN", + description: "The Client Secret of your GCP OAuth2 application." + } + ] + } +}; + +export const overridableKeys = new Set( + Object.values(overwriteSchema).flatMap(({ fields }) => fields.map(({ key }) => key)) +); + +export const validateOverrides = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (!parsedResult.success) { + const errorDetails = parsedResult.error.issues + .map((issue) => `Key: "${issue.path.join(".")}", Error: ${issue.message}`) + .join("\n"); + throw new BadRequestError({ message: errorDetails }); + } +}; + +export const overrideEnvConfig = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (parsedResult.success) { + envCfg = Object.freeze(parsedResult.data); + } +}; + export const formatSmtpConfig = () => { const tlsOptions: { rejectUnauthorized: boolean; diff --git a/backend/src/lib/config/request.ts b/backend/src/lib/config/request.ts index 8636b7476..aedf5cd44 100644 --- a/backend/src/lib/config/request.ts +++ b/backend/src/lib/config/request.ts @@ -1,11 +1,18 @@ -import axios from "axios"; -import axiosRetry from "axios-retry"; +import axios, { AxiosInstance, CreateAxiosDefaults } from "axios"; +import axiosRetry, { IAxiosRetryConfig } from "axios-retry"; -export const request = axios.create(); +export function createRequestClient(defaults: CreateAxiosDefaults = {}, retry: IAxiosRetryConfig = {}): AxiosInstance { + const client = axios.create(defaults); -axiosRetry(request, { - retries: 3, - // eslint-disable-next-line - retryDelay: axiosRetry.exponentialDelay, - retryCondition: (err) => axiosRetry.isNetworkError(err) || axiosRetry.isRetryableError(err) -}); + axiosRetry(client, { + retries: 3, + // eslint-disable-next-line + retryDelay: axiosRetry.exponentialDelay, + retryCondition: (err) => axiosRetry.isNetworkError(err) || axiosRetry.isRetryableError(err), + ...retry + }); + + return client; +} + +export const request = createRequestClient(); diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index c472f8d5d..a57705526 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -10,4 +10,4 @@ export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-] export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); -export const GitHubRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); +export const BasicRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts index 466450180..1323fa4ad 100644 --- a/backend/src/server/plugins/secret-scanner-v2.ts +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -1,7 +1,9 @@ import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types"; import { PushEvent } from "@octokit/webhooks-types"; import { Probot } from "probot"; +import { z } from "zod"; +import { TBitbucketPushEvent } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -63,4 +65,52 @@ export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvide return res.send("ok"); } }); + + // bitbucket push event webhook + server.route({ + method: "POST", + url: "/bitbucket", + schema: { + querystring: z.object({ + dataSourceId: z.string().min(1, { message: "Data Source ID is required" }) + }), + headers: z + .object({ + "x-hub-signature": z.string().min(1, { message: "X-Hub-Signature header is required" }) + }) + .passthrough() + }, + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const { dataSourceId } = req.query; + + // Verify signature + const signature = req.headers["x-hub-signature"]; + if (!signature) { + logger.error("Missing X-Hub-Signature header for Bitbucket webhook"); + return res.status(401).send({ message: "Unauthorized: Missing signature" }); + } + + const expectedSignaturePrefix = "sha256="; + if (!signature.startsWith(expectedSignaturePrefix)) { + logger.error({ signature }, "Invalid X-Hub-Signature format for Bitbucket webhook"); + return res.status(401).send({ message: "Unauthorized: Invalid signature format" }); + } + + const receivedSignature = signature.substring(expectedSignaturePrefix.length); + + if (!dataSourceId) return res.status(400).send({ message: "Data Source ID is required" }); + + await server.services.secretScanningV2.bitbucket.handlePushEvent({ + ...(req.body as TBitbucketPushEvent), + dataSourceId, + receivedSignature, + bodyString: JSON.stringify(req.body) + }); + + return res.send("ok"); + } + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 62795d4f6..3915ac65f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -304,6 +304,7 @@ import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; import { registerV1Routes } from "./v1"; +import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; @@ -1631,7 +1632,8 @@ export const registerRoutes = async ( secretSharingDAL, secretVersionV2DAL: secretVersionV2BridgeDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, - serviceTokenService + serviceTokenService, + orgService }); const dailyReminderQueueService = dailyReminderQueueServiceFactory({ @@ -1932,6 +1934,7 @@ export const registerRoutes = async ( await hsmService.startService(); await telemetryQueue.startTelemetryCheck(); + await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.startCleanUp(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); @@ -2071,6 +2074,16 @@ export const registerRoutes = async ( } } + const configSyncJob = await superAdminService.initializeEnvConfigSync(); + if (configSyncJob) { + cronJobs.push(configSyncJob); + } + + const oauthConfigSyncJob = await initializeOauthConfigSync(); + if (oauthConfigSyncJob) { + cronJobs.push(oauthConfigSyncJob); + } + server.decorate("store", { user: userDAL, kmipClient: kmipClientDAL diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a21587db1..c3b204c48 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -8,7 +8,7 @@ import { SuperAdminSchema, UsersSchema } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; +import { getConfig, overridableKeys } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; @@ -42,13 +42,15 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { encryptedGitHubAppConnectionClientSecret: true, encryptedGitHubAppConnectionSlug: true, encryptedGitHubAppConnectionId: true, - encryptedGitHubAppConnectionPrivateKey: true + encryptedGitHubAppConnectionPrivateKey: true, + encryptedEnvOverrides: true }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), defaultAuthOrgAuthEnforced: z.boolean().nullish(), defaultAuthOrgAuthMethod: z.string().nullish(), - isSecretScanningDisabled: z.boolean() + isSecretScanningDisabled: z.boolean(), + kubernetesAutoFetchServiceAccountToken: z.boolean() }) }) } @@ -60,7 +62,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { config: { ...config, isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, - isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING + isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, + kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN } }; } @@ -110,11 +113,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { .refine((content) => DOMPurify.sanitize(content) === content, { message: "Page frame content contains unsafe HTML." }) - .optional() + .optional(), + envOverrides: z.record(z.enum(Array.from(overridableKeys) as [string, ...string[]]), z.string()).optional() }), response: { 200: z.object({ - config: SuperAdminSchema.extend({ + config: SuperAdminSchema.omit({ + encryptedEnvOverrides: true + }).extend({ defaultAuthOrgSlug: z.string().nullable() }) }) @@ -381,6 +387,41 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/env-overrides", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.record( + z.string(), + z.object({ + name: z.string(), + fields: z + .object({ + key: z.string(), + value: z.string(), + hasEnvEntry: z.boolean(), + description: z.string().optional() + }) + .array() + }) + ) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const envOverrides = await server.services.superAdmin.getEnvOverridesOrganized(); + return envOverrides; + } + }); + server.route({ method: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 6160828f4..f692e700f 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -31,6 +31,10 @@ import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema } from "@app/services/app-connection/azure-key-vault"; +import { + BitbucketConnectionListItemSchema, + SanitizedBitbucketConnectionSchema +} from "@app/services/app-connection/bitbucket"; import { CamundaConnectionListItemSchema, SanitizedCamundaConnectionSchema @@ -67,6 +71,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + RailwayConnectionListItemSchema, + SanitizedRailwayConnectionSchema +} from "@app/services/app-connection/railway"; import { RenderConnectionListItemSchema, SanitizedRenderConnectionSchema @@ -84,6 +92,7 @@ import { SanitizedWindmillConnectionSchema, WindmillConnectionListItemSchema } from "@app/services/app-connection/windmill"; +import { SanitizedZabbixConnectionSchema, ZabbixConnectionListItemSchema } from "@app/services/app-connection/zabbix"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -116,7 +125,10 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedRenderConnectionSchema.options, ...SanitizedFlyioConnectionSchema.options, ...SanitizedGitLabConnectionSchema.options, - ...SanitizedCloudflareConnectionSchema.options + ...SanitizedCloudflareConnectionSchema.options, + ...SanitizedBitbucketConnectionSchema.options, + ...SanitizedZabbixConnectionSchema.options, + ...SanitizedRailwayConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -148,7 +160,10 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ RenderConnectionListItemSchema, FlyioConnectionListItemSchema, GitLabConnectionListItemSchema, - CloudflareConnectionListItemSchema + CloudflareConnectionListItemSchema, + BitbucketConnectionListItemSchema, + ZabbixConnectionListItemSchema, + RailwayConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/bitbucket-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/bitbucket-connection-router.ts new file mode 100644 index 000000000..7fe5113e5 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/bitbucket-connection-router.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateBitbucketConnectionSchema, + SanitizedBitbucketConnectionSchema, + UpdateBitbucketConnectionSchema +} from "@app/services/app-connection/bitbucket"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerBitbucketConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Bitbucket, + server, + sanitizedResponseSchema: SanitizedBitbucketConnectionSchema, + createSchema: CreateBitbucketConnectionSchema, + updateSchema: UpdateBitbucketConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/workspaces`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + workspaces: z.object({ slug: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + params: { connectionId } + } = req; + + const workspaces = await server.services.appConnection.bitbucket.listWorkspaces(connectionId, req.permission); + + return { workspaces }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + workspaceSlug: z.string().min(1).max(255) + }), + response: { + 200: z.object({ + repositories: z.object({ slug: z.string(), full_name: z.string(), uuid: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + params: { connectionId }, + query: { workspaceSlug } + } = req; + + const repositories = await server.services.appConnection.bitbucket.listRepositories( + { connectionId, workspaceSlug }, + req.permission + ); + + return { repositories }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index cd4ccd728..524abc18d 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -9,6 +9,7 @@ import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-confi import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; +import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; @@ -24,11 +25,13 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerRailwayConnectionRouter } from "./railway-connection-router"; import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; +import { registerZabbixConnectionRouter } from "./zabbix-connection-router"; export * from "./app-connection-router"; @@ -62,5 +65,8 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Railway, + server, + sanitizedResponseSchema: SanitizedRailwayConnectionSchema, + createSchema: CreateRailwayConnectionSchema, + updateSchema: UpdateRailwayConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z + .object({ + name: z.string(), + id: z.string(), + services: z.array( + z.object({ + name: z.string(), + id: z.string() + }) + ), + environments: z.array( + z.object({ + name: z.string(), + id: z.string() + }) + ) + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects = await server.services.appConnection.railway.listProjects(connectionId, req.permission); + + return { projects }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/zabbix-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/zabbix-connection-router.ts new file mode 100644 index 000000000..902509d8d --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/zabbix-connection-router.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateZabbixConnectionSchema, + SanitizedZabbixConnectionSchema, + UpdateZabbixConnectionSchema +} from "@app/services/app-connection/zabbix"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerZabbixConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Zabbix, + server, + sanitizedResponseSchema: SanitizedZabbixConnectionSchema, + createSchema: CreateZabbixConnectionSchema, + updateSchema: UpdateZabbixConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/hosts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + hostId: z.string(), + host: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const hosts = await server.services.appConnection.zabbix.listHosts(connectionId, req.permission); + return hosts; + } + }); +}; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index dae18c23e..c466be087 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -732,8 +732,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, projectId, environment, - path: secretPath, - search + path: secretPath + // search scott: removing for now because this prevents searching imported secrets which are fetched separately client side }); if (remainingLimit > 0 && totalImportCount > adjustedOffset) { @@ -745,7 +745,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { projectId, environment, path: secretPath, - search, + // search scott: removing for now because this prevents searching imported secrets which are fetched separately client side limit: remainingLimit, offset: adjustedOffset }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index b3fceb201..e1669c784 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -113,52 +113,73 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.AuditLogs], description: "Get all audit logs for an organization", - querystring: z.object({ - projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), - environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment), - actorType: z.nativeEnum(ActorType).optional(), - secretPath: z - .string() - .optional() - .transform((val) => (!val ? val : removeTrailingSlash(val))) - .describe(AUDIT_LOGS.EXPORT.secretPath), - secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey), + querystring: z + .object({ + projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), + environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment), + actorType: z.nativeEnum(ActorType).optional(), + secretPath: z + .string() + .optional() + .transform((val) => (!val ? val : removeTrailingSlash(val))) + .describe(AUDIT_LOGS.EXPORT.secretPath), + secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey), + // eventType is split with , for multiple values, we need to transform it to array + eventType: z + .string() + .optional() + .transform((val) => (val ? val.split(",") : undefined)), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + eventMetadata: z + .string() + .optional() + .transform((val) => { + if (!val) { + return undefined; + } - // eventType is split with , for multiple values, we need to transform it to array - eventType: z - .string() - .optional() - .transform((val) => (val ? val.split(",") : undefined)), - userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), - eventMetadata: z - .string() - .optional() - .transform((val) => { - if (!val) { - return undefined; + const pairs = val.split(","); + + return pairs.reduce( + (acc, pair) => { + const [key, value] = pair.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + }) + .describe(AUDIT_LOGS.EXPORT.eventMetadata), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }) + .superRefine((el, ctx) => { + if (el.endDate && el.startDate) { + const startDate = new Date(el.startDate); + const endDate = new Date(el.endDate); + const maxAllowedDate = new Date(startDate); + maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); + if (endDate < startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); } - - const pairs = val.split(","); - - return pairs.reduce( - (acc, pair) => { - const [key, value] = pair.split("="); - if (key && value) { - acc[key] = value; - } - return acc; - }, - {} as Record - ); - }) - .describe(AUDIT_LOGS.EXPORT.eventMetadata), - startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), - endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), - offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), - limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), - actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) - }), - + if (endDate > maxAllowedDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "Dates must be within 3 months" + }); + } + } + }), response: { 200: z.object({ auditLogs: AuditLogsSchema.omit({ @@ -188,14 +209,13 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const auditLogs = await server.services.auditLog.listAuditLogs({ filter: { ...req.query, - endDate: req.query.endDate, + endDate: req.query.endDate || new Date().toISOString(), projectId: req.query.projectId, startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActorId: req.query.actor, actorType: req.query.actorType, eventType: req.query.eventType as EventType[] | undefined }, - actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 4675a1a40..038fce7aa 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -17,11 +17,13 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; import { registerWindmillSyncRouter } from "./windmill-sync-router"; +import { registerZabbixSyncRouter } from "./zabbix-sync-router"; export * from "./secret-sync-router"; @@ -47,5 +49,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Railway, + server, + responseSchema: RailwaySyncSchema, + createSchema: CreateRailwaySyncSchema, + updateSchema: UpdateRailwaySyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts index 6ab9b5939..9db3a2013 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts @@ -382,7 +382,8 @@ export const registerSyncSecretsEndpoints = { diff --git a/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts new file mode 100644 index 000000000..cfd029623 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateZabbixSyncSchema, UpdateZabbixSyncSchema, ZabbixSyncSchema } from "@app/services/secret-sync/zabbix"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerZabbixSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Zabbix, + server, + responseSchema: ZabbixSyncSchema, + createSchema: CreateZabbixSyncSchema, + updateSchema: UpdateZabbixSyncSchema + }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index b6b3cb8aa..5e2518362 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,6 +9,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; +import { CronJob } from "cron"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; import { Strategy as OAuth2Strategy } from "passport-oauth2"; @@ -25,27 +26,14 @@ import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; -export const registerSsoRouter = async (server: FastifyZodProvider) => { +const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); + +let serverInstance: FastifyZodProvider | null = null; + +export const registerOauthMiddlewares = (server: FastifyZodProvider) => { + serverInstance = server; const appCfg = getConfig(); - const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); - 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 const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -176,6 +164,49 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { ) ); } +}; + +export const refreshOauthConfig = () => { + if (!serverInstance) { + logger.warn("Cannot refresh OAuth config: server instance not available"); + return; + } + + logger.info("Refreshing OAuth configuration..."); + registerOauthMiddlewares(serverInstance); +}; + +export const initializeOauthConfigSync = async () => { + logger.info("Setting up background sync process for oauth configuration"); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", refreshOauthConfig); + job.start(); + + return job; +}; + +export const registerSsoRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + 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()); + + registerOauthMiddlewares(server); server.route({ url: "/redirect/google", diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 8784989bd..55fc094b7 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -2,7 +2,7 @@ import picomatch from "picomatch"; import { z } from "zod"; import { SecretApprovalRequestsSchema, SecretsSchema, SecretType, ServiceTokenScopes } from "@app/db/schemas"; -import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { EventType, SecretApprovalEvent, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -594,6 +594,23 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretReminderRepeatDays: req.body.secretReminderRepeatDays }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Create + } + } + }); + return { approval: secretOperation.approval }; } @@ -730,6 +747,23 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Update + } + } + }); + return { approval: secretOperation.approval }; } const { secret } = secretOperation; @@ -831,6 +865,23 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { type: req.body.type }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Delete + } + } + }); + return { approval: secretOperation.approval }; } @@ -1165,7 +1216,10 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + eventType: SecretApprovalEvent.Create } } }); @@ -1351,7 +1405,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Update } } }); @@ -1489,7 +1547,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Delete } } }); @@ -1673,7 +1735,10 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + eventType: SecretApprovalEvent.CreateMany } } }); @@ -1801,7 +1866,13 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + eventType: SecretApprovalEvent.UpdateMany, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretName + })) } } }); @@ -1920,7 +1991,13 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { metadata: { committedBy: approval.committerUserId, secretApprovalRequestId: approval.id, - secretApprovalRequestSlug: approval.slug + secretApprovalRequestSlug: approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretName + })), + eventType: SecretApprovalEvent.DeleteMany } } }); @@ -2038,6 +2115,24 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secrets: inputSecrets }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); return { approval: secretOperation.approval }; } const { secrets } = secretOperation; @@ -2170,6 +2265,25 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { mode: req.body.mode }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey, + secretPath: secret.secretPath + })), + eventType: SecretApprovalEvent.UpdateMany + } + } + }); return { approval: secretOperation.approval }; } const { secrets } = secretOperation; @@ -2298,6 +2412,25 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secrets: inputSecrets }); if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey + })), + eventType: SecretApprovalEvent.DeleteMany + } + } + }); + return { approval: secretOperation.approval }; } const { secrets } = secretOperation; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 11b84b5ad..b9c405654 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -27,7 +27,10 @@ export enum AppConnection { Render = "render", Flyio = "flyio", GitLab = "gitlab", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + Zabbix = "zabbix", + Railway = "railway", + Bitbucket = "bitbucket" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 78f6b99b5..df40a9eea 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -50,6 +50,11 @@ import { getAzureKeyVaultConnectionListItem, validateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { + BitbucketConnectionMethod, + getBitbucketConnectionListItem, + validateBitbucketConnectionCredentials +} from "./bitbucket"; import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; import { @@ -86,6 +91,7 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; import { RenderConnectionMethod } from "./render/render-connection-enums"; import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; import { @@ -105,6 +111,7 @@ import { validateWindmillConnectionCredentials, WindmillConnectionMethod } from "./windmill"; +import { getZabbixConnectionListItem, validateZabbixConnectionCredentials, ZabbixConnectionMethod } from "./zabbix"; export const listAppConnectionOptions = () => { return [ @@ -136,7 +143,10 @@ export const listAppConnectionOptions = () => { getRenderConnectionListItem(), getFlyioConnectionListItem(), getGitLabConnectionListItem(), - getCloudflareConnectionListItem() + getCloudflareConnectionListItem(), + getZabbixConnectionListItem(), + getRailwayConnectionListItem(), + getBitbucketConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -216,7 +226,10 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -253,6 +266,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: case CloudflareConnectionMethod.APIToken: + case BitbucketConnectionMethod.ApiToken: + case ZabbixConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -332,7 +347,10 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Render]: platformManagedCredentialsNotSupported, [AppConnection.Flyio]: platformManagedCredentialsNotSupported, [AppConnection.GitLab]: platformManagedCredentialsNotSupported, - [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported + [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported, + [AppConnection.Zabbix]: platformManagedCredentialsNotSupported, + [AppConnection.Railway]: platformManagedCredentialsNotSupported, + [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 9c0a3b5b8..4f274516c 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -29,7 +29,10 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Render]: "Render", [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", - [AppConnection.Cloudflare]: "Cloudflare" + [AppConnection.Cloudflare]: "Cloudflare", + [AppConnection.Zabbix]: "Zabbix", + [AppConnection.Railway]: "Railway", + [AppConnection.Bitbucket]: "Bitbucket" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -61,5 +64,8 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -232,6 +253,9 @@ export type TAppConnectionInput = { id: string } & ( | TFlyioConnectionInput | TGitLabConnectionInput | TCloudflareConnectionInput + | TBitbucketConnectionInput + | TZabbixConnectionInput + | TRailwayConnectionInput ); export type TSqlConnectionInput = @@ -275,7 +299,10 @@ export type TAppConnectionConfig = | TRenderConnectionConfig | TFlyioConnectionConfig | TGitLabConnectionConfig - | TCloudflareConnectionConfig; + | TCloudflareConnectionConfig + | TBitbucketConnectionConfig + | TZabbixConnectionConfig + | TRailwayConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -306,7 +333,10 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateRenderConnectionCredentialsSchema | TValidateFlyioConnectionCredentialsSchema | TValidateGitLabConnectionCredentialsSchema - | TValidateCloudflareConnectionCredentialsSchema; + | TValidateCloudflareConnectionCredentialsSchema + | TValidateBitbucketConnectionCredentialsSchema + | TValidateZabbixConnectionCredentialsSchema + | TValidateRailwayConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-enums.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-enums.ts new file mode 100644 index 000000000..037915863 --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-enums.ts @@ -0,0 +1,3 @@ +export enum BitbucketConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-fns.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-fns.ts new file mode 100644 index 000000000..2d418a8a3 --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-fns.ts @@ -0,0 +1,117 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { BitbucketConnectionMethod } from "./bitbucket-connection-enums"; +import { + TBitbucketConnection, + TBitbucketConnectionConfig, + TBitbucketRepo, + TBitbucketWorkspace +} from "./bitbucket-connection-types"; + +export const getBitbucketConnectionListItem = () => { + return { + name: "Bitbucket" as const, + app: AppConnection.Bitbucket as const, + methods: Object.values(BitbucketConnectionMethod) as [BitbucketConnectionMethod.ApiToken] + }; +}; + +export const getBitbucketUser = async ({ email, apiToken }: { email: string; apiToken: string }) => { + try { + const { data } = await request.get<{ username: string }>(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/user`, { + headers: { + Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`, + Accept: "application/json" + } + }); + + return data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } +}; + +export const validateBitbucketConnectionCredentials = async (config: TBitbucketConnectionConfig) => { + await getBitbucketUser(config.credentials); + return config.credentials; +}; + +interface BitbucketWorkspacesResponse { + values: TBitbucketWorkspace[]; + next?: string; +} + +export const listBitbucketWorkspaces = async (appConnection: TBitbucketConnection) => { + const { email, apiToken } = appConnection.credentials; + + const headers = { + Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`, + Accept: "application/json" + }; + + let allWorkspaces: TBitbucketWorkspace[] = []; + let nextUrl: string | undefined = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces?pagelen=100`; + let iterationCount = 0; + + // Limit to 10 iterations, fetching at most 10 * 100 = 1000 workspaces + while (nextUrl && iterationCount < 10) { + // eslint-disable-next-line no-await-in-loop + const { data }: { data: BitbucketWorkspacesResponse } = await request.get(nextUrl, { + headers + }); + + allWorkspaces = allWorkspaces.concat(data.values.map((workspace) => ({ slug: workspace.slug }))); + nextUrl = data.next; + iterationCount += 1; + } + + return allWorkspaces; +}; + +interface BitbucketRepositoriesResponse { + values: TBitbucketRepo[]; + next?: string; +} + +export const listBitbucketRepositories = async (appConnection: TBitbucketConnection, workspaceSlug: string) => { + const { email, apiToken } = appConnection.credentials; + + const headers = { + Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`, + Accept: "application/json" + }; + + let allRepos: TBitbucketRepo[] = []; + let nextUrl: string | undefined = + `${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodeURIComponent(workspaceSlug)}?pagelen=100`; + let iterationCount = 0; + + // Limit to 10 iterations, fetching at most 10 * 100 = 1000 repositories + while (nextUrl && iterationCount < 10) { + // eslint-disable-next-line no-await-in-loop + const { data }: { data: BitbucketRepositoriesResponse } = await request.get( + nextUrl, + { + headers + } + ); + + allRepos = allRepos.concat(data.values); + nextUrl = data.next; + iterationCount += 1; + } + + return allRepos; +}; diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts new file mode 100644 index 000000000..fab1bf74c --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts @@ -0,0 +1,72 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { BitbucketConnectionMethod } from "./bitbucket-connection-enums"; + +export const BitbucketConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z + .string() + .trim() + .min(1, "API Token required") + .max(255) + .describe(AppConnections.CREDENTIALS.BITBUCKET.apiToken), + email: z + .string() + .email() + .trim() + .min(1, "Email required") + .max(255) + .describe(AppConnections.CREDENTIALS.BITBUCKET.email) +}); + +const BaseBitbucketConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Bitbucket) }); + +export const BitbucketConnectionSchema = BaseBitbucketConnectionSchema.extend({ + method: z.literal(BitbucketConnectionMethod.ApiToken), + credentials: BitbucketConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedBitbucketConnectionSchema = z.discriminatedUnion("method", [ + BaseBitbucketConnectionSchema.extend({ + method: z.literal(BitbucketConnectionMethod.ApiToken), + credentials: BitbucketConnectionAccessTokenCredentialsSchema.pick({ + email: true + }) + }) +]); + +export const ValidateBitbucketConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(BitbucketConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Bitbucket).method), + credentials: BitbucketConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Bitbucket).credentials + ) + }) +]); + +export const CreateBitbucketConnectionSchema = ValidateBitbucketConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Bitbucket) +); + +export const UpdateBitbucketConnectionSchema = z + .object({ + credentials: BitbucketConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Bitbucket).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Bitbucket)); + +export const BitbucketConnectionListItemSchema = z.object({ + name: z.literal("Bitbucket"), + app: z.literal(AppConnection.Bitbucket), + methods: z.nativeEnum(BitbucketConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-service.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-service.ts new file mode 100644 index 000000000..f08a8d276 --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-service.ts @@ -0,0 +1,33 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listBitbucketRepositories, listBitbucketWorkspaces } from "./bitbucket-connection-fns"; +import { TBitbucketConnection, TGetBitbucketRepositoriesDTO } from "./bitbucket-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const bitbucketConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor); + const workspaces = await listBitbucketWorkspaces(appConnection); + return workspaces; + }; + + const listRepositories = async ( + { connectionId, workspaceSlug }: TGetBitbucketRepositoriesDTO, + actor: OrgServiceActor + ) => { + const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor); + const repositories = await listBitbucketRepositories(appConnection, workspaceSlug); + return repositories; + }; + + return { + listWorkspaces, + listRepositories + }; +}; diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-types.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-types.ts new file mode 100644 index 000000000..b0694c6e3 --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + BitbucketConnectionSchema, + CreateBitbucketConnectionSchema, + ValidateBitbucketConnectionCredentialsSchema +} from "./bitbucket-connection-schemas"; + +export type TBitbucketConnection = z.infer; + +export type TBitbucketConnectionInput = z.infer & { + app: AppConnection.Bitbucket; +}; + +export type TValidateBitbucketConnectionCredentialsSchema = typeof ValidateBitbucketConnectionCredentialsSchema; + +export type TBitbucketConnectionConfig = DiscriminativePick< + TBitbucketConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TGetBitbucketRepositoriesDTO = { + connectionId: string; + workspaceSlug: string; +}; + +export type TBitbucketWorkspace = { + slug: string; +}; + +export type TBitbucketRepo = { + uuid: string; + full_name: string; // workspace-slug/repo-slug + slug: string; +}; diff --git a/backend/src/services/app-connection/bitbucket/index.ts b/backend/src/services/app-connection/bitbucket/index.ts new file mode 100644 index 000000000..634342ba7 --- /dev/null +++ b/backend/src/services/app-connection/bitbucket/index.ts @@ -0,0 +1,4 @@ +export * from "./bitbucket-connection-enums"; +export * from "./bitbucket-connection-fns"; +export * from "./bitbucket-connection-schemas"; +export * from "./bitbucket-connection-types"; diff --git a/backend/src/services/app-connection/railway/index.ts b/backend/src/services/app-connection/railway/index.ts new file mode 100644 index 000000000..a282bd9dd --- /dev/null +++ b/backend/src/services/app-connection/railway/index.ts @@ -0,0 +1,4 @@ +export * from "./railway-connection-constants"; +export * from "./railway-connection-fns"; +export * from "./railway-connection-schemas"; +export * from "./railway-connection-types"; diff --git a/backend/src/services/app-connection/railway/railway-connection-constants.ts b/backend/src/services/app-connection/railway/railway-connection-constants.ts new file mode 100644 index 000000000..aabec1027 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-constants.ts @@ -0,0 +1,5 @@ +export enum RailwayConnectionMethod { + AccountToken = "account-token", + ProjectToken = "project-token", + TeamToken = "team-token" +} diff --git a/backend/src/services/app-connection/railway/railway-connection-fns.ts b/backend/src/services/app-connection/railway/railway-connection-fns.ts new file mode 100644 index 000000000..7aa25b9f6 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-fns.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { RailwayConnectionMethod } from "./railway-connection-constants"; +import { RailwayPublicAPI } from "./railway-connection-public-client"; +import { TRailwayConnection, TRailwayConnectionConfig } from "./railway-connection-types"; + +export const getRailwayConnectionListItem = () => { + return { + name: "Railway" as const, + app: AppConnection.Railway as const, + methods: Object.values(RailwayConnectionMethod) + }; +}; + +export const validateRailwayConnectionCredentials = async (config: TRailwayConnectionConfig) => { + const { credentials, method } = config; + + try { + await RailwayPublicAPI.healthcheck({ + method, + credentials + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + return credentials; +}; + +export const listProjects = async (appConnection: TRailwayConnection) => { + const { credentials, method } = appConnection; + + try { + return await RailwayPublicAPI.listProjects({ + method, + credentials + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list projects: ${error.message || "Unknown error"}` + }); + } + + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: "Unable to list projects", + error + }); + } +}; diff --git a/backend/src/services/app-connection/railway/railway-connection-public-client.ts b/backend/src/services/app-connection/railway/railway-connection-public-client.ts new file mode 100644 index 000000000..1c8bd9cc2 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-public-client.ts @@ -0,0 +1,237 @@ +/* eslint-disable class-methods-use-this */ +import { AxiosError, AxiosInstance, AxiosResponse } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { RailwayConnectionMethod } from "./railway-connection-constants"; +import { + RailwayAccountWorkspaceListSchema, + RailwayGetProjectsByProjectTokenSchema, + RailwayGetSubscriptionTypeSchema, + RailwayProjectsListSchema +} from "./railway-connection-schemas"; +import { RailwayProject, TRailwayConnectionConfig, TRailwayResponse } from "./railway-connection-types"; + +type RailwaySendReqOptions = Pick; + +export function getRailwayAuthHeaders(method: RailwayConnectionMethod, token: string): Record { + switch (method) { + case RailwayConnectionMethod.AccountToken: + case RailwayConnectionMethod.TeamToken: + return { + Authorization: token + }; + case RailwayConnectionMethod.ProjectToken: + return { + "Project-Access-Token": token + }; + default: + throw new Error(`Unsupported Railway connection method`); + } +} + +export function getRailwayRatelimiter(headers: AxiosResponse["headers"]): { + isRatelimited: boolean; + maxAttempts: number; + wait: () => Promise; +} { + const retryAfter: number | undefined = headers["Retry-After"] as number | undefined; + const requestsLeft = parseInt(headers["X-RateLimit-Remaining"] as string, 10); + const limitResetAt = headers["X-RateLimit-Reset"] as string; + + const now = +new Date(); + const nextReset = +new Date(limitResetAt); + + const remaining = Math.min(0, nextReset - now); + + const wait = () => { + return new Promise((res) => { + setTimeout(res, remaining); + }); + }; + + return { + isRatelimited: Boolean(retryAfter || requestsLeft === 0), + wait, + maxAttempts: 3 + }; +} + +class RailwayPublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + method: "POST", + baseURL: IntegrationUrls.RAILWAY_API_URL, + headers: { + "Content-Type": "application/json" + } + }); + } + + async send( + query: string, + options: RailwaySendReqOptions, + variables: Record> = {}, + retryAttempt: number = 0 + ): Promise { + const body = { + query, + variables + }; + + const response = await this.client.request({ + data: body, + headers: getRailwayAuthHeaders(options.method, options.credentials.apiToken) + }); + + const { errors } = response.data; + + if (Array.isArray(errors) && errors.length > 0) { + throw new AxiosError(errors[0].message); + } + + const limiter = getRailwayRatelimiter(response.headers); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(query, options, variables, retryAttempt + 1); + } + + return response.data.data; + } + + healthcheck(config: RailwaySendReqOptions) { + switch (config.method) { + case RailwayConnectionMethod.AccountToken: + return this.send(`{ me { teams { edges { node { id } } } } }`, config); + case RailwayConnectionMethod.ProjectToken: + return this.send(`{ projectToken { projectId environmentId project { id } } }`, config); + case RailwayConnectionMethod.TeamToken: + return this.send(`{ projects { edges { node { id name team { id } } } } }`, config); + default: + throw new Error(`Unsupported Railway connection method`); + } + } + + async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) { + const res = await this.send( + `query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`, + config, + { + projectId: config.projectId + } + ); + + const data = await RailwayGetSubscriptionTypeSchema.parseAsync(res); + + return data.project.subscriptionType; + } + + async listProjects(config: RailwaySendReqOptions): Promise { + switch (config.method) { + case RailwayConnectionMethod.TeamToken: { + const res = await this.send( + `{ projects { edges { node { id, name, services{ edges{ node { id, name } } } environments { edges { node { name, id } } } } } } }`, + config + ); + + const data = await RailwayProjectsListSchema.parseAsync(res); + + return data.projects.edges.map((p) => ({ + id: p.node.id, + name: p.node.name, + environments: p.node.environments.edges.map((e) => e.node), + services: p.node.services.edges.map((s) => s.node) + })); + } + + case RailwayConnectionMethod.AccountToken: { + const res = await this.send( + `{ me { workspaces { id, name, team{ projects{ edges{ node{ id, name, services{ edges { node { name, id } } } environments { edges { node { name, id } } } } } } } } } }`, + config + ); + + const data = await RailwayAccountWorkspaceListSchema.parseAsync(res); + + return data.me.workspaces.flatMap((w) => + w.team.projects.edges.map((p) => ({ + id: p.node.id, + name: p.node.name, + environments: p.node.environments.edges.map((e) => e.node), + services: p.node.services.edges.map((s) => s.node) + })) + ); + } + + case RailwayConnectionMethod.ProjectToken: { + const res = await this.send( + `query { projectToken { project { id, name, services { edges { node { name, id } } } environments { edges { node { name, id } } } } } }`, + config + ); + + const data = await RailwayGetProjectsByProjectTokenSchema.parseAsync(res); + + const p = data.projectToken.project; + + return [ + { + id: p.id, + name: p.name, + environments: p.environments.edges.map((e) => e.node), + services: p.services.edges.map((s) => s.node) + } + ]; + } + + default: + throw new Error(`Unsupported Railway connection method`); + } + } + + async getVariables( + config: RailwaySendReqOptions, + variables: { projectId: string; environmentId: string; serviceId?: string } + ) { + const res = await this.send }>>( + `query variables($environmentId: String!, $projectId: String!, $serviceId: String) { variables( projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId ) }`, + config, + variables + ); + + if (!res?.variables) { + throw new BadRequestError({ + message: "Failed to get railway variables - empty response" + }); + } + + return res.variables; + } + + async deleteVariable( + config: RailwaySendReqOptions, + variables: { input: { projectId: string; environmentId: string; name: string; serviceId?: string } } + ) { + await this.send }>>( + `mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, + config, + variables + ); + } + + async upsertVariable( + config: RailwaySendReqOptions, + variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } } + ) { + await this.send }>>( + `mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`, + config, + variables + ); + } +} + +export const RailwayPublicAPI = new RailwayPublicClient(); diff --git a/backend/src/services/app-connection/railway/railway-connection-schemas.ts b/backend/src/services/app-connection/railway/railway-connection-schemas.ts new file mode 100644 index 000000000..066258f1e --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-schemas.ts @@ -0,0 +1,117 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { RailwayConnectionMethod } from "./railway-connection-constants"; + +export const RailwayConnectionMethodSchema = z + .nativeEnum(RailwayConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Railway).method); + +export const RailwayConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z + .string() + .trim() + .min(1, "API Token required") + .max(255) + .describe(AppConnections.CREDENTIALS.RAILWAY.apiToken) +}); + +const BaseRailwayConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Railway) +}); + +export const RailwayConnectionSchema = BaseRailwayConnectionSchema.extend({ + method: RailwayConnectionMethodSchema, + credentials: RailwayConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedRailwayConnectionSchema = z.discriminatedUnion("method", [ + BaseRailwayConnectionSchema.extend({ + method: RailwayConnectionMethodSchema, + credentials: RailwayConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateRailwayConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: RailwayConnectionMethodSchema, + credentials: RailwayConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Railway).credentials + ) + }) +]); + +export const CreateRailwayConnectionSchema = ValidateRailwayConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Railway) +); + +export const UpdateRailwayConnectionSchema = z + .object({ + credentials: RailwayConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Railway).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Railway)); + +export const RailwayConnectionListItemSchema = z.object({ + name: z.literal("Railway"), + app: z.literal(AppConnection.Railway), + methods: z.nativeEnum(RailwayConnectionMethod).array() +}); + +export const RailwayResourceSchema = z.object({ + node: z.object({ + id: z.string(), + name: z.string() + }) +}); + +export const RailwayProjectEdgeSchema = z.object({ + node: z.object({ + id: z.string(), + name: z.string(), + services: z.object({ + edges: z.array(RailwayResourceSchema) + }), + environments: z.object({ + edges: z.array(RailwayResourceSchema) + }) + }) +}); + +export const RailwayProjectsListSchema = z.object({ + projects: z.object({ + edges: z.array(RailwayProjectEdgeSchema) + }) +}); + +export const RailwayAccountWorkspaceListSchema = z.object({ + me: z.object({ + workspaces: z.array( + z.object({ + id: z.string(), + name: z.string(), + team: RailwayProjectsListSchema + }) + ) + }) +}); + +export const RailwayGetProjectsByProjectTokenSchema = z.object({ + projectToken: z.object({ + project: RailwayProjectEdgeSchema.shape.node + }) +}); + +export const RailwayGetSubscriptionTypeSchema = z.object({ + project: z.object({ + subscriptionType: z.enum(["free", "hobby", "pro", "trial"]) + }) +}); diff --git a/backend/src/services/app-connection/railway/railway-connection-service.ts b/backend/src/services/app-connection/railway/railway-connection-service.ts new file mode 100644 index 000000000..379f36456 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getRailwayProjects } from "./railway-connection-fns"; +import { TRailwayConnection } from "./railway-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const railwayConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Railway, connectionId, actor); + try { + const projects = await getRailwayProjects(appConnection); + + return projects; + } catch (error) { + logger.error(error, "Failed to establish connection with Railway"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/railway/railway-connection-types.ts b/backend/src/services/app-connection/railway/railway-connection-types.ts new file mode 100644 index 000000000..66b6b549f --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-types.ts @@ -0,0 +1,79 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateRailwayConnectionSchema, + RailwayConnectionSchema, + ValidateRailwayConnectionCredentialsSchema +} from "./railway-connection-schemas"; + +export type TRailwayConnection = z.infer; + +export type TRailwayConnectionInput = z.infer & { + app: AppConnection.Railway; +}; + +export type TValidateRailwayConnectionCredentialsSchema = typeof ValidateRailwayConnectionCredentialsSchema; + +export type TRailwayConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TRailwayService = { + id: string; + name: string; +}; + +export type TRailwayEnvironment = { + id: string; + name: string; +}; + +export type RailwayProject = { + id: string; + name: string; + services: TRailwayService[]; + environments: TRailwayEnvironment[]; +}; + +export type TRailwayResponse = { + data?: T; + errors?: { + message: string; + }[]; +}; + +export type TAccountProjectListResponse = TRailwayResponse<{ + projects: { + edges: TProjectEdge[]; + }; +}>; + +export interface TProjectEdge { + node: { + id: string; + name: string; + services: { + edges: TServiceEdge[]; + }; + environments: { + edges: TEnvironmentEdge[]; + }; + }; +} + +type TServiceEdge = { + node: { + id: string; + name: string; + }; +}; + +type TEnvironmentEdge = { + node: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/app-connection/zabbix/index.ts b/backend/src/services/app-connection/zabbix/index.ts new file mode 100644 index 000000000..0de17bde7 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/index.ts @@ -0,0 +1,4 @@ +export * from "./zabbix-connection-enums"; +export * from "./zabbix-connection-fns"; +export * from "./zabbix-connection-schemas"; +export * from "./zabbix-connection-types"; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts new file mode 100644 index 000000000..690d7c609 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts @@ -0,0 +1,3 @@ +export enum ZabbixConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts new file mode 100644 index 000000000..a34a6c381 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts @@ -0,0 +1,108 @@ +import { AxiosError } from "axios"; +import RE2 from "re2"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { ZabbixConnectionMethod } from "./zabbix-connection-enums"; +import { + TZabbixConnection, + TZabbixConnectionConfig, + TZabbixHost, + TZabbixHostListResponse +} from "./zabbix-connection-types"; + +const TRAILING_SLASH_REGEX = new RE2("/+$"); + +export const getZabbixConnectionListItem = () => { + return { + name: "Zabbix" as const, + app: AppConnection.Zabbix as const, + methods: Object.values(ZabbixConnectionMethod) as [ZabbixConnectionMethod.ApiToken] + }; +}; + +export const validateZabbixConnectionCredentials = async (config: TZabbixConnectionConfig) => { + const { apiToken, instanceUrl } = config.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + try { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + const payload = { + jsonrpc: "2.0", + method: "authentication.get", + params: { + output: "extend" + }, + id: 1 + }; + + const response: { data: { error?: { message: string }; result?: string } } = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + if (response.data.error) { + throw new BadRequestError({ + message: response.data.error.message + }); + } + + return config.credentials; + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to connect to Zabbix instance: ${error.message}` + }); + } + throw error; + } +}; + +export const listZabbixHosts = async (appConnection: TZabbixConnection): Promise => { + const { apiToken, instanceUrl } = appConnection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + try { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + const payload = { + jsonrpc: "2.0", + method: "host.get", + params: { + output: ["hostid", "host"], + sortfield: "host", + sortorder: "ASC" + }, + id: 1 + }; + + const response: { data: TZabbixHostListResponse } = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + return response.data.result + ? response.data.result.map((host) => ({ + hostId: host.hostid, + host: host.host + })) + : []; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } +}; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts new file mode 100644 index 000000000..23bffd859 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts @@ -0,0 +1,62 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { ZabbixConnectionMethod } from "./zabbix-connection-enums"; + +export const ZabbixConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z + .string() + .trim() + .min(1, "API Token required") + .max(1000) + .describe(AppConnections.CREDENTIALS.ZABBIX.apiToken), + instanceUrl: z.string().trim().url("Invalid Instance URL").describe(AppConnections.CREDENTIALS.ZABBIX.instanceUrl) +}); + +const BaseZabbixConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Zabbix) }); + +export const ZabbixConnectionSchema = BaseZabbixConnectionSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: ZabbixConnectionApiTokenCredentialsSchema +}); + +export const SanitizedZabbixConnectionSchema = z.discriminatedUnion("method", [ + BaseZabbixConnectionSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: ZabbixConnectionApiTokenCredentialsSchema.pick({ instanceUrl: true }) + }) +]); + +export const ValidateZabbixConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(ZabbixConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.Zabbix).method), + credentials: ZabbixConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Zabbix).credentials + ) + }) +]); + +export const CreateZabbixConnectionSchema = ValidateZabbixConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Zabbix) +); + +export const UpdateZabbixConnectionSchema = z + .object({ + credentials: ZabbixConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Zabbix).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Zabbix)); + +export const ZabbixConnectionListItemSchema = z.object({ + name: z.literal("Zabbix"), + app: z.literal(AppConnection.Zabbix), + methods: z.nativeEnum(ZabbixConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts new file mode 100644 index 000000000..e8c8f8018 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listZabbixHosts } from "./zabbix-connection-fns"; +import { TZabbixConnection } from "./zabbix-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const zabbixConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listHosts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Zabbix, connectionId, actor); + + try { + const hosts = await listZabbixHosts(appConnection); + return hosts; + } catch (error) { + logger.error(error, "Failed to establish connection with zabbix"); + return []; + } + }; + + return { + listHosts + }; +}; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts new file mode 100644 index 000000000..08b4c685f --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts @@ -0,0 +1,33 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateZabbixConnectionSchema, + ValidateZabbixConnectionCredentialsSchema, + ZabbixConnectionSchema +} from "./zabbix-connection-schemas"; + +export type TZabbixConnection = z.infer; + +export type TZabbixConnectionInput = z.infer & { + app: AppConnection.Zabbix; +}; + +export type TValidateZabbixConnectionCredentialsSchema = typeof ValidateZabbixConnectionCredentialsSchema; + +export type TZabbixConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TZabbixHost = { + hostId: string; + host: string; +}; + +export type TZabbixHostListResponse = { + jsonrpc: string; + result: { hostid: string; host: string }[]; + error?: { message: string }; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index f354477cf..7ee051d88 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -93,23 +93,25 @@ export const identityProjectServiceFactory = ({ projectId ); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to assign to role", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); + if (requestedRoleChange !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to assign to role", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } } // validate custom roles input diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 4ea382f9e..7c76520b3 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -69,23 +69,25 @@ export const identityServiceFactory = ({ orgId ); const isCustomRole = Boolean(customRole); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GrantPrivileges, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to create identity", - membership.shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GrantPrivileges, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); + if (role !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const plan = await licenseService.getPlan(orgId); @@ -187,6 +189,7 @@ export const identityServiceFactory = ({ ), details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions } }); + if (isCustomRole) customRole = customOrgRole; } diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 4a5fd8231..c549d1265 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -814,9 +814,9 @@ const getAppsCloudflareWorkers = async ({ accessToken, accountId }: { accessToke }; /** - * Return list of repositories for the BitBucket integration based on provided BitBucket workspace + * Return list of repositories for the Bitbucket integration based on provided Bitbucket workspace */ -const getAppsBitBucket = async ({ accessToken, workspaceSlug }: { accessToken: string; workspaceSlug?: string }) => { +const getAppsBitbucket = async ({ accessToken, workspaceSlug }: { accessToken: string; workspaceSlug?: string }) => { interface RepositoriesResponse { size: number; page: number; @@ -1302,7 +1302,7 @@ export const getApps = async ({ }); case Integrations.BITBUCKET: - return getAppsBitBucket({ + return getAppsBitbucket({ accessToken, workspaceSlug }); diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 9b9841f1f..0608bbd4b 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -342,7 +342,7 @@ export const getIntegrationOptions = async () => { { name: "Bitbucket", slug: "bitbucket", - image: "BitBucket.png", + image: "Bitbucket.png", isAvailable: true, type: "oauth", clientId: appCfg.CLIENT_ID_BITBUCKET, diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 989a5a88c..1cd4569ac 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -3921,9 +3921,9 @@ const syncSecretsCloudflareWorkers = async ({ }; /** - * Sync/push [secrets] to BitBucket repo with name [integration.app] + * Sync/push [secrets] to Bitbucket repo with name [integration.app] */ -const syncSecretsBitBucket = async ({ +const syncSecretsBitbucket = async ({ integration, secrets, accessToken @@ -4832,7 +4832,7 @@ export const syncIntegrationSecrets = async ({ }); break; case Integrations.BITBUCKET: - await syncSecretsBitBucket({ + await syncSecretsBitbucket({ integration, secrets, accessToken diff --git a/backend/src/services/integration-auth/integration-token.ts b/backend/src/services/integration-auth/integration-token.ts index 362b20a07..a15c9dd1f 100644 --- a/backend/src/services/integration-auth/integration-token.ts +++ b/backend/src/services/integration-auth/integration-token.ts @@ -64,7 +64,7 @@ type ExchangeCodeGitlabResponse = { created_at: number; }; -type ExchangeCodeBitBucketResponse = { +type ExchangeCodeBitbucketResponse = { access_token: string; token_type: string; expires_in: number; @@ -392,10 +392,10 @@ const exchangeCodeGitlab = async ({ code, url }: { code: string; url?: string }) }; /** - * Return [accessToken], [accessExpiresAt], and [refreshToken] for BitBucket + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Bitbucket * code-token exchange */ -const exchangeCodeBitBucket = async ({ code }: { code: string }) => { +const exchangeCodeBitbucket = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); const appCfg = getConfig(); if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) { @@ -403,7 +403,7 @@ const exchangeCodeBitBucket = async ({ code }: { code: string }) => { } const res = ( - await request.post( + await request.post( IntegrationUrls.BITBUCKET_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", @@ -490,7 +490,7 @@ export const exchangeCode = async ({ url }); case Integrations.BITBUCKET: - return exchangeCodeBitBucket({ + return exchangeCodeBitbucket({ code }); default: @@ -524,7 +524,7 @@ type RefreshTokenGitLabResponse = { created_at: number; }; -type RefreshTokenBitBucketResponse = { +type RefreshTokenBitbucketResponse = { access_token: string; token_type: string; expires_in: number; @@ -653,9 +653,9 @@ const exchangeRefreshGitLab = async ({ refreshToken, url }: { url?: string | nul /** * Return new access token by exchanging refresh token [refreshToken] for the - * BitBucket integration + * Bitbucket integration */ -const exchangeRefreshBitBucket = async ({ refreshToken }: { refreshToken: string }) => { +const exchangeRefreshBitbucket = async ({ refreshToken }: { refreshToken: string }) => { const accessExpiresAt = new Date(); const appCfg = getConfig(); if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) { @@ -664,7 +664,7 @@ const exchangeRefreshBitBucket = async ({ refreshToken }: { refreshToken: string const { data }: { - data: RefreshTokenBitBucketResponse; + data: RefreshTokenBitbucketResponse; } = await request.post( IntegrationUrls.BITBUCKET_TOKEN_URL, new URLSearchParams({ @@ -794,7 +794,7 @@ export const exchangeRefresh = async ( url }); case Integrations.BITBUCKET: - return exchangeRefreshBitBucket({ + return exchangeRefreshBitbucket({ refreshToken }); case Integrations.GCP_SECRET_MANAGER: diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 68b117202..ed4867025 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -103,8 +103,54 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findRecentInvitedMemberships = async () => { + try { + const now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const twelveMonthsAgo = new Date(now.getTime() - 360 * 24 * 60 * 60 * 1000); + + const memberships = await db + .replicaNode()(TableName.OrgMembership) + .where("status", "invited") + .where((qb) => { + // lastInvitedAt is null AND createdAt is between 1 week and 12 months ago + void qb + .whereNull(`${TableName.OrgMembership}.lastInvitedAt`) + .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); + }) + .orWhere((qb) => { + // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago + void qb + .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) + .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + }); + + return memberships; + } catch (error) { + throw new DatabaseError({ + error, + name: "Find recent invited memberships" + }); + } + }; + + const updateLastInvitedAtByIds = async (membershipIds: string[]) => { + try { + if (membershipIds.length === 0) return; + await db(TableName.OrgMembership).whereIn("id", membershipIds).update({ lastInvitedAt: new Date() }); + } catch (error) { + throw new DatabaseError({ + error, + name: "Update last invited at by ids" + }); + } + }; + return { ...orgMembershipOrm, - findOrgMembershipById + findOrgMembershipById, + findRecentInvitedMemberships, + updateLastInvitedAtByIds }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1d72da16e..5483dbfa5 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -36,6 +36,8 @@ import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; +import { applyJitter } from "@app/lib/dates"; +import { delay as delayMs } from "@app/lib/delay"; import { BadRequestError, ForbiddenRequestError, @@ -44,8 +46,10 @@ import { UnauthorizedError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { QueueName } from "@app/queue"; import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -107,7 +111,15 @@ type TOrgServiceFactoryDep = { "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" >; projectKeyDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + | "findOrgMembershipById" + | "findOne" + | "findById" + | "findRecentInvitedMemberships" + | "updateById" + | "updateLastInvitedAtByIds" + >; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; oidcConfigDAL: Pick; @@ -760,6 +772,10 @@ export const orgServiceFactory = ({ } }); + await orgMembershipDAL.updateById(inviteeOrgMembership.id, { + lastInvitedAt: new Date() + }); + return { signupToken: undefined }; }; @@ -1422,6 +1438,63 @@ export const orgServiceFactory = ({ return incidentContact; }; + /** + * Re-send emails to users who haven't accepted an invite yet + */ + const notifyInvitedUsers = async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: notify invited users started`); + + const invitedUsers = await orgMembershipDAL.findRecentInvitedMemberships(); + const appCfg = getConfig(); + + const orgCache: Record = {}; + const notifiedUsers: string[] = []; + + await Promise.all( + invitedUsers.map(async (invitedUser) => { + let org = orgCache[invitedUser.orgId]; + if (!org) { + org = await orgDAL.findById(invitedUser.orgId); + orgCache[invitedUser.orgId] = org; + } + + if (!org || !invitedUser.userId) return; + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: invitedUser.userId, + orgId: org.id + }); + + if (invitedUser.inviteEmail) { + await delayMs(Math.max(0, applyJitter(0, 2000))); + + try { + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: `Reminder: You have been invited to ${org.name} on Infisical`, + recipients: [invitedUser.inviteEmail], + substitutions: { + organizationName: org.name, + email: invitedUser.inviteEmail, + organizationId: org.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + notifiedUsers.push(invitedUser.id); + } catch (err) { + logger.error(err, `${QueueName.DailyResourceCleanUp}: notify invited users failed to send email`); + } + } + }) + ); + + await orgMembershipDAL.updateLastInvitedAtByIds(notifiedUsers); + + logger.info(`${QueueName.DailyResourceCleanUp}: notify invited users completed`); + }; + return { findOrganizationById, findAllOrgMembers, @@ -1445,6 +1518,7 @@ export const orgServiceFactory = ({ listProjectMembershipsByOrgMembershipId, findOrgBySlug, resendOrgMemberInvitation, - upgradePrivilegeSystem + upgradePrivilegeSystem, + notifyInvitedUsers }; }; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 77cd6e603..bfaef5708 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -5,6 +5,7 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; +import { TOrgServiceFactory } from "../org/org-service"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; @@ -22,6 +23,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { secretSharingDAL: Pick; serviceTokenService: Pick; queueService: TQueueServiceFactory; + orgService: TOrgServiceFactory; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -36,11 +38,11 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretSharingDAL, secretVersionV2DAL, identityUniversalAuthClientSecretDAL, - serviceTokenService + serviceTokenService, + orgService }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); - await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); await secretSharingDAL.pruneExpiredSharedSecrets(); @@ -50,6 +52,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); await serviceTokenService.notifyExpiringTokens(); + await orgService.notifyInvitedUsers(); + await auditLogDAL.pruneAuditLog(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 37a595daf..e8d0b05e5 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; -import { TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -214,7 +214,7 @@ export const secretFolderServiceFactory = ({ } }, message: "Folder created", - folderId: doc.id, + folderId: parentFolder.id, changes: [ { type: CommitType.ADD, @@ -469,15 +469,41 @@ export const secretFolderServiceFactory = ({ const $checkFolderPolicy = async ({ projectId, - environment, - parentId + env, + parentId, + idOrName }: { projectId: string; - environment: string; + env: TProjectEnvironments; parentId: string; + idOrName: string; }) => { + let targetFolder = await folderDAL + .findOne({ + envId: env.id, + name: idOrName, + parentId, + isReserved: false + }) + .catch(() => null); + + if (!targetFolder && uuidValidate(idOrName)) { + targetFolder = await folderDAL + .findOne({ + envId: env.id, + id: idOrName, + parentId, + isReserved: false + }) + .catch(() => null); + } + + if (!targetFolder) { + throw new NotFoundError({ message: `Target folder not found` }); + } + // get environment root folder (as it's needed to get all folders under it) - const rootFolder = await folderDAL.findBySecretPath(projectId, environment, "/"); + const rootFolder = await folderDAL.findBySecretPath(projectId, env.slug, "/"); if (!rootFolder) throw new NotFoundError({ message: `Root folder not found` }); // get all folders under environment root folder const folderPaths = await folderDAL.findByEnvsDeep({ parentIds: [rootFolder.id] }); @@ -492,7 +518,13 @@ export const secretFolderServiceFactory = ({ folderMap.get(normalizeKey(folder.parentId))?.push(folder); } - // Recursively collect all folders under the given parentId + // Find the target folder in the folderPaths to get its full details + const targetFolderWithPath = folderPaths.find((f) => f.id === targetFolder!.id); + if (!targetFolderWithPath) { + throw new NotFoundError({ message: `Target folder path not found` }); + } + + // Recursively collect all folders under the target folder (descendants only) const collectDescendants = ( id: string ): (TSecretFolders & { path: string; depth: number; environment: string })[] => { @@ -500,23 +532,31 @@ export const secretFolderServiceFactory = ({ return [...children, ...children.flatMap((child) => collectDescendants(child.id))]; }; - const foldersUnderParent = collectDescendants(parentId); + const targetFolderDescendants = collectDescendants(targetFolder.id); - const folderPolicyPaths = foldersUnderParent.map((folder) => ({ + // Include the target folder itself plus all its descendants + const foldersToCheck = [targetFolderWithPath, ...targetFolderDescendants]; + + const folderPolicyPaths = foldersToCheck.map((folder) => ({ path: folder.path, id: folder.id })); // get secrets under the given folders - const secrets = await secretV2BridgeDAL.findByFolderIds({ folderIds: folderPolicyPaths.map((p) => p.id) }); + const secrets = await secretV2BridgeDAL.findByFolderIds({ + folderIds: folderPolicyPaths.map((p) => p.id) + }); + for await (const folderPolicyPath of folderPolicyPaths) { // eslint-disable-next-line no-continue if (!secrets.some((s) => s.folderId === folderPolicyPath.id)) continue; + const policy = await secretApprovalPolicyService.getSecretApprovalPolicy( projectId, - environment, + env.slug, folderPolicyPath.path ); + // if there is a policy and there are secrets under the given folder, throw error if (policy) { throw new BadRequestError({ @@ -560,20 +600,42 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, environment, parentId: parentFolder.id }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + + let folderToDelete = await folderDAL + .findOne({ + envId: env.id, + name: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + + if (!folderToDelete && uuidValidate(idOrName)) { + folderToDelete = await folderDAL + .findOne({ + envId: env.id, + id: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + } + + if (!folderToDelete) { + throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); + } const [doc] = await folderDAL.delete( { envId: env.id, - [uuidValidate(idOrName) ? "id" : "name"]: idOrName, + id: folderToDelete.id, parentId: parentFolder.id, isReserved: false }, tx ); - if (!doc) throw new NotFoundError({ message: `Failed to delete folder with ID '${idOrName}', not found` }); - const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx); await folderCommitService.createCommit( diff --git a/backend/src/services/secret-sync/railway/railway-sync-constants.ts b/backend/src/services/secret-sync/railway/railway-sync-constants.ts new file mode 100644 index 000000000..a77311bbf --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const RAILWAY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Railway", + destination: SecretSync.Railway, + connection: AppConnection.Railway, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts new file mode 100644 index 000000000..07862aeb5 --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { RailwayPublicAPI } from "@app/services/app-connection/railway/railway-connection-public-client"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; + +import { SecretSyncError } from "../secret-sync-errors"; +import { TSecretMap } from "../secret-sync-types"; +import { TRailwaySyncWithCredentials } from "./railway-sync-types"; + +export const RailwaySyncFns = { + async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise { + try { + const config = secretSync.destinationConfig; + + const variables = await RailwayPublicAPI.getVariables(secretSync.connection, { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined + }); + + const entries = {} as TSecretMap; + + for (const [key, value] of Object.entries(variables)) { + // Skip importing private railway variables + // eslint-disable-next-line no-continue + if (key.startsWith("RAILWAY_")) continue; + + entries[key] = { + value + }; + } + + return entries; + } catch (error) { + throw new SecretSyncError({ + error, + message: "Failed to import secrets from Railway" + }); + } + }, + + async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + const railwaySecrets = await this.getSecrets(secretSync); + const config = secretSync.destinationConfig; + + for await (const key of Object.keys(secretMap)) { + try { + const existing = railwaySecrets[key]; + + if (existing === undefined || existing.value !== secretMap[key].value) { + await RailwayPublicAPI.upsertVariable(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + name: key, + value: secretMap[key].value ?? "" + } + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(railwaySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await RailwayPublicAPI.deleteVariable(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + name: key + } + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }, + + async removeSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { + const existing = await this.getSecrets(secretSync); + const config = secretSync.destinationConfig; + + for await (const secret of Object.keys(existing)) { + try { + if (secret in secretMap) { + await RailwayPublicAPI.deleteVariable(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + name: secret + } + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-schemas.ts b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts new file mode 100644 index 000000000..56cea0408 --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const RailwaySyncDestinationConfigSchema = z.object({ + projectId: z + .string() + .trim() + .min(1, "Railway project ID required") + .describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.projectId), + projectName: z.string().trim().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.projectName), + environmentId: z + .string() + .trim() + .min(1, "Railway environment ID required") + .describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.environmentId), + environmentName: z.string().trim().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.environmentName), + serviceId: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.serviceId), + serviceName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.serviceName) +}); + +const RailwaySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const RailwaySyncSchema = BaseSecretSyncSchema(SecretSync.Railway, RailwaySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Railway), + destinationConfig: RailwaySyncDestinationConfigSchema +}); + +export const CreateRailwaySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Railway, + RailwaySyncOptionsConfig +).extend({ + destinationConfig: RailwaySyncDestinationConfigSchema +}); + +export const UpdateRailwaySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Railway, + RailwaySyncOptionsConfig +).extend({ + destinationConfig: RailwaySyncDestinationConfigSchema.optional() +}); + +export const RailwaySyncListItemSchema = z.object({ + name: z.literal("Railway"), + connection: z.literal(AppConnection.Railway), + destination: z.literal(SecretSync.Railway), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/railway/railway-sync-types.ts b/backend/src/services/secret-sync/railway/railway-sync-types.ts new file mode 100644 index 000000000..d2165072d --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-types.ts @@ -0,0 +1,31 @@ +import z from "zod"; + +import { TRailwayConnection } from "@app/services/app-connection/railway"; + +import { CreateRailwaySyncSchema, RailwaySyncListItemSchema, RailwaySyncSchema } from "./railway-sync-schemas"; + +export type TRailwaySyncListItem = z.infer; + +export type TRailwaySync = z.infer; + +export type TRailwaySyncInput = z.infer; + +export type TRailwaySyncWithCredentials = TRailwaySync & { + connection: TRailwayConnection; +}; + +export type TRailwaySecret = { + createdAt: string; + environmentId?: string | null; + id: string; + isSealed: boolean; + name: string; + serviceId?: string | null; + updatedAt: string; +}; + +export type TRailwayVariablesGraphResponse = { + data: { + variables: Record; + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index b70b37caf..c7dc0c9bb 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -20,7 +20,9 @@ export enum SecretSync { Render = "render", Flyio = "flyio", GitLab = "gitlab", - CloudflarePages = "cloudflare-pages" + CloudflarePages = "cloudflare-pages", + Zabbix = "zabbix", + Railway = "railway" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 9d0513a2c..34b24eece 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -39,12 +39,15 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; +import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; +import { ZABBIX_SYNC_LIST_OPTION, ZabbixSyncFns } from "./zabbix"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -68,7 +71,9 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION, [SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION, - [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION + [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION, + [SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION, + [SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -236,6 +241,10 @@ export const SecretSyncFns = { return GitLabSyncFns.syncSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.CloudflarePages: return CloudflarePagesSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Zabbix: + return ZabbixSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Railway: + return RailwaySyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -328,6 +337,12 @@ export const SecretSyncFns = { case SecretSync.CloudflarePages: secretMap = await CloudflarePagesSyncFns.getSecrets(secretSync); break; + case SecretSync.Zabbix: + secretMap = await ZabbixSyncFns.getSecrets(secretSync); + break; + case SecretSync.Railway: + secretMap = await RailwaySyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -405,6 +420,10 @@ export const SecretSyncFns = { return GitLabSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.CloudflarePages: return CloudflarePagesSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Zabbix: + return ZabbixSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Railway: + return RailwaySyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 1dc0ea6c0..938679332 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -23,7 +23,9 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Render]: "Render", [SecretSync.Flyio]: "Fly.io", [SecretSync.GitLab]: "GitLab", - [SecretSync.CloudflarePages]: "Cloudflare Pages" + [SecretSync.CloudflarePages]: "Cloudflare Pages", + [SecretSync.Zabbix]: "Zabbix", + [SecretSync.Railway]: "Railway" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -48,7 +50,9 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, [SecretSync.GitLab]: AppConnection.GitLab, - [SecretSync.CloudflarePages]: AppConnection.Cloudflare + [SecretSync.CloudflarePages]: AppConnection.Cloudflare, + [SecretSync.Zabbix]: AppConnection.Zabbix, + [SecretSync.Railway]: AppConnection.Railway }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -73,5 +77,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Render]: SecretSyncPlanType.Regular, [SecretSync.Flyio]: SecretSyncPlanType.Regular, [SecretSync.GitLab]: SecretSyncPlanType.Regular, - [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular + [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular, + [SecretSync.Zabbix]: SecretSyncPlanType.Regular, + [SecretSync.Railway]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index a31183280..7eaba35f2 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -94,6 +94,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TRailwaySync, + TRailwaySyncInput, + TRailwaySyncListItem, + TRailwaySyncWithCredentials +} from "./railway/railway-sync-types"; import { TRenderSync, TRenderSyncInput, @@ -113,6 +119,7 @@ import { TTerraformCloudSyncWithCredentials } from "./terraform-cloud"; import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; +import { TZabbixSync, TZabbixSyncInput, TZabbixSyncListItem, TZabbixSyncWithCredentials } from "./zabbix"; export type TSecretSync = | TAwsParameterStoreSync @@ -136,7 +143,9 @@ export type TSecretSync = | TRenderSync | TFlyioSync | TGitLabSync - | TCloudflarePagesSync; + | TCloudflarePagesSync + | TZabbixSync + | TRailwaySync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -160,7 +169,9 @@ export type TSecretSyncWithCredentials = | TRenderSyncWithCredentials | TFlyioSyncWithCredentials | TGitLabSyncWithCredentials - | TCloudflarePagesSyncWithCredentials; + | TCloudflarePagesSyncWithCredentials + | TZabbixSyncWithCredentials + | TRailwaySyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -184,7 +195,9 @@ export type TSecretSyncInput = | TRenderSyncInput | TFlyioSyncInput | TGitLabSyncInput - | TCloudflarePagesSyncInput; + | TCloudflarePagesSyncInput + | TZabbixSyncInput + | TRailwaySyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -208,7 +221,9 @@ export type TSecretSyncListItem = | TRenderSyncListItem | TFlyioSyncListItem | TGitLabSyncListItem - | TCloudflarePagesSyncListItem; + | TCloudflarePagesSyncListItem + | TZabbixSyncListItem + | TRailwaySyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/zabbix/index.ts b/backend/src/services/secret-sync/zabbix/index.ts new file mode 100644 index 000000000..a49d8e14c --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/index.ts @@ -0,0 +1,5 @@ +export * from "./zabbix-sync-constants"; +export * from "./zabbix-sync-enums"; +export * from "./zabbix-sync-fns"; +export * from "./zabbix-sync-schemas"; +export * from "./zabbix-sync-types"; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts new file mode 100644 index 000000000..51c1ca793 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const ZABBIX_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Zabbix", + destination: SecretSync.Zabbix, + connection: AppConnection.Zabbix, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts new file mode 100644 index 000000000..8f4c8c5d6 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts @@ -0,0 +1,4 @@ +export enum ZabbixSyncScope { + Global = "global", + Host = "host" +} diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts new file mode 100644 index 000000000..18f9061ec --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts @@ -0,0 +1,285 @@ +import RE2 from "re2"; + +import { request } from "@app/lib/config/request"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { + TZabbixSecret, + TZabbixSyncWithCredentials, + ZabbixApiResponse, + ZabbixMacroCreateResponse, + ZabbixMacroDeleteResponse +} from "@app/services/secret-sync/zabbix/zabbix-sync-types"; + +import { ZabbixSyncScope } from "./zabbix-sync-enums"; + +const TRAILING_SLASH_REGEX = new RE2("/+$"); +const MACRO_START_REGEX = new RE2("^\\{\\$"); +const MACRO_END_REGEX = new RE2("\\}$"); + +const extractMacroKey = (macro: string): string => { + return macro.replace(MACRO_START_REGEX, "").replace(MACRO_END_REGEX, ""); +}; + +// Helper function to handle Zabbix API responses and errors +const handleZabbixResponse = (response: ZabbixApiResponse): T => { + if (response.data.error) { + const errorMessage = response.data.error.data + ? `${response.data.error.message}: ${response.data.error.data}` + : response.data.error.message; + throw new SecretSyncError({ + error: new Error(`Zabbix API Error (${response.data.error.code}): ${errorMessage}`) + }); + } + + if (response.data.result === undefined) { + throw new SecretSyncError({ + error: new Error("Zabbix API returned no result") + }); + } + + return response.data.result; +}; + +const listZabbixSecrets = async (apiToken: string, instanceUrl: string, hostId?: string): Promise => { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + // - jsonrpc: Specifies the JSON-RPC protocol version. + // - method: The API method to call, in this case "usermacro.get" for retrieving user macros. + // - id: A unique identifier for the request. Required by JSON-RPC but not used by the API for logic. Typically set to any integer. + const payload = { + jsonrpc: "2.0" as const, + method: "usermacro.get", + params: hostId ? { output: "extend", hostids: hostId } : { output: "extend", globalmacro: true }, + id: 1 + }; + + try { + const response: ZabbixApiResponse = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + return handleZabbixResponse(response) || []; + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to list Zabbix secrets") + }); + } +}; + +const putZabbixSecrets = async ( + apiToken: string, + instanceUrl: string, + secretMap: TSecretMap, + destinationConfig: TZabbixSyncWithCredentials["destinationConfig"], + existingSecrets: TZabbixSecret[] +): Promise => { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + const existingMacroMap = new Map(existingSecrets.map((secret) => [secret.macro, secret])); + + for (const [key, secret] of Object.entries(secretMap)) { + const macroKey = `{$${key.toUpperCase()}}`; + const existingMacro = existingMacroMap.get(macroKey); + + try { + if (existingMacro) { + // Update existing macro + const updatePayload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.update" : "usermacro.updateglobal", + params: { + [hostId ? "hostmacroid" : "globalmacroid"]: existingMacro[hostId ? "hostmacroid" : "globalmacroid"], + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + }, + id: 1 + }; + + // eslint-disable-next-line no-await-in-loop + const response: ZabbixApiResponse = await request.post(apiUrl, updatePayload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } else { + // Create new macro + const createPayload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.create" : "usermacro.createglobal", + params: hostId + ? { + hostid: hostId, + macro: macroKey, + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + } + : { + macro: macroKey, + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + }, + id: 1 + }; + + // eslint-disable-next-line no-await-in-loop + const response: ZabbixApiResponse = await request.post(apiUrl, createPayload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error(`Failed to sync secret ${key}`) + }); + } + } +}; + +const deleteZabbixSecrets = async ( + apiToken: string, + instanceUrl: string, + keys: string[], + hostId?: string +): Promise => { + if (keys.length === 0) return; + + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + try { + // Get existing macros to find their IDs + const existingSecrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + const macroIds = existingSecrets + .filter((secret) => keys.includes(secret.macro)) + .map((secret) => secret[hostId ? "hostmacroid" : "globalmacroid"]) + .filter(Boolean); + + if (macroIds.length === 0) return; + + const payload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.delete" : "usermacro.deleteglobal", + params: macroIds, + id: 1 + }; + + const response: ZabbixApiResponse = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to delete Zabbix secrets") + }); + } +}; + +export const ZabbixSyncFns = { + syncSecrets: async (secretSync: TZabbixSyncWithCredentials, secretMap: TSecretMap) => { + const { connection, environment, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + let secrets: TZabbixSecret[] = []; + try { + secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to list Zabbix secrets") + }); + } + + try { + await putZabbixSecrets(apiToken, instanceUrl, secretMap, destinationConfig, secrets); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to sync secrets") + }); + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + try { + const shapedSecretMapKeys = Object.keys(secretMap).map((key) => key.toUpperCase()); + + const keys = secrets + .filter( + (secret) => + matchesSchema(secret.macro, environment?.slug || "", secretSync.syncOptions.keySchema) && + !shapedSecretMapKeys.includes(extractMacroKey(secret.macro)) + ) + .map((secret) => secret.macro); + + await deleteZabbixSecrets(apiToken, instanceUrl, keys, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to delete orphaned secrets") + }); + } + }, + + removeSecrets: async (secretSync: TZabbixSyncWithCredentials, secretMap: TSecretMap) => { + const { connection, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + try { + const secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + + const shapedSecretMapKeys = Object.keys(secretMap).map((key) => key.toUpperCase()); + const keys = secrets + .filter((secret) => shapedSecretMapKeys.includes(extractMacroKey(secret.macro))) + .map((secret) => secret.macro); + + await deleteZabbixSecrets(apiToken, instanceUrl, keys, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to remove secrets") + }); + } + }, + + getSecrets: async (secretSync: TZabbixSyncWithCredentials) => { + const { connection, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + try { + const secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + return Object.fromEntries( + secrets.map((secret) => [ + extractMacroKey(secret.macro), + { value: secret.value ?? "", comment: secret.description } + ]) + ); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to get secrets") + }); + } + } +}; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts new file mode 100644 index 000000000..94a729cb6 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { ZabbixSyncScope } from "./zabbix-sync-enums"; + +const ZabbixSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(ZabbixSyncScope.Host).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.scope), + hostId: z.string().trim().min(1, "Host required").max(255).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.hostId), + hostName: z + .string() + .trim() + .min(1, "Host name required") + .max(255) + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.hostName), + macroType: z + .number() + .min(0, "Macro type required") + .max(1, "Macro type required") + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.macroType) + }), + z.object({ + scope: z.literal(ZabbixSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.scope), + macroType: z + .number() + .min(0, "Macro type required") + .max(1, "Macro type required") + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.macroType) + }) +]); + +const ZabbixSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const ZabbixSyncSchema = BaseSecretSyncSchema(SecretSync.Zabbix, ZabbixSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Zabbix), + destinationConfig: ZabbixSyncDestinationConfigSchema +}); + +export const CreateZabbixSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Zabbix, + ZabbixSyncOptionsConfig +).extend({ + destinationConfig: ZabbixSyncDestinationConfigSchema +}); + +export const UpdateZabbixSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Zabbix, + ZabbixSyncOptionsConfig +).extend({ + destinationConfig: ZabbixSyncDestinationConfigSchema.optional() +}); + +export const ZabbixSyncListItemSchema = z.object({ + name: z.literal("Zabbix"), + connection: z.literal(AppConnection.Zabbix), + destination: z.literal(SecretSync.Zabbix), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts new file mode 100644 index 000000000..9640394d9 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { TZabbixConnection } from "@app/services/app-connection/zabbix"; + +import { CreateZabbixSyncSchema, ZabbixSyncListItemSchema, ZabbixSyncSchema } from "./zabbix-sync-schemas"; + +export type TZabbixSync = z.infer; +export type TZabbixSyncInput = z.infer; +export type TZabbixSyncListItem = z.infer; + +export type TZabbixSyncWithCredentials = TZabbixSync & { + connection: TZabbixConnection; +}; + +export type TZabbixSecret = { + macro: string; + value: string; + description?: string; + globalmacroid?: string; + hostmacroid?: string; + hostid?: string; + type: number; + automatic?: string; +}; + +export interface ZabbixApiResponse { + data: { + jsonrpc: "2.0"; + result?: T; + error?: { + code: number; + message: string; + data?: string; + }; + id: number; + }; +} + +export interface ZabbixMacroCreateResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export interface ZabbixMacroUpdateResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export interface ZabbixMacroDeleteResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export enum ZabbixMacroType { + TEXT = 0, + SECRET = 1 +} + +export interface ZabbixMacroInput { + hostid?: string; + macro: string; + value: string; + description?: string; + type?: ZabbixMacroType; + automatic?: "0" | "1"; +} + +export interface ZabbixMacroUpdate { + hostmacroid?: string; + globalmacroid?: string; + value?: string; + description?: string; + type?: ZabbixMacroType; + automatic?: "0" | "1"; +} diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 8a0d4dd14..2ca7a0c33 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -5,7 +5,13 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { + getConfig, + getOriginalConfig, + overrideEnvConfig, + overwriteSchema, + validateOverrides +} from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -33,6 +39,7 @@ import { TInvalidateCacheQueueFactory } from "./invalidate-cache-queue"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { CacheType, + EnvOverrides, LoginMethod, TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, @@ -234,6 +241,45 @@ export const superAdminServiceFactory = ({ adminIntegrationsConfig = config; }; + const getEnvOverrides = async () => { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if (!serverCfg || !serverCfg.encryptedEnvOverrides) { + return {}; + } + + const decrypt = kmsService.decryptWithRootKey(); + + const overrides = JSON.parse(decrypt(serverCfg.encryptedEnvOverrides).toString()) as Record; + + return overrides; + }; + + const getEnvOverridesOrganized = async (): Promise => { + const overrides = await getEnvOverrides(); + const ogConfig = getOriginalConfig(); + + return Object.fromEntries( + Object.entries(overwriteSchema).map(([groupKey, groupDef]) => [ + groupKey, + { + name: groupDef.name, + fields: groupDef.fields.map(({ key, description }) => ({ + key, + description, + value: overrides[key] || "", + hasEnvEntry: !!(ogConfig as unknown as Record)[key] + })) + } + ]) + ); + }; + + const $syncEnvConfig = async () => { + const config = await getEnvOverrides(); + overrideEnvConfig(config); + }; + const updateServerCfg = async ( data: TSuperAdminUpdate & { slackClientId?: string; @@ -246,6 +292,7 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; }, userId: string ) => { @@ -374,6 +421,17 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSettingsUpdated = true; } + let envOverridesUpdated = false; + if (data.envOverrides !== undefined) { + // Verify input format + validateOverrides(data.envOverrides); + + const encryptedEnvOverrides = encryptWithRoot(Buffer.from(JSON.stringify(data.envOverrides))); + updatedData.encryptedEnvOverrides = encryptedEnvOverrides; + updatedData.envOverrides = undefined; + envOverridesUpdated = true; + } + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); @@ -382,6 +440,10 @@ export const superAdminServiceFactory = ({ await $syncAdminIntegrationConfig(); } + if (envOverridesUpdated) { + await $syncEnvConfig(); + } + if ( updatedServerCfg.encryptedMicrosoftTeamsAppId && updatedServerCfg.encryptedMicrosoftTeamsClientSecret && @@ -814,6 +876,18 @@ export const superAdminServiceFactory = ({ return job; }; + const initializeEnvConfigSync = async () => { + logger.info("Setting up background sync process for environment overrides"); + + await $syncEnvConfig(); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", $syncEnvConfig); + job.start(); + + return job; + }; + return { initServerCfg, updateServerCfg, @@ -833,6 +907,9 @@ export const superAdminServiceFactory = ({ getOrganizations, deleteOrganization, deleteOrganizationMembership, - initializeAdminIntegrationConfigSync + initializeAdminIntegrationConfigSync, + initializeEnvConfigSync, + getEnvOverrides, + getEnvOverridesOrganized }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 205c59f2c..b57a015a4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,3 +1,5 @@ +import { TEnvConfig } from "@app/lib/config/env"; + export type TAdminSignUpDTO = { email: string; password: string; @@ -74,3 +76,10 @@ export type TAdminIntegrationConfig = { privateKey: string; }; }; + +export interface EnvOverrides { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/backend/src/services/telemetry/telemetry-queue.ts b/backend/src/services/telemetry/telemetry-queue.ts index 994ebbd38..cc386aebb 100644 --- a/backend/src/services/telemetry/telemetry-queue.ts +++ b/backend/src/services/telemetry/telemetry-queue.ts @@ -71,6 +71,15 @@ export const telemetryQueueServiceFactory = ({ QueueName.TelemetryInstanceStats // just a job id ); + if (postHog) { + await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, { + jobId: QueueName.TelemetryInstanceStats, + repeat: { pattern: "0 0 * * *", utc: true } + }); + } + }; + + const startAggregatedEventsJob = async () => { // clear previous aggregated events job await queueService.stopRepeatableJob( QueueName.TelemetryAggregatedEvents, @@ -80,11 +89,6 @@ export const telemetryQueueServiceFactory = ({ ); if (postHog) { - await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, { - jobId: QueueName.TelemetryInstanceStats, - repeat: { pattern: "0 0 * * *", utc: true } - }); - // Start aggregated events job (runs every five minutes) await queueService.queue(QueueName.TelemetryAggregatedEvents, QueueJobs.TelemetryAggregatedEvents, undefined, { jobId: QueueName.TelemetryAggregatedEvents, @@ -102,6 +106,7 @@ export const telemetryQueueServiceFactory = ({ }); return { - startTelemetryCheck + startTelemetryCheck, + startAggregatedEventsJob }; }; diff --git a/backend/src/services/telemetry/telemetry-service.ts b/backend/src/services/telemetry/telemetry-service.ts index eaab5bec6..6dbd12ff5 100644 --- a/backend/src/services/telemetry/telemetry-service.ts +++ b/backend/src/services/telemetry/telemetry-service.ts @@ -14,7 +14,7 @@ export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed"; export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations"; export const POSTHOG_AGGREGATED_EVENTS = [PostHogEventTypes.SecretPulled]; -const TELEMETRY_AGGREGATED_KEY_EXP = 900; // 15mins +const TELEMETRY_AGGREGATED_KEY_EXP = 600; // 10mins // Bucket configuration const TELEMETRY_BUCKET_COUNT = 30; @@ -102,13 +102,6 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme const instanceType = licenseService.getInstanceType(); // capture posthog only when its cloud or signup event happens in self-hosted if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) { - if (event.organizationId) { - try { - postHog.groupIdentify({ groupType: "organization", groupKey: event.organizationId }); - } catch (error) { - logger.error(error, "Failed to identify PostHog organization"); - } - } if (POSTHOG_AGGREGATED_EVENTS.includes(event.event)) { const eventKey = createTelemetryEventKey(event.event, event.distinctId); await keyStore.setItemWithExpiry( @@ -122,6 +115,13 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme }) ); } else { + if (event.organizationId) { + try { + postHog.groupIdentify({ groupType: "organization", groupKey: event.organizationId }); + } catch (error) { + logger.error(error, "Failed to identify PostHog organization"); + } + } postHog.capture({ event: event.event, distinctId: event.distinctId, diff --git a/cli/detect/cmd/scm/scm.go b/cli/detect/cmd/scm/scm.go index 66868aadc..dddeffdf5 100644 --- a/cli/detect/cmd/scm/scm.go +++ b/cli/detect/cmd/scm/scm.go @@ -35,6 +35,7 @@ const ( GitHubPlatform GitLabPlatform AzureDevOpsPlatform + BitBucketPlatform // TODO: Add others. ) @@ -45,6 +46,7 @@ func (p Platform) String() string { "github", "gitlab", "azuredevops", + "bitbucket", }[p] } @@ -60,6 +62,8 @@ func PlatformFromString(s string) (Platform, error) { return GitLabPlatform, nil case "azuredevops": return AzureDevOpsPlatform, nil + case "bitbucket": + return BitBucketPlatform, nil default: return UnknownPlatform, fmt.Errorf("invalid scm platform value: %s", s) } diff --git a/cli/detect/git.go b/cli/detect/git.go index ddde0757d..83ed8a853 100644 --- a/cli/detect/git.go +++ b/cli/detect/git.go @@ -208,6 +208,8 @@ func platformFromHost(u *url.URL) scm.Platform { return scm.GitLabPlatform case "dev.azure.com", "visualstudio.com": return scm.AzureDevOpsPlatform + case "bitbucket.org": + return scm.BitBucketPlatform default: return scm.UnknownPlatform } diff --git a/cli/detect/utils.go b/cli/detect/utils.go index 255d01fbe..84b1017fc 100644 --- a/cli/detect/utils.go +++ b/cli/detect/utils.go @@ -112,6 +112,15 @@ func createScmLink(scmPlatform scm.Platform, remoteUrl string, finding report.Fi // This is a bit dirty, but Azure DevOps does not highlight the line when the lineStartColumn and lineEndColumn are not provided link += "&lineStartColumn=1&lineEndColumn=10000000&type=2&lineStyle=plain&_a=files" return link + case scm.BitBucketPlatform: + link := fmt.Sprintf("%s/src/%s/%s", remoteUrl, finding.Commit, filePath) + if finding.StartLine != 0 { + link += fmt.Sprintf("#lines-%d", finding.StartLine) + } + if finding.EndLine != finding.StartLine { + link += fmt.Sprintf(":%d", finding.EndLine) + } + return link default: // This should never happen. return "" diff --git a/cli/packages/cmd/scan.go b/cli/packages/cmd/scan.go index 42ff0f1e1..4a721d2c5 100644 --- a/cli/packages/cmd/scan.go +++ b/cli/packages/cmd/scan.go @@ -337,9 +337,7 @@ var scanCmd = &cobra.Command{ if gitCmd, err = sources.NewGitLogCmd(source, logOpts); err != nil { logging.Fatal().Err(err).Msg("could not create Git cmd") } - if scmPlatform, err = scm.PlatformFromString("github"); err != nil { - logging.Fatal().Err(err).Send() - } + scmPlatform = scm.UnknownPlatform remote = detect.NewRemoteInfo(scmPlatform, source) if findings, err = detector.DetectGit(gitCmd, remote); err != nil { diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 000000000..079972544 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN npm install -g mint@4.2.13 + +COPY . . + +# Install a local version of our OpenAPI spec +RUN apk add --no-cache wget jq && \ + wget -O spec.json https://app.infisical.com/api/docs/json && \ + jq '.api.openapi = "./spec.json"' docs.json > temp.json && \ + mv temp.json docs.json + +# Run mint dev briefly to download the web client +RUN timeout 30 mint dev || true + +FROM node:20-alpine + +WORKDIR /app + +RUN npm install -g mint@4.2.13 + +COPY . . + +COPY --from=builder /root/.mintlify /root/.mintlify +COPY --from=builder /app/docs.json /app/docs.json +COPY --from=builder /app/spec.json /app/spec.json + +EXPOSE 3000 + +CMD ["mint", "dev"] diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/available.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/available.mdx new file mode 100644 index 000000000..cdaaceff3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/bitbucket/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/create.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/create.mdx new file mode 100644 index 000000000..70f48aae7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/bitbucket" +--- + + + Check out the configuration docs for [Bitbucket Connections](/integrations/app-connections/bitbucket) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/delete.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/delete.mdx new file mode 100644 index 000000000..a61b6bbb1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/bitbucket/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/get-by-id.mdx new file mode 100644 index 000000000..83f7f7521 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/bitbucket/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/get-by-name.mdx new file mode 100644 index 000000000..f2aa16f40 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/bitbucket/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/list.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/list.mdx new file mode 100644 index 000000000..4bc4c4714 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/bitbucket" +--- diff --git a/docs/api-reference/endpoints/app-connections/bitbucket/update.mdx b/docs/api-reference/endpoints/app-connections/bitbucket/update.mdx new file mode 100644 index 000000000..289e96681 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/bitbucket/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/bitbucket/{connectionId}" +--- + + + Check out the configuration docs for [Bitbucket Connections](/integrations/app-connections/bitbucket) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/railway/available.mdx b/docs/api-reference/endpoints/app-connections/railway/available.mdx new file mode 100644 index 000000000..83190c379 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/railway/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/create.mdx b/docs/api-reference/endpoints/app-connections/railway/create.mdx new file mode 100644 index 000000000..96c1c9c53 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/railway" +--- + + + Check out the configuration docs for [Railway Connections](/integrations/app-connections/railway) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/railway/delete.mdx b/docs/api-reference/endpoints/app-connections/railway/delete.mdx new file mode 100644 index 000000000..4938f26e8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/railway/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx new file mode 100644 index 000000000..844bd2376 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/railway/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx new file mode 100644 index 000000000..4497cbfca --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/railway/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/list.mdx b/docs/api-reference/endpoints/app-connections/railway/list.mdx new file mode 100644 index 000000000..16a6a087e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/railway" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/update.mdx b/docs/api-reference/endpoints/app-connections/railway/update.mdx new file mode 100644 index 000000000..66fa37a43 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/railway/{connectionId}" +--- + + + Check out the configuration docs for [Railway Connections](/integrations/app-connections/railway) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/zabbix/available.mdx b/docs/api-reference/endpoints/app-connections/zabbix/available.mdx new file mode 100644 index 000000000..49488471e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/zabbix/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/create.mdx b/docs/api-reference/endpoints/app-connections/zabbix/create.mdx new file mode 100644 index 000000000..a11b01309 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/zabbix" +--- + + + Check out the configuration docs for [Zabbix Connections](/integrations/app-connections/zabbix) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx b/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx new file mode 100644 index 000000000..95b34e814 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/zabbix/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx new file mode 100644 index 000000000..46306b035 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/zabbix/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx new file mode 100644 index 000000000..692c69fc7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/zabbix/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/list.mdx b/docs/api-reference/endpoints/app-connections/zabbix/list.mdx new file mode 100644 index 000000000..bc1c7df2b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/zabbix" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/update.mdx b/docs/api-reference/endpoints/app-connections/zabbix/update.mdx new file mode 100644 index 000000000..aa408c9ee --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/zabbix/{connectionId}" +--- + + + Check out the configuration docs for [Zabbix Connections](/integrations/app-connections/zabbix) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/organizations/ldap-sso/create-ldap-config.mdx b/docs/api-reference/endpoints/organizations/ldap-sso/create-ldap-config.mdx new file mode 100644 index 000000000..3edabd366 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/ldap-sso/create-ldap-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Create LDAP SSO Config" +openapi: "POST /api/v1/ldap/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/ldap-sso/get-ldap-config.mdx b/docs/api-reference/endpoints/organizations/ldap-sso/get-ldap-config.mdx new file mode 100644 index 000000000..a41669384 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/ldap-sso/get-ldap-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Get LDAP SSO Config" +openapi: "GET /api/v1/ldap/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/ldap-sso/update-ldap-config.mdx b/docs/api-reference/endpoints/organizations/ldap-sso/update-ldap-config.mdx new file mode 100644 index 000000000..ab613728a --- /dev/null +++ b/docs/api-reference/endpoints/organizations/ldap-sso/update-ldap-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Update LDAP SSO Config" +openapi: "PATCH /api/v1/ldap/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/oidc-sso/create-oidc-config.mdx b/docs/api-reference/endpoints/organizations/oidc-sso/create-oidc-config.mdx new file mode 100644 index 000000000..6a86d970c --- /dev/null +++ b/docs/api-reference/endpoints/organizations/oidc-sso/create-oidc-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Create OIDC Config" +openapi: "POST /api/v1/sso/oidc/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/oidc-sso/get-oidc-config.mdx b/docs/api-reference/endpoints/organizations/oidc-sso/get-oidc-config.mdx new file mode 100644 index 000000000..af62c7d0a --- /dev/null +++ b/docs/api-reference/endpoints/organizations/oidc-sso/get-oidc-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Get OIDC Config" +openapi: "GET /api/v1/sso/oidc/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/oidc-sso/update-oidc-config.mdx b/docs/api-reference/endpoints/organizations/oidc-sso/update-oidc-config.mdx new file mode 100644 index 000000000..fdafeaf99 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/oidc-sso/update-oidc-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Update OIDC Config" +openapi: "PATCH /api/v1/sso/oidc/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/saml-sso/create-saml-config.mdx b/docs/api-reference/endpoints/organizations/saml-sso/create-saml-config.mdx new file mode 100644 index 000000000..0a01fb742 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/saml-sso/create-saml-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Create SAML SSO Config" +openapi: "POST /api/v1/sso/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/saml-sso/get-saml-config.mdx b/docs/api-reference/endpoints/organizations/saml-sso/get-saml-config.mdx new file mode 100644 index 000000000..71c00fb24 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/saml-sso/get-saml-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Get SAML SSO Config" +openapi: "GET /api/v1/sso/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/saml-sso/update-saml-config.mdx b/docs/api-reference/endpoints/organizations/saml-sso/update-saml-config.mdx new file mode 100644 index 000000000..57067dbd1 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/saml-sso/update-saml-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Update SAML SSO Config" +openapi: "PATCH /api/v1/sso/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/create.mdx new file mode 100644 index 000000000..eeee8e019 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/bitbucket" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete.mdx new file mode 100644 index 000000000..b2bb37d0f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id.mdx new file mode 100644 index 000000000..009d57dcb --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name.mdx new file mode 100644 index 000000000..d9b82fb9b --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/bitbucket/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources.mdx new file mode 100644 index 000000000..e80a71eb6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans.mdx new file mode 100644 index 000000000..1c1c83abc --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list.mdx new file mode 100644 index 000000000..f75e3c2b7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/bitbucket" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource.mdx new file mode 100644 index 000000000..03d0c79b0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan.mdx new file mode 100644 index 000000000..f7b7c2dae --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/update.mdx new file mode 100644 index 000000000..92a88b6a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/bitbucket/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/bitbucket/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/create.mdx b/docs/api-reference/endpoints/secret-syncs/railway/create.mdx new file mode 100644 index 000000000..51d23eaf2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/railway" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx b/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx new file mode 100644 index 000000000..786ce05e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx new file mode 100644 index 000000000..dbeddee50 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx new file mode 100644 index 000000000..4e4964adc --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/railway/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx new file mode 100644 index 000000000..2f9a9b017 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/list.mdx b/docs/api-reference/endpoints/secret-syncs/railway/list.mdx new file mode 100644 index 000000000..f4dc62a45 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/railway" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx new file mode 100644 index 000000000..f3e187a11 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx new file mode 100644 index 000000000..5bccb271a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/update.mdx b/docs/api-reference/endpoints/secret-syncs/railway/update.mdx new file mode 100644 index 000000000..104194868 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx new file mode 100644 index 000000000..1d15bd7d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/zabbix" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx new file mode 100644 index 000000000..dc7345298 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx new file mode 100644 index 000000000..79e787d04 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx new file mode 100644 index 000000000..2b364c699 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/zabbix/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx new file mode 100644 index 000000000..716f3c8d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx new file mode 100644 index 000000000..d6d4bdef5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/zabbix" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx new file mode 100644 index 000000000..8fb422544 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx new file mode 100644 index 000000000..9751ad721 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx new file mode 100644 index 000000000..ea7582143 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index ac9d62a7c..8e6972429 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -210,6 +210,7 @@ "group": "Secret Scanning", "pages": [ "documentation/platform/secret-scanning/overview", + "documentation/platform/secret-scanning/bitbucket", "documentation/platform/secret-scanning/github" ] } @@ -469,6 +470,7 @@ "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-devops", "integrations/app-connections/azure-key-vault", + "integrations/app-connections/bitbucket", "integrations/app-connections/camunda", "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", @@ -486,11 +488,13 @@ "integrations/app-connections/oci", "integrations/app-connections/oracledb", "integrations/app-connections/postgres", + "integrations/app-connections/railway", "integrations/app-connections/render", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", - "integrations/app-connections/windmill" + "integrations/app-connections/windmill", + "integrations/app-connections/zabbix" ] } ] @@ -519,11 +523,13 @@ "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/oci-vault", + "integrations/secret-syncs/railway", "integrations/secret-syncs/render", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", - "integrations/secret-syncs/windmill" + "integrations/secret-syncs/windmill", + "integrations/secret-syncs/zabbix" ] } ] @@ -559,8 +565,8 @@ "integrations/cloud/digital-ocean-app-platform", "integrations/cloud/heroku", "integrations/cloud/netlify", - "integrations/cloud/railway", "integrations/cloud/flyio", + "integrations/cloud/railway", "integrations/cloud/render", "integrations/cloud/laravel-forge", "integrations/cloud/supabase", @@ -849,6 +855,30 @@ { "group": "Organizations", "pages": [ + { + "group": "OIDC SSO", + "pages": [ + "api-reference/endpoints/organizations/oidc-sso/get-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/update-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/create-oidc-config" + ] + }, + { + "group": "LDAP SSO", + "pages": [ + "api-reference/endpoints/organizations/ldap-sso/get-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/update-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/create-ldap-config" + ] + }, + { + "group": "SAML SSO", + "pages": [ + "api-reference/endpoints/organizations/saml-sso/get-saml-config", + "api-reference/endpoints/organizations/saml-sso/update-saml-config", + "api-reference/endpoints/organizations/saml-sso/create-saml-config" + ] + }, "api-reference/endpoints/organizations/memberships", "api-reference/endpoints/organizations/update-membership", "api-reference/endpoints/organizations/delete-membership", @@ -1109,6 +1139,21 @@ "pages": [ "api-reference/endpoints/secret-scanning/data-sources/list", "api-reference/endpoints/secret-scanning/data-sources/options", + { + "group": "Bitbucket", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/create", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/update", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource" + ] + }, { "group": "GitHub", "pages": [ @@ -1258,6 +1303,18 @@ "api-reference/endpoints/app-connections/azure-key-vault/delete" ] }, + { + "group": "Bitbucket", + "pages": [ + "api-reference/endpoints/app-connections/bitbucket/list", + "api-reference/endpoints/app-connections/bitbucket/available", + "api-reference/endpoints/app-connections/bitbucket/get-by-id", + "api-reference/endpoints/app-connections/bitbucket/get-by-name", + "api-reference/endpoints/app-connections/bitbucket/create", + "api-reference/endpoints/app-connections/bitbucket/update", + "api-reference/endpoints/app-connections/bitbucket/delete" + ] + }, { "group": "Camunda", "pages": [ @@ -1462,6 +1519,18 @@ "api-reference/endpoints/app-connections/postgres/delete" ] }, + { + "group": "Railway", + "pages": [ + "api-reference/endpoints/app-connections/railway/list", + "api-reference/endpoints/app-connections/railway/available", + "api-reference/endpoints/app-connections/railway/get-by-id", + "api-reference/endpoints/app-connections/railway/get-by-name", + "api-reference/endpoints/app-connections/railway/create", + "api-reference/endpoints/app-connections/railway/update", + "api-reference/endpoints/app-connections/railway/delete" + ] + }, { "group": "Render", "pages": [ @@ -1521,6 +1590,18 @@ "api-reference/endpoints/app-connections/windmill/update", "api-reference/endpoints/app-connections/windmill/delete" ] + }, + { + "group": "Zabbix", + "pages": [ + "api-reference/endpoints/app-connections/zabbix/list", + "api-reference/endpoints/app-connections/zabbix/available", + "api-reference/endpoints/app-connections/zabbix/get-by-id", + "api-reference/endpoints/app-connections/zabbix/get-by-name", + "api-reference/endpoints/app-connections/zabbix/create", + "api-reference/endpoints/app-connections/zabbix/update", + "api-reference/endpoints/app-connections/zabbix/delete" + ] } ] }, @@ -1759,6 +1840,20 @@ "api-reference/endpoints/secret-syncs/oci-vault/remove-secrets" ] }, + { + "group": "Railway", + "pages": [ + "api-reference/endpoints/secret-syncs/railway/list", + "api-reference/endpoints/secret-syncs/railway/get-by-id", + "api-reference/endpoints/secret-syncs/railway/get-by-name", + "api-reference/endpoints/secret-syncs/railway/create", + "api-reference/endpoints/secret-syncs/railway/update", + "api-reference/endpoints/secret-syncs/railway/delete", + "api-reference/endpoints/secret-syncs/railway/sync-secrets", + "api-reference/endpoints/secret-syncs/railway/import-secrets", + "api-reference/endpoints/secret-syncs/railway/remove-secrets" + ] + }, { "group": "Render", "pages": [ @@ -1827,6 +1922,20 @@ "api-reference/endpoints/secret-syncs/windmill/import-secrets", "api-reference/endpoints/secret-syncs/windmill/remove-secrets" ] + }, + { + "group": "Zabbix", + "pages": [ + "api-reference/endpoints/secret-syncs/zabbix/list", + "api-reference/endpoints/secret-syncs/zabbix/get-by-id", + "api-reference/endpoints/secret-syncs/zabbix/get-by-name", + "api-reference/endpoints/secret-syncs/zabbix/create", + "api-reference/endpoints/secret-syncs/zabbix/update", + "api-reference/endpoints/secret-syncs/zabbix/delete", + "api-reference/endpoints/secret-syncs/zabbix/sync-secrets", + "api-reference/endpoints/secret-syncs/zabbix/import-secrets", + "api-reference/endpoints/secret-syncs/zabbix/remove-secrets" + ] } ] }, @@ -2091,7 +2200,7 @@ "api": { "openapi": "https://app.infisical.com/api/docs/json", "mdx": { - "server": ["https://app.infisical.com", "http://localhost:8080"] + "server": ["https://app.infisical.com"] } }, "appearance": { diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 03bc5df84..28b177c5f 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -3,13 +3,13 @@ title: "AWS IAM" description: "Learn how to dynamically generate AWS IAM Users." --- -The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on configured AWS policy. +The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on a configured AWS policy. Infisical supports several authentication methods to connect to your AWS account, including assuming an IAM Role, using IAM Roles for Service Accounts (IRSA) on EKS, or static Access Keys. ## Prerequisite -Infisical needs an initial AWS IAM user with the required permissions to create sub IAM users. This IAM user will be responsible for managing the lifecycle of new IAM users. +Infisical needs an AWS IAM principal (a user or a role) with the required permissions to create and manage other IAM users. This principal will be responsible for the lifecycle of the dynamically generated users. - + ```json { @@ -235,7 +235,169 @@ Replace **\** with your AWS account id and **\** w ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) + + + This method is recommended for self-hosted Infisical instances running on AWS EKS. It uses [IAM Roles for Service Accounts (IRSA)](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) to securely grant permissions to the Infisical pods without managing static credentials. + + In order to use IRSA, the `KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN` environment variable must be set to `true` for your self-hosted Infisical instance. + + + + + If you don't already have one, you need to create an IAM OIDC provider for your EKS cluster. This allows IAM to trust authentication tokens from your Kubernetes cluster. + 1. Find your cluster's OIDC provider URL from the EKS console or by using the AWS CLI: + `aws eks describe-cluster --name --query "cluster.identity.oidc.issuer" --output text` + 2. Navigate to the [IAM Identity Providers](https://console.aws.amazon.com/iam/home#/providers) page in your AWS Console and create a new OpenID Connect provider with the URL and `sts.amazonaws.com` as the audience. + + ![Create OIDC Provider Placeholder](/images/integrations/aws/irsa-create-oidc-provider.png) + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + 2. Select **Web identity** as the **Trusted Entity Type**. + 3. Choose the OIDC provider you created in the previous step. + 4. For the **Audience**, select `sts.amazonaws.com`. + ![IAM Role Creation for IRSA](/images/integrations/aws/irsa-iam-role-creation.png) + 5. Attach the permission policy detailed in the **Prerequisite** section at the top of this page. + 6. After creating the role, edit its **Trust relationship** to specify the service account Infisical is using in your cluster. This ensures only the Infisical pod can assume this role. + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam:::oidc-provider/oidc.eks..amazonaws.com/id/" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "oidc.eks..amazonaws.com/id/:sub": "system:serviceaccount::", + "oidc.eks..amazonaws.com/id/:aud": "sts.amazonaws.com" + } + } + } + ] + } + ``` + Replace ``, ``, ``, ``, and `` with your specific values. + + + For the IRSA mechanism to work, the Infisical service account in your Kubernetes cluster must be annotated with the ARN of the IAM role you just created. + + Run the following command, replacing the placeholders with your values: + ```bash + kubectl annotate serviceaccount -n \ + eks.amazonaws.com/role-arn=arn:aws:iam:::role/ + ``` + This annotation tells the EKS Pod Identity Webhook to inject the necessary environment variables and tokens into the Infisical pod, allowing it to assume the specified IAM role. + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + ![Dynamic Secret Setup Modal for IRSA](/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-irsa.png) + + Name by which you want the secret to be referenced + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + Maximum time-to-live for a generated secret + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + Tags to be added to the created IAM User resource. + + + Select *IRSA* method. + + + The ARN of the AWS IAM Role for the service account to assume. + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + + The AWS data center region. + + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas + + + The AWS IAM inline policy that should be attached to the created users. + Multiple values can be provided by separating them with commas + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret in step 4. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. @@ -263,9 +425,9 @@ Replace **\** with your AWS account id and **\** w Maximum time-to-live for a generated secret - - Select *Access Key* method. - + + Select *Access Key* method. + The managing AWS IAM User Access Key diff --git a/docs/documentation/platform/secret-scanning/bitbucket.mdx b/docs/documentation/platform/secret-scanning/bitbucket.mdx new file mode 100644 index 000000000..c5feb5b06 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/bitbucket.mdx @@ -0,0 +1,100 @@ +--- +title: "Bitbucket Secret Scanning" +sidebarTitle: "Bitbucket" +description: "Learn how to configure secret scanning for Bitbucket." +--- + +## Prerequisites + +- Create a [Bitbucket Connection](/integrations/app-connections/bitbucket) with Secret Scanning permissions + +## Create a Bitbucket Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **Bitbucket** option. + + ![Select Bitbucket](/images/platform/secret-scanning/bitbucket/step-2.png) + + 3. Configure which workspace and repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/bitbucket/step-3.png) + + - **Bitbucket Connection** - the connection that has access to the repositories you want to scan. + - **Workspace** - the Bitbucket workspace to scan secrets in. + - **Scan Repositories** - select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/bitbucket/step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/bitbucket/step-5.png) + + 6. Your **Bitbucket Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/bitbucket/step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/bitbucket/step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/bitbucket/step-8.png) + + + To create a Bitbucket Data Source, make an API request to the [Create Bitbucket Data Source](/api-reference/endpoints/secret-scanning/data-sources/bitbucket/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/bitbucket \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-bitbucket-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my bitbucket data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "workspaceSlug": "my-workspace", + "includeRepos": ["*"] + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-bitbucket-source", + "description": "my bitbucket data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "bitbucket", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "bitbucket", + "name": "my-bitbucket-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "workspaceSlug": "my-workspace", + "includeRepos": ["*"] + } + } + } + ``` + + diff --git a/docs/images/app-connections/bitbucket/step-1.png b/docs/images/app-connections/bitbucket/step-1.png new file mode 100644 index 000000000..3b5941e82 Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-1.png differ diff --git a/docs/images/app-connections/bitbucket/step-2.png b/docs/images/app-connections/bitbucket/step-2.png new file mode 100644 index 000000000..91d06eb34 Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-2.png differ diff --git a/docs/images/app-connections/bitbucket/step-3.png b/docs/images/app-connections/bitbucket/step-3.png new file mode 100644 index 000000000..840d86e1b Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-3.png differ diff --git a/docs/images/app-connections/bitbucket/step-4.png b/docs/images/app-connections/bitbucket/step-4.png new file mode 100644 index 000000000..98949dd3c Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-4.png differ diff --git a/docs/images/app-connections/bitbucket/step-5.png b/docs/images/app-connections/bitbucket/step-5.png new file mode 100644 index 000000000..278446897 Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-5.png differ diff --git a/docs/images/app-connections/bitbucket/step-6.png b/docs/images/app-connections/bitbucket/step-6.png new file mode 100644 index 000000000..cf45c347b Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-6.png differ diff --git a/docs/images/app-connections/bitbucket/step-7.png b/docs/images/app-connections/bitbucket/step-7.png new file mode 100644 index 000000000..4c13a1f61 Binary files /dev/null and b/docs/images/app-connections/bitbucket/step-7.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png b/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png new file mode 100644 index 000000000..c3a6b014f Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-settings.png b/docs/images/app-connections/railway/railway-app-connection-account-settings.png new file mode 100644 index 000000000..15f3d9af5 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-settings.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-create.png b/docs/images/app-connections/railway/railway-app-connection-account-token-create.png new file mode 100644 index 000000000..7c4ac2af7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-created.png b/docs/images/app-connections/railway/railway-app-connection-account-token-created.png new file mode 100644 index 000000000..6ba7a83c7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-form.png b/docs/images/app-connections/railway/railway-app-connection-account-token-form.png new file mode 100644 index 000000000..200fd1cdc Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-form.png b/docs/images/app-connections/railway/railway-app-connection-form.png new file mode 100644 index 000000000..122661426 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-generated.png b/docs/images/app-connections/railway/railway-app-connection-generated.png new file mode 100644 index 000000000..e3fc0c5cb Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-generated.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-option.png b/docs/images/app-connections/railway/railway-app-connection-option.png new file mode 100644 index 000000000..cffaa0e85 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-option.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-create.png b/docs/images/app-connections/railway/railway-app-connection-project-token-create.png new file mode 100644 index 000000000..839336cdd Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-created.png b/docs/images/app-connections/railway/railway-app-connection-project-token-created.png new file mode 100644 index 000000000..5588f89d5 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png b/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png new file mode 100644 index 000000000..0ec110a91 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-form.png b/docs/images/app-connections/railway/railway-app-connection-project-token-form.png new file mode 100644 index 000000000..a16c6a2e7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-project.png b/docs/images/app-connections/railway/railway-app-connection-project-token-project.png new file mode 100644 index 000000000..b938cbe9e Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-project.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png b/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png new file mode 100644 index 000000000..ae59c588a Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-create.png b/docs/images/app-connections/railway/railway-app-connection-team-token-create.png new file mode 100644 index 000000000..d5c368e83 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-created.png b/docs/images/app-connections/railway/railway-app-connection-team-token-created.png new file mode 100644 index 000000000..4c3ef583d Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-form.png b/docs/images/app-connections/railway/railway-app-connection-team-token-form.png new file mode 100644 index 000000000..be7e10710 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-form.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-form.png b/docs/images/app-connections/zabbix/zabbix-api-token-form.png new file mode 100644 index 000000000..471ecc4a4 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-form.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-generated.png b/docs/images/app-connections/zabbix/zabbix-api-token-generated.png new file mode 100644 index 000000000..f05dd279e Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-generated.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-list.png b/docs/images/app-connections/zabbix/zabbix-api-token-list.png new file mode 100644 index 000000000..d549cc576 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-list.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-form.png b/docs/images/app-connections/zabbix/zabbix-app-connection-form.png new file mode 100644 index 000000000..90ac10f5f Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-form.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png b/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png new file mode 100644 index 000000000..87cf99a20 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-option.png b/docs/images/app-connections/zabbix/zabbix-app-connection-option.png new file mode 100644 index 000000000..4cd01571c Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-option.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-dashboard.png b/docs/images/app-connections/zabbix/zabbix-dashboard.png new file mode 100644 index 000000000..1f80f2e1b Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-dashboard.png differ diff --git a/docs/images/integrations/aws/irsa-create-oidc-provider.png b/docs/images/integrations/aws/irsa-create-oidc-provider.png new file mode 100644 index 000000000..aff8d600e Binary files /dev/null and b/docs/images/integrations/aws/irsa-create-oidc-provider.png differ diff --git a/docs/images/integrations/aws/irsa-iam-role-creation.png b/docs/images/integrations/aws/irsa-iam-role-creation.png new file mode 100644 index 000000000..8fd725bca Binary files /dev/null and b/docs/images/integrations/aws/irsa-iam-role-creation.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-irsa.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-irsa.png new file mode 100644 index 000000000..0051b0cf4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-irsa.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-2.png b/docs/images/platform/secret-scanning/bitbucket/step-2.png new file mode 100644 index 000000000..0d10951a4 Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-2.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-3.png b/docs/images/platform/secret-scanning/bitbucket/step-3.png new file mode 100644 index 000000000..97c373711 Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-3.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-4.png b/docs/images/platform/secret-scanning/bitbucket/step-4.png new file mode 100644 index 000000000..2a9fb1cb1 Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-4.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-5.png b/docs/images/platform/secret-scanning/bitbucket/step-5.png new file mode 100644 index 000000000..365323c6d Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-5.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-6.png b/docs/images/platform/secret-scanning/bitbucket/step-6.png new file mode 100644 index 000000000..adac9bb40 Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-6.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-7.png b/docs/images/platform/secret-scanning/bitbucket/step-7.png new file mode 100644 index 000000000..2ecbdda1a Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-7.png differ diff --git a/docs/images/platform/secret-scanning/bitbucket/step-8.png b/docs/images/platform/secret-scanning/bitbucket/step-8.png new file mode 100644 index 000000000..3948374f6 Binary files /dev/null and b/docs/images/platform/secret-scanning/bitbucket/step-8.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-created.png b/docs/images/secret-syncs/railway/railway-sync-created.png new file mode 100644 index 000000000..0b965ed5c Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-created.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-destination.png b/docs/images/secret-syncs/railway/railway-sync-destination.png new file mode 100644 index 000000000..2c401ac3d Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-destination.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-details.png b/docs/images/secret-syncs/railway/railway-sync-details.png new file mode 100644 index 000000000..9667ac8fb Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-details.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-options.png b/docs/images/secret-syncs/railway/railway-sync-options.png new file mode 100644 index 000000000..874b4c866 Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-options.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-review.png b/docs/images/secret-syncs/railway/railway-sync-review.png new file mode 100644 index 000000000..8f381d59e Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-review.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-source.png b/docs/images/secret-syncs/railway/railway-sync-source.png new file mode 100644 index 000000000..25cfe51d7 Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-source.png differ diff --git a/docs/images/secret-syncs/railway/select-option.png b/docs/images/secret-syncs/railway/select-option.png new file mode 100644 index 000000000..54f68a959 Binary files /dev/null and b/docs/images/secret-syncs/railway/select-option.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-destination.png b/docs/images/secret-syncs/zabbix/configure-destination.png new file mode 100644 index 000000000..deb9ad993 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-destination.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-details.png b/docs/images/secret-syncs/zabbix/configure-details.png new file mode 100644 index 000000000..c454de858 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-details.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-source.png b/docs/images/secret-syncs/zabbix/configure-source.png new file mode 100644 index 000000000..82b2630b4 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-source.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-sync-options.png b/docs/images/secret-syncs/zabbix/configure-sync-options.png new file mode 100644 index 000000000..ad54ce8ad Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/zabbix/review-configuration.png b/docs/images/secret-syncs/zabbix/review-configuration.png new file mode 100644 index 000000000..25b0fbf8f Binary files /dev/null and b/docs/images/secret-syncs/zabbix/review-configuration.png differ diff --git a/docs/images/secret-syncs/zabbix/select-option.png b/docs/images/secret-syncs/zabbix/select-option.png new file mode 100644 index 000000000..9ebf248a0 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/select-option.png differ diff --git a/docs/images/secret-syncs/zabbix/sync-created.png b/docs/images/secret-syncs/zabbix/sync-created.png new file mode 100644 index 000000000..e9924c82f Binary files /dev/null and b/docs/images/secret-syncs/zabbix/sync-created.png differ diff --git a/docs/images/self-hosting/configuration/overrides/page.png b/docs/images/self-hosting/configuration/overrides/page.png new file mode 100644 index 000000000..660273d4f Binary files /dev/null and b/docs/images/self-hosting/configuration/overrides/page.png differ diff --git a/docs/integrations/app-connections/1password.mdx b/docs/integrations/app-connections/1password.mdx index 0c3926a1b..394d8bc23 100644 --- a/docs/integrations/app-connections/1password.mdx +++ b/docs/integrations/app-connections/1password.mdx @@ -92,7 +92,7 @@ Infisical supports the use of [Service Accounts](https://developer.1password.com "method": "api-token", "credentials": { "instanceUrl": "https://1pass.example.com", - "apiToken": "[PRIVATE TOKEN]" + "apiToken": "" } }' ``` diff --git a/docs/integrations/app-connections/bitbucket.mdx b/docs/integrations/app-connections/bitbucket.mdx new file mode 100644 index 000000000..e4f9ae29f --- /dev/null +++ b/docs/integrations/app-connections/bitbucket.mdx @@ -0,0 +1,133 @@ +--- +title: "Bitbucket Connection" +description: "Learn how to configure a Bitbucket Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) to connect with Bitbucket. + + + Infisical recommends creating a dedicated Bitbucket account with access restricted to only the resources your use case requires. + + +## Create Bitbucket Access Token + + + + Go to [Account API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens) and click **Create API token with scopes**. + + ![Create API Token](/images/app-connections/bitbucket/step-1.png) + + + Set the name and expiration date of the token, then click **Next**. + + ![Set Name and Expiry](/images/app-connections/bitbucket/step-2.png) + + + Keep in mind that you'll need to manually replace the token after it expires. + + + + Select **Bitbucket** and then click **Next**. + + ![Select Bitbucket](/images/app-connections/bitbucket/step-3.png) + + + Configure permissions according to your app's use case: + + + + ``` + read:workspace:bitbucket + read:user:bitbucket + read:webhook:bitbucket + write:webhook:bitbucket + delete:webhook:bitbucket + read:repository:bitbucket + ``` + + ![Configure Permissions](/images/app-connections/bitbucket/step-4.png) + + + + Click **Next**. + + + + Save the API Token for later steps. + + ![Save Token](/images/app-connections/bitbucket/step-5.png) + + + +## Create Bitbucket Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **Add new connection** button and select **Bitbucket** from the list of available connections. + + + Complete the Bitbucket Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - Your Bitbucket email + - The API Token from earlier steps + + ![Bitbucket Connection Modal](/images/app-connections/bitbucket/step-6.png) + + + After clicking Create, your **Bitbucket Connection** is established and ready to use with your Infisical projects. + + ![Bitbucket Connection Created](/images/app-connections/bitbucket/step-7.png) + + + + + To create a Bitbucket Connection, make an API request to the [Create Bitbucket Connection](/api-reference/endpoints/app-connections/bitbucket/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/bitbucket \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-bitbucket-connection", + "method": "api-token", + "credentials": { + "email": "user@example.com", + "apiToken": "" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-bitbucket-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "bitbucket", + "method": "api-token", + "credentials": { + "email": "user@example.com" + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/railway.mdx b/docs/integrations/app-connections/railway.mdx new file mode 100644 index 000000000..7b53d02ad --- /dev/null +++ b/docs/integrations/app-connections/railway.mdx @@ -0,0 +1,164 @@ +--- +title: "Railway Connection" +description: "Learn how to configure a Railway Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://docs.railway.com/guides/public-api#creating-a-token) to connect with Railway. + +## Create a Railway API Token + + + + A team token provides access to all resources within a team. It cannot be used to access personal resources in Railway. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-account-settings.png) + + + ![Account Settings Page](/images/app-connections/railway/railway-app-connection-account-settings-tokens.png) + + + Make sure to provide a descriptive name and select the correct team. + + ![Enter Name and Select Team](/images/app-connections/railway/railway-app-connection-team-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-team-token-create.png) + + + After clicking 'Create', your access token will be displayed. Save it securely for later use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-team-token-created.png) + + + + + + If no team is selected, the token will be associated with your personal Railway account and will have access to all your individual and team resources. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-account-settings.png) + + + ![Account Settings Page](/images/app-connections/railway/railway-app-connection-account-settings-tokens.png) + + + Provide a descriptive name and ensure no team is selected. This will create an account-level token. + + ![Enter Name](/images/app-connections/railway/railway-app-connection-account-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-account-token-create.png) + + + After clicking 'Create', your access token will be shown. Save it for future use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-account-token-created.png) + + + + + + Project tokens are limited to a specific environment within a project and can only be used to authenticate requests to that environment. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-project-token-dashboard.png) + + + ![Project Settings Page](/images/app-connections/railway/railway-app-connection-project-token-project.png) + + + ![Project Token Settings Page](/images/app-connections/railway/railway-app-connection-project-token-settings.png) + + + Provide a descriptive name and select the appropriate environment for the token. + + ![Enter Name and Select environment](/images/app-connections/railway/railway-app-connection-project-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-project-token-create.png) + + + After clicking 'Create', the access token will be displayed. Be sure to save it for later use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-project-token-created.png) + + + + + +## Create a Railway Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Railway Connection** from the list of integrations. + + ![Select Railway Connection](/images/app-connections/railway/railway-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The type of token you created earlier + - The token value from the previous step + + ![Railway Connection Modal](/images/app-connections/railway/railway-app-connection-form.png) + + + After submitting the form, your **Railway Connection** will be successfully created and ready to use with your Infisical projects. + + ![Railway Connection Created](/images/app-connections/railway/railway-app-connection-generated.png) + + + + + + To create a Railway Connection via API, send a request to the [Create Railway Connection](/api-reference/endpoints/app-connections/railway/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/railway \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-railway-connection", + "method": "team-token", + "credentials": { + "apiToken": "[TEAM TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-railway-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "railway", + "method": "team-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/app-connections/zabbix.mdx b/docs/integrations/app-connections/zabbix.mdx new file mode 100644 index 000000000..c4d47b22e --- /dev/null +++ b/docs/integrations/app-connections/zabbix.mdx @@ -0,0 +1,101 @@ +--- +title: "Zabbix Connection" +description: "Learn how to configure a Zabbix Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/current/en/manual/web_interface/frontend_sections/users/api_tokens) to connect with Zabbix. + +## Create Zabbix API Token + + + + ![Dashboard Page](/images/app-connections/zabbix/zabbix-dashboard.png) + + + ![Click Create Token](/images/app-connections/zabbix/zabbix-api-token-list.png) + + + Ensure that you give this token access to the correct app, then click 'Create Token'. + + ![Create Token Page](/images/app-connections/zabbix/zabbix-api-token-form.png) + + + After clicking 'Create Token', a modal containing your access token will appear. Save this token for later steps. + ![Copy Token Modal](/images/app-connections/zabbix/zabbix-api-token-generated.png) + + + +## Create Zabbix Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Zabbix Connection** option from the available integrations. + + ![Select Zabbix Connection](/images/app-connections/zabbix/zabbix-app-connection-option.png) + + + Complete the Zabbix Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The Zabbix URL for your instance + - The API Token from earlier steps + + ![Zabbix Connection Modal](/images/app-connections/zabbix/zabbix-app-connection-form.png) + + + After clicking Create, your **Zabbix Connection** is established and ready to use with your Infisical projects. + + ![Zabbix Connection Created](/images/app-connections/zabbix/zabbix-app-connection-generated.png) + + + + + To create a Zabbix Connection, make an API request to the [Create Zabbix Connection](/api-reference/endpoints/app-connections/zabbix/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/zabbix \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-zabbix-connection", + "method": "api-token", + "credentials": { + "apiToken": "[API TOKEN]", + "instanceUrl": "https://zabbix.example.com" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-zabbix-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "zabbix", + "method": "api-token", + "credentials": { + "instanceUrl": "https://zabbix.example.com" + } + } + } + ``` + + diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 08debf8cf..5dc06daed 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -35,7 +35,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | | [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | | [Windmill](/integrations/cloud/windmill) | Cloud | Available | -| [BitBucket](/integrations/cicd/bitbucket) | CI/CD | Available | +| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | | [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | | [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | | [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | diff --git a/docs/integrations/secret-syncs/railway.mdx b/docs/integrations/secret-syncs/railway.mdx new file mode 100644 index 000000000..d0d546984 --- /dev/null +++ b/docs/integrations/secret-syncs/railway.mdx @@ -0,0 +1,171 @@ +--- +title: "Railway Sync" +description: "Learn how to configure a Railway Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Railway Connection](/integrations/app-connections/railway) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Railway](/images/secret-syncs/railway/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/railway/railway-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/railway/railway-sync-destination.png) + + - **Railway Connection**: The Railway Connection to authenticate with. + - **Project**: The Railway project to sync secrets to. + - **Environment**: The Railway environment to sync secrets to. + - **Service**: The Service to sync secrets to. + - **If not provided**: Secrets will be synced as [shared variables](https://docs.railway.com/guides/variables#shared-variables) on Railway. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/railway/railway-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Railway when keys conflict. + - **Import Secrets (Prioritize Railway)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Railway over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Railway Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/railway/railway-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Railway Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/railway/railway-sync-review.png) + + + If enabled, your Railway Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/railway/railway-sync-created.png) + + + + + To create a **Railway Sync**, make an API request to the [Create Railway Sync](/api-reference/endpoints/secret-syncs/railway/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/railway \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-railway-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "projectId": "dev-project-id", + "projectName": "Development Project", + "environmentId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environmentName": "Development", + "serviceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "serviceName": "my-railway-service", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-railway-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "railway", + "name": "my-railway-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "railway", + "destinationConfig": { + "projectId": "dev-project-id", + "projectName": "Development Project", + "environmentId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environmentName": "Development", + "serviceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "serviceName": "my-railway-service", + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/zabbix.mdx b/docs/integrations/secret-syncs/zabbix.mdx new file mode 100644 index 000000000..bb3292069 --- /dev/null +++ b/docs/integrations/secret-syncs/zabbix.mdx @@ -0,0 +1,173 @@ +--- +title: "Zabbix Sync" +description: "Learn how to configure a Zabbix Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Zabbix Connection](/integrations/app-connections/zabbix) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Zabbix](/images/secret-syncs/zabbix/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/zabbix/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/zabbix/configure-destination.png) + + - **Zabbix Connection**: The Zabbix Connection to authenticate with. + - **Scope**: The Zabbix scope to sync secrets to. + - **Global**: Secrets will be synced globally. + - **Host**: Secrets will be synced to the specified host. + - **Macro Type**: The type of macro to use when syncing secrets to Zabbix. Currently only **Text** and **Secret** macros are supported. + The remaining fields are determined by the selected **Scope**: + + + - **Host**: The host to sync secrets to. + + + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/zabbix/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Zabbix when keys conflict. + - **Import Secrets (Prioritize Zabbix)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Zabbix over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Zabbix Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/zabbix/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Zabbix Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/zabbix/review-configuration.png) + + + If enabled, your Zabbix Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/zabbix/sync-created.png) + + + + + To create a **Zabbix Sync**, make an API request to the [Create Zabbix Sync](/api-reference/endpoints/secret-syncs/zabbix/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/zabbix \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-zabbix-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "scope": "host", + "hostId": "my-zabbix-host", + "hostName": "my-zabbix-host", + "macroType": 0 + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-zabbix-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "zabbix", + "name": "my-zabbix-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "zabbix", + "destinationConfig": { + "scope": "host", + "hostId": "my-zabbix-host", + "hostName": "my-zabbix-host", + "macroType": 0 + } + } + } + ``` + + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 90f3b2207..1e050ff14 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -59,6 +59,15 @@ Example values: connect with internal/private IP addresses. + + Determines whether your Infisical instance can automatically read the service account token of the pod it's running on. Used for features such as the IRSA auth method. + + ## CORS Cross-Origin Resource Sharing (CORS) is a security feature that allows web applications running on one domain to access resources from another domain. @@ -669,11 +678,11 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I - OAuth2 client ID for BitBucket integration + OAuth2 client ID for Bitbucket integration - OAuth2 client secret for BitBucket integration + OAuth2 client secret for Bitbucket integration @@ -794,3 +803,9 @@ If export type is set to `otlp`, you will have to configure a value for `OTEL_EX The TLS header used to propagate the client certificate from the load balancer to the server. + +## Environment Variable Overrides + +If you can't directly access and modify environment variables, you can update them using the [Server Admin Console](/documentation/platform/admin-panel/server-admin). + +![Environment Variables Overrides Page](../../images/self-hosting/configuration/overrides/page.png) diff --git a/docs/self-hosting/deployment-options/docker-compose.mdx b/docs/self-hosting/deployment-options/docker-compose.mdx index 440811e65..65a0f05dd 100644 --- a/docs/self-hosting/deployment-options/docker-compose.mdx +++ b/docs/self-hosting/deployment-options/docker-compose.mdx @@ -4,17 +4,20 @@ description: "Read how to run Infisical with Docker Compose template." --- This self-hosting guide will walk you through the steps to self-host Infisical using Docker Compose. -## Prerequisites -- [Docker](https://docs.docker.com/engine/install/) -- [Docker compose](https://docs.docker.com/compose/install/) - -This Docker Compose configuration is not designed for high-availability production scenarios. -It includes just the essential components needed to set up an Infisical proof of concept (POC). -To run Infisical in a highly available manner, give the [Docker Swarm guide](/self-hosting/deployment-options/docker-swarm). - + + + ## Prerequisites + - [Docker](https://docs.docker.com/engine/install/) + - [Docker compose](https://docs.docker.com/compose/install/) -## Verify prerequisites + + This Docker Compose configuration is not designed for high-availability production scenarios. + It includes just the essential components needed to set up an Infisical proof of concept (POC). + To run Infisical in a highly available manner, give the [Docker Swarm guide](/self-hosting/deployment-options/docker-swarm). + + + ## Verify prerequisites To verify that Docker compose and Docker are installed on the machine where you plan to install Infisical, run the following commands. Check for docker installation @@ -27,55 +30,145 @@ To run Infisical in a highly available manner, give the [Docker Swarm guide](/se docker-compose ``` -## Download docker compose file -You can obtain the Infisical docker compose file by using a command-line downloader such as `wget` or `curl`. -If your system doesn't have either of these, you can use a equivalent command that works with your machine. + ## Download docker compose file + You can obtain the Infisical docker compose file by using a command-line downloader such as `wget` or `curl`. + If your system doesn't have either of these, you can use a equivalent command that works with your machine. + + + + ```bash + curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + ```bash + wget -O docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + + ## Configure instance credentials + Infisical requires a set of credentials used for connecting to dependent services such as Postgres, Redis, etc. + The default credentials can be downloaded using the one of the commands listed below. + + + + ```bash + curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + ```bash + wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + + Once downloaded, the credentials file will be saved to your working directly as `.env` file. + View all available configurations [here](/self-hosting/configuration/envars). + + + The default .env file contains credentials that are intended solely for testing purposes. + Please generate a new `ENCRYPTION_KEY` and `AUTH_SECRET` for use outside of testing. + Instructions to do so, can be found [here](/self-hosting/configuration/envars). + + + ## Start Infisical + Run the command below to start Infisical and all related services. - - ```bash - curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + docker-compose -f docker-compose.prod.yml up ``` + - - ```bash - wget -O docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + + Podman Compose is an alternative way to run Infisical using Podman as a replacement for Docker. Podman is backwards compatible with Docker Compose files. + + ## Prerequisites + - [Podman](https://podman-desktop.io/docs/installation) + - [Podman Compose](https://podman-desktop.io/docs/compose) + + + This Docker Compose configuration is not designed for high-availability production scenarios. + It includes just the essential components needed to set up an Infisical proof of concept (POC). + To run Infisical in a highly available manner, give the [Docker Swarm guide](/self-hosting/deployment-options/docker-swarm). + + + + ## Verify prerequisites + To verify that Podman compose and Podman are installed on the machine where you plan to install Infisical, run the following commands. + + Check for podman installation + ```bash + podman version + ``` + + Check for podman compose installation + ```bash + podman-compose version + ``` + + ## Download Docker Compose file + You can obtain the Infisical docker compose file by using a command-line downloader such as `wget` or `curl`. + If your system doesn't have either of these, you can use a equivalent command that works with your machine. + + + + ```bash + curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + ```bash + wget -O docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + + ## Configure instance credentials + Infisical requires a set of credentials used for connecting to dependent services such as Postgres, Redis, etc. + The default credentials can be downloaded using the one of the commands listed below. + + + + ```bash + curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + ```bash + wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + + + Make sure to rename the `.env.example` file to `.env` before starting Infisical. Additionally it's important that the `.env` file is in the same directory as the `docker-compose.prod.yml` file. + + + ## Setup Podman + Run the commands below to setup Podman for first time use. + ```bash + podman machine init --now + podman machine set --rootful + podman machine start + ``` + + + If you are using a rootless podman installation, you can skip the `podman machine set --rootful` command. + + + ## Start Infisical + Run the command below to start Infisical and all related services. + + ```bash + podman-compose -f docker-compose.prod.yml up ``` -## Configure instance credentials -Infisical requires a set of credentials used for connecting to dependent services such as Postgres, Redis, etc. -The default credentials can be downloaded using the one of the commands listed below. - - - ```bash - curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example - ``` - - - ```bash - wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example - ``` - - -Once downloaded, the credentials file will be saved to your working directly as `.env` file. -View all available configurations [here](/self-hosting/configuration/envars). - - - The default .env file contains credentials that are intended solely for testing purposes. - Please generate a new `ENCRYPTION_KEY` and `AUTH_SECRET` for use outside of testing. - Instructions to do so, can be found [here](/self-hosting/configuration/envars). - - -## Start Infisical -Run the command below to start Infisical and all related services. - -```bash -docker-compose -f docker-compose.prod.yml up -``` Your Infisical instance should now be running on port `80`. To access your instance, visit `http://localhost:80`. diff --git a/frontend/public/images/integrations/BitBucket.png b/frontend/public/images/integrations/Bitbucket.png similarity index 100% rename from frontend/public/images/integrations/BitBucket.png rename to frontend/public/images/integrations/Bitbucket.png diff --git a/frontend/public/images/integrations/Zabbix.png b/frontend/public/images/integrations/Zabbix.png new file mode 100644 index 000000000..3ac67d2b4 Binary files /dev/null and b/frontend/public/images/integrations/Zabbix.png differ diff --git a/frontend/public/lotties/wrench.json b/frontend/public/lotties/wrench.json new file mode 100644 index 000000000..be373f39b --- /dev/null +++ b/frontend/public/lotties/wrench.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-22-build","ddd":0,"assets":[{"id":"comp_1","nm":"hover-build","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[1],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[1],"y":[0]},"t":10,"s":[-45]},{"i":{"x":[0.26],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[-45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.74],"y":[0]},"t":28,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[-45]},{"i":{"x":[0.101],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-45]},{"i":{"x":[0.101],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":52,"s":[7]},{"t":58,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":1,"s":[314.5,185,0],"to":[0,-0.833,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":10,"s":[314.5,180,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.26,"y":1},"o":{"x":0.333,"y":0},"t":19,"s":[314.5,180,0],"to":[0,0.833,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.74,"y":0},"t":28,"s":[314.5,185,0],"to":[0,0,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":37,"s":[314.5,180,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":46,"s":[314.5,180,0],"to":[0,0.833,0],"ti":[0,-0.833,0]},{"t":52,"s":[314.5,185,0]}],"ix":2,"l":2},"a":{"a":0,"k":[314.5,185,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.104,0],[0.104,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":41.73,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[145.836,354.17],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[22.113,40.698],[0,0],[0,0],[0,0],[34.989,-34.987],[-8.119,-34.725],[0,0],[-16.272,-16.272],[0,0],[-16.271,16.272],[0,0],[-27.153,27.152]],"o":[[0,0],[0,0],[0,0],[-40.697,-22.839],[-27.152,27.153],[0,0],[-16.272,16.272],[0,0],[16.272,16.272],[0,0],[34.725,8.118],[34.622,-34.624]],"v":[[159.225,-115.686],[75.4,-31.862],[32.739,-74.523],[116.745,-158.53],[-11.514,-140.28],[-40.441,-40.189],[-159.877,79.247],[-159.877,138.173],[-138.203,159.847],[-79.278,159.847],[40.159,40.412],[140.249,11.484]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":59,"st":-8,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.041,250.001,0],"ix":2,"l":2},"a":{"a":0,"k":[250.002,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.55,0],[0,0.55],[-0.55,0],[0,0],[0,-0.55]],"o":[[-0.55,0],[0,-0.55],[0,0],[0.55,0],[0,0.56]],"v":[[0,1],[-1,0],[-0.01,-1],[0,-1],[1,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245.01,254.99],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.39,0],[0.86,-0.85],[-0.34,-1.46],[0.18,-0.18],[0,0],[0,-0.33],[-0.24,-0.23],[0,0],[-0.47,0.47],[0,0],[-0.25,-0.06],[-1.08,1.08],[0,0],[0.39,1.49],[0,0],[0.29,0.29],[0,0],[-0.29,0.29],[0,0]],"o":[[-1.14,0],[-1.07,1.07],[0.06,0.25],[0,0],[-0.24,0.24],[0,0.33],[0,0],[0.47,0.47],[0,0],[0.18,-0.18],[1.45,0.34],[0,0],[1.14,-1.14],[0,0],[-0.29,0.29],[0,0],[-0.29,-0.29],[0,0],[-0.39,-0.1]],"v":[[3.108,-7.5],[-0.032,-6.2],[-1.222,-2.1],[-1.422,-1.4],[-7.142,4.33],[-7.512,5.21],[-7.142,6.09],[-6.102,7.13],[-4.342,7.13],[1.378,1.4],[2.078,1.2],[6.178,0.01],[6.178,0.01],[7.348,-4.23],[4.138,-1],[3.078,-1],[1.038,-3.05],[1.038,-4.11],[4.278,-7.35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.74,0],[0.52,0.52],[0,0],[0,0.74],[-0.51,0.52],[0,0],[-1.34,1.33],[-2.28,-1.28],[-0.04,-0.23],[0.16,-0.16],[0,0],[0,0],[0,0],[-0.24,-0.04],[-0.11,-0.2],[1.87,-1.88],[1.83,0.28],[0,0]],"o":[[-0.73,0],[0,0],[-0.51,-0.51],[0,-0.73],[0,0],[-0.29,-1.83],[1.87,-1.87],[0.2,0.11],[0.03,0.23],[0,0],[0,0],[0,0],[0.17,-0.17],[0.23,0.03],[1.25,2.31],[-1.33,1.33],[0,0],[-0.52,0.51]],"v":[[-5.222,9],[-7.162,8.19],[-8.202,7.15],[-9.002,5.21],[-8.202,3.27],[-2.762,-2.18],[-1.092,-7.26],[5.948,-8.26],[6.328,-7.71],[6.118,-7.08],[2.618,-3.58],[3.608,-2.59],[7.098,-6.08],[7.738,-6.29],[8.288,-5.91],[7.248,1.08],[2.168,2.75],[-3.272,8.2]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.002,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":-59,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.041,250.001,0],"ix":2,"l":2},"a":{"a":0,"k":[250.002,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.55,0],[0,0.55],[-0.55,0],[0,0],[0,-0.55]],"o":[[-0.55,0],[0,-0.55],[0,0],[0.55,0],[0,0.56]],"v":[[0,1],[-1,0],[-0.01,-1],[0,-1],[1,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245.01,254.99],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.39,0],[0.86,-0.85],[-0.34,-1.46],[0.18,-0.18],[0,0],[0,-0.33],[-0.24,-0.23],[0,0],[-0.47,0.47],[0,0],[-0.25,-0.06],[-1.08,1.08],[0,0],[0.39,1.49],[0,0],[0.29,0.29],[0,0],[-0.29,0.29],[0,0]],"o":[[-1.14,0],[-1.07,1.07],[0.06,0.25],[0,0],[-0.24,0.24],[0,0.33],[0,0],[0.47,0.47],[0,0],[0.18,-0.18],[1.45,0.34],[0,0],[1.14,-1.14],[0,0],[-0.29,0.29],[0,0],[-0.29,-0.29],[0,0],[-0.39,-0.1]],"v":[[3.108,-7.5],[-0.032,-6.2],[-1.222,-2.1],[-1.422,-1.4],[-7.142,4.33],[-7.512,5.21],[-7.142,6.09],[-6.102,7.13],[-4.342,7.13],[1.378,1.4],[2.078,1.2],[6.178,0.01],[6.178,0.01],[7.348,-4.23],[4.138,-1],[3.078,-1],[1.038,-3.05],[1.038,-4.11],[4.278,-7.35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.74,0],[0.52,0.52],[0,0],[0,0.74],[-0.51,0.52],[0,0],[-1.34,1.33],[-2.28,-1.28],[-0.04,-0.23],[0.16,-0.16],[0,0],[0,0],[0,0],[-0.24,-0.04],[-0.11,-0.2],[1.87,-1.88],[1.83,0.28],[0,0]],"o":[[-0.73,0],[0,0],[-0.51,-0.51],[0,-0.73],[0,0],[-0.29,-1.83],[1.87,-1.87],[0.2,0.11],[0.03,0.23],[0,0],[0,0],[0,0],[0.17,-0.17],[0.23,0.03],[1.25,2.31],[-1.33,1.33],[0,0],[-0.52,0.51]],"v":[[-5.222,9],[-7.162,8.19],[-8.202,7.15],[-9.002,5.21],[-8.202,3.27],[-2.762,-2.18],[-1.092,-7.26],[5.948,-8.26],[6.328,-7.71],[6.118,-7.08],[2.618,-3.58],[3.608,-2.59],[7.098,-6.08],[7.738,-6.29],[8.288,-5.91],[7.248,1.08],[2.168,2.75],[-3.272,8.2]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-22-build').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.002,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":59,"op":300,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.914,0.91,0.91],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-build","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-build","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 0b3a8d038..22f47fa17 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -65,6 +65,7 @@ export const createNotification = ( toast(, { position: "bottom-right", ...toastProps, + autoClose: toastProps.autoClose || 15000, theme: "dark", type: myProps?.type || "info" }); diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/BitbucketDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/BitbucketDataSourceConfigFields.tsx new file mode 100644 index 000000000..092e7d623 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/BitbucketDataSourceConfigFields.tsx @@ -0,0 +1,172 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue, SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TBitbucketRepo, + TBitbucketWorkspace, + useBitbucketConnectionListRepositories, + useBitbucketConnectionListWorkspaces +} from "@app/hooks/api/appConnections/bitbucket"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllRepositories = "all-repositories", + SelectRepositories = "select-repositories" +} + +export const BitbucketDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.Bitbucket; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const selectedWorkspaceSlug = useWatch({ control, name: "config.workspaceSlug" }); + + const { data: workspaces, isPending: areWorkspacesLoading } = + useBitbucketConnectionListWorkspaces(connectionId, { enabled: Boolean(connectionId) }); + + const { data: repositories, isPending: areRepositoriesLoading } = + useBitbucketConnectionListRepositories(connectionId, selectedWorkspaceSlug, { + enabled: Boolean(connectionId) && Boolean(selectedWorkspaceSlug) + }); + + const includeRepos = watch("config.includeRepos"); + + const scanMethod = + !includeRepos || includeRepos[0] === "*" + ? ScanMethod.AllRepositories + : ScanMethod.SelectRepositories; + + useEffect(() => { + if (!includeRepos) { + setValue("config.includeRepos", ["*"]); + } + }, [includeRepos, setValue]); + + return ( + <> + { + if (scanMethod === ScanMethod.SelectRepositories) { + setValue("config.workspaceSlug", ""); + setValue("config.includeRepos", []); + } + }} + /> + ( + Ensure that your connection has the correct permissions.} + > +

+ Don't see the workspaces you're looking for?{" "} + +
+ + } + > + { + onChange((newValue as SingleValue)?.slug); + if (scanMethod === ScanMethod.SelectRepositories) { + setValue("config.includeRepos", []); + } + }} + options={workspaces} + placeholder="Select workspace..." + getOptionLabel={(option) => option.slug} + getOptionValue={(option) => option.slug} + /> + + )} + /> + + + + {scanMethod === ScanMethod.SelectRepositories && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the repository you're looking for?{" "} + +
+ + } + > + + (value as string[]).includes(repository.full_name) + )} + onChange={(newValue) => { + onChange( + newValue ? (newValue as MultiValue).map((p) => p.full_name) : [] + ); + }} + options={repositories} + placeholder="Select repositories..." + getOptionLabel={(option) => option.full_name} + getOptionValue={(option) => option.full_name} + /> +
+ )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx index bebcf28fc..cbdc0f0b8 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -5,10 +5,12 @@ import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; +import { BitbucketDataSourceConfigFields } from "./BitbucketDataSourceConfigFields"; import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; const COMPONENT_MAP: Record = { - [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields + [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields, + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields }; export const SecretScanningDataSourceConfigFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/BitbucketDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/BitbucketDataSourceReviewFields.tsx new file mode 100644 index 000000000..1523cb0ad --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/BitbucketDataSourceReviewFields.tsx @@ -0,0 +1,28 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConfigReviewSection } from "./shared"; + +export const BitbucketDataSourceReviewFields = () => { + const { watch } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.Bitbucket; + } + >(); + + const [{ includeRepos, workspaceSlug }, connection] = watch(["config", "connection"]); + const shouldScanAll = includeRepos[0] === "*"; + + return ( + + {connection && {connection.name}} + {workspaceSlug} + + {shouldScanAll ? "All" : includeRepos.join(", ")} + + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx index 021b5e679..e29dc9ebd 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx @@ -4,10 +4,12 @@ import { GenericFieldLabel } from "@app/components/v2"; import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; +import { BitbucketDataSourceReviewFields } from "./BitbucketDataSourceReviewFields"; import { GitHubDataSourceReviewFields } from "./GitHubDataSourceReviewFields"; const COMPONENT_MAP: Record = { - [SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields + [SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields, + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields }; export const SecretScanningDataSourceReviewFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/schemas/bitbucket-data-source-schema.ts b/frontend/src/components/secret-scanning/forms/schemas/bitbucket-data-source-schema.ts new file mode 100644 index 000000000..38345ca07 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/schemas/bitbucket-data-source-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { BaseSecretScanningDataSourceSchema } from "./base-secret-scanning-data-source-schema"; + +export const BitbucketDataSourceSchema = z + .object({ + type: z.literal(SecretScanningDataSource.Bitbucket), + config: z.object({ + workspaceSlug: z.string().min(1, "Workspace Required").max(128), + includeRepos: z + .string() + .array() + .min(1, "One or more repositories required") + .max(100, "Cannot configure more than 100 repositories") + }) + }) + .merge(BaseSecretScanningDataSourceSchema({ isConnectionRequired: true })); diff --git a/frontend/src/components/secret-scanning/forms/schemas/index.ts b/frontend/src/components/secret-scanning/forms/schemas/index.ts index bfb1ae5ec..23e6d86e4 100644 --- a/frontend/src/components/secret-scanning/forms/schemas/index.ts +++ b/frontend/src/components/secret-scanning/forms/schemas/index.ts @@ -1,9 +1,11 @@ import { z } from "zod"; +import { BitbucketDataSourceSchema } from "./bitbucket-data-source-schema"; import { GitHubDataSourceSchema } from "./github-data-source-schema"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ - GitHubDataSourceSchema + GitHubDataSourceSchema, + BitbucketDataSourceSchema ]); export type TSecretScanningDataSourceForm = z.infer; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx index dd54dbf63..15169c73a 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx @@ -60,7 +60,7 @@ export const OnePassSyncFields = () => { menuPlacement="top" isLoading={isVaultsLoading && Boolean(connectionId)} isDisabled={!connectionId} - value={vaults?.find((v) => v.id === value) ?? null} + value={vaults?.find((v) => v.id === value) || null} onChange={(option) => onChange((option as SingleValue)?.id ?? null)} options={vaults} placeholder="Select a vault..." diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/OCIVaultSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/OCIVaultSyncFields.tsx index 26fa601f6..12092e301 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/OCIVaultSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/OCIVaultSyncFields.tsx @@ -120,7 +120,7 @@ export const OCIVaultSyncFields = () => { menuPlacement="top" isLoading={isVaultsLoading && Boolean(connectionId)} isDisabled={!connectionId || !selectedCompartment} - value={vaults?.find((v) => v.id === value) ?? null} + value={vaults?.find((v) => v.id === value) || null} onChange={(option) => { onChange((option as SingleValue<{ id: string }>)?.id ?? null); setValue("destinationConfig.keyOcid", ""); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx new file mode 100644 index 000000000..6095f5763 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TRailwayProject, + useRailwayConnectionListProjects +} from "@app/hooks/api/appConnections/railway"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const RailwaySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Railway } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useRailwayConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const environments = useMemo(() => { + return projects.find((p) => p.id === projectId)?.environments ?? []; + }, [projects, projectId]); + + const services = useMemo(() => { + return projects.find((p) => p.id === projectId)?.services ?? []; + }, [projects, projectId]); + + return ( + <> + { + setValue("destinationConfig.environmentId", ""); + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.environmentName", ""); + setValue("destinationConfig.serviceName", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.environmentName", v?.name ?? ""); + }} + options={environments} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.serviceName", v?.name ?? ""); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b6074d270..f530e0a52 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -20,11 +20,13 @@ import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; +import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; +import { ZabbixSyncFields } from "./ZabbixSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -76,6 +78,10 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.CloudflarePages: return ; + case SecretSync.Zabbix: + return ; + case SecretSync.Railway: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx new file mode 100644 index 000000000..e00100284 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx @@ -0,0 +1,147 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TZabbixHost, + useZabbixConnectionListHosts, + ZABBIX_SYNC_SCOPES, + ZabbixMacroType, + ZabbixSyncScope +} from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const ZabbixSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Zabbix } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentScope = watch("destinationConfig.scope"); + + const { data: hosts = [], isPending: isHostsPending } = useZabbixConnectionListHosts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.scope", ZabbixSyncScope.Global); + setValue("destinationConfig.hostId", ""); + setValue("destinationConfig.hostName", ""); + }} + /> + ( + +

+ Specify how Infisical should manage secrets from Zabbix. The following options are + available: +

+
    + {Object.values(ZABBIX_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+ + } + > + +
+ )} + /> + {currentScope === ZabbixSyncScope.Host && ( + ( + + host.hostId === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue; + onChange(selectedOption?.hostId ?? null); + + if (selectedOption) { + setValue("destinationConfig.hostName", selectedOption.host); + } else { + setValue("destinationConfig.hostName", ""); + } + }} + options={hosts} + placeholder="Select a host..." + getOptionLabel={(option) => option.host} + getOptionValue={(option) => option.hostId} + /> + + )} + /> + )} + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index a61c2a6b8..57967f130 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -23,6 +23,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { const { control, watch } = useFormContext(); const destination = watch("destination"); + const currentSyncOption = watch("syncOptions"); const destinationName = SECRET_SYNC_MAP[destination].name; @@ -57,6 +58,8 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Flyio: case SecretSync.GitLab: case SecretSync.CloudflarePages: + case SecretSync.Zabbix: + case SecretSync.Railway: AdditionalSyncOptionsFieldsComponent = null; break; default: @@ -127,8 +130,9 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { {!syncOption?.canImportSecrets && (

- {destinationName} only supports overwriting destination secrets. Secrets not present - in Infisical will be removed from the destination. + {destinationName} only supports overwriting destination secrets.{" "} + {!currentSyncOption.disableSecretDeletion && + "Secrets not present in Infisical will be removed from the destination."}

)} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx new file mode 100644 index 000000000..717ad9ae7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx @@ -0,0 +1,29 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { useRailwayConnectionListProjects } from "@app/hooks/api/appConnections/railway"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const RailwaySyncReviewFields = () => { + const { watch } = useFormContext(); + const connectionId = watch("connection.id"); + const projectId = watch("destinationConfig.projectId"); + const environmentId = watch("destinationConfig.environmentId"); + + const { data: projects = [] } = useRailwayConnectionListProjects(connectionId, { + enabled: Boolean(connectionId) + }); + + const project = projects.find((p) => p.id === projectId); + const environment = project?.environments.find((e) => e.id === environmentId); + + return ( + <> + {project?.name ?? projectId} + + {environment?.name ?? environmentId} + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index fb639e91b..3b3a9cf74 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -30,11 +30,13 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; +import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; import { RenderSyncReviewFields } from "./RenderSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; +import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -124,6 +126,12 @@ export const SecretSyncReviewFields = () => { case SecretSync.CloudflarePages: DestinationFieldsComponent = ; break; + case SecretSync.Zabbix: + DestinationFieldsComponent = ; + break; + case SecretSync.Railway: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx new file mode 100644 index 000000000..c58e35382 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx @@ -0,0 +1,31 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +const isTextMacro = (macroType: number) => macroType === 0; + +export const ZabbixSyncReviewFields = () => { + const { watch } = useFormContext(); + const scope = watch("destinationConfig.scope"); + const hostId = watch("destinationConfig.hostId"); + const hostName = watch("destinationConfig.hostName"); + const macroType = watch("destinationConfig.macroType"); + + return ( + <> + {scope} + {scope === ZabbixSyncScope.Host && ( + <> + {hostId} + {hostName} + + )} + + {isTextMacro(macroType) ? "Text" : "Secret"} + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts new file mode 100644 index 000000000..2870d6087 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const RailwaySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Railway), + destinationConfig: z.object({ + projectId: z.string().min(1, "Project ID is required"), + projectName: z.string(), + environmentName: z.string(), + environmentId: z.string().min(1, "Environment is required"), + serviceId: z.string().optional(), + serviceName: z.string().optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5d15492eb..425fd2414 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -17,11 +17,13 @@ import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; +import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; +import { ZabbixSyncDestinationSchema } from "./zabbix-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -45,7 +47,9 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ RenderSyncDestinationSchema, FlyioSyncDestinationSchema, GitlabSyncDestinationSchema, - CloudflarePagesSyncDestinationSchema + CloudflarePagesSyncDestinationSchema, + ZabbixSyncDestinationSchema, + RailwaySyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts new file mode 100644 index 000000000..694c6c7af --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { ZabbixMacroType, ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const ZabbixSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Zabbix), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(ZabbixSyncScope.Host), + hostId: z.string().trim().min(1, "Host ID required"), + hostName: z.string().trim().min(1, "Host name required"), + macroType: z.nativeEnum(ZabbixMacroType, { + errorMap: () => ({ message: "Macro type must be either 'text' or 'secret'" }) + }) + }), + z.object({ + scope: z.literal(ZabbixSyncScope.Global), + macroType: z.nativeEnum(ZabbixMacroType, { + errorMap: () => ({ message: "Macro type must be either 'text' or 'secret'" }) + }) + }) + ]) + }) +); diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index d1df7cd68..3e5a57edf 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -77,3 +77,91 @@ export const parseJson = (src: ArrayBuffer | string) => { }); return env; }; + +/** + * Parses simple flat YAML with support for multiline strings using |, |-, and >. + * @param {ArrayBuffer | string} src + * @returns {Record} + */ +export function parseYaml(src: ArrayBuffer | string) { + const result: Record = {}; + + const content = src.toString().replace(/\r\n?/g, "\n"); + const lines = content.split("\n"); + + let i = 0; + let comments: string[] = []; + + while (i < lines.length) { + const line = lines[i].trim(); + + // Collect comment + if (line.startsWith("#")) { + comments.push(line.slice(1).trim()); + i += 1; // move to next line + } else { + // Match key: value or key: |, key: >, etc. + const keyMatch = lines[i].match(/^([\w.-]+)\s*:\s*(.*)$/); + if (keyMatch) { + const [, key, rawValue] = keyMatch; + let value = rawValue.trim(); + + // Multiline string handling + if (value === "|-" || value === "|" || value === ">") { + const isFolded = value === ">"; + + const baseIndent = lines[i + 1]?.match(/^(\s*)/)?.[1]?.length ?? 0; + const collectedLines: string[] = []; + + i += 1; // move to first content line + + while (i < lines.length) { + const current = lines[i]; + const currentIndent = current.match(/^(\s*)/)?.[1]?.length ?? 0; + + if (current.trim() === "" || currentIndent >= baseIndent) { + collectedLines.push(current.slice(baseIndent)); + i += 1; // move to next line + } else { + break; + } + } + + if (isFolded) { + // Join lines with space for `>` folded style + value = collectedLines.map((l) => l.trim()).join(" "); + } else { + // Keep lines with newlines for `|` and `|-` + value = collectedLines.join("\n"); + } + } else { + // Inline value — strip quotes and inline comment + const commentIndex = value.indexOf(" #"); + if (commentIndex !== -1) { + value = value.slice(0, commentIndex).trim(); + } + + // Remove surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + i += 1; // advance to next line + } + + result[key] = { + value, + comments: [...comments] + }; + comments = []; // reset + } else { + i += 1; // skip unknown line + } + } + } + + return result; +} diff --git a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx index 3254dd9ff..f60668aec 100644 --- a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx +++ b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx @@ -33,7 +33,7 @@ export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { className )} > - + {text && isTextArray && ( ; + isActive: boolean; + scrollOffset: number; + heightOffset: number; +}) => { + return ( + <> +
+
+
+
+ + ); +}; diff --git a/frontend/src/components/v2/HeaderResizer/index.tsx b/frontend/src/components/v2/HeaderResizer/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx new file mode 100644 index 000000000..3ce7c8f19 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -0,0 +1,42 @@ +export const HighlightText = ({ + text, + highlight, + highlightClassName +}: { + text: string | undefined | null; + highlight: string; + highlightClassName?: string; +}) => { + if (!text) return null; + const searchTerm = highlight.toLowerCase().trim(); + + if (!searchTerm) return {text}; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearchTerm, "gi"); + + text.replace(regex, (match: string, offset: number) => { + if (offset > lastIndex) { + parts.push({text.substring(lastIndex, offset)}); + } + + parts.push( + + {match} + + ); + + lastIndex = offset + match.length; + + return match; + }); + + if (lastIndex < text.length) { + parts.push({text.substring(lastIndex)}); + } + + return parts; +}; diff --git a/frontend/src/components/v2/HighlightText/index.tsx b/frontend/src/components/v2/HighlightText/index.tsx new file mode 100644 index 000000000..a2ff1b504 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/index.tsx @@ -0,0 +1 @@ +export { HighlightText } from "./HighlightText"; diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index 1d5a9c394..cc180ffb6 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -45,10 +45,14 @@ export const Table = ({ children, className }: TableProps): JSX.Element => ( export type THeadProps = { children: ReactNode; className?: string; + style?: React.CSSProperties; }; -export const THead = ({ children, className }: THeadProps): JSX.Element => ( - +export const THead = ({ children, className, style }: THeadProps): JSX.Element => ( + {children} ); @@ -96,14 +100,16 @@ export const Tr = ({ export type ThProps = { children?: ReactNode; className?: string; + style?: React.CSSProperties; }; -export const Th = ({ children, className }: ThProps): JSX.Element => ( +export const Th = ({ children, className, style }: ThProps): JSX.Element => ( {children} diff --git a/frontend/src/components/v2/Tooltip/Tooltip.tsx b/frontend/src/components/v2/Tooltip/Tooltip.tsx index 2eb443b4e..12d4397d9 100644 --- a/frontend/src/components/v2/Tooltip/Tooltip.tsx +++ b/frontend/src/components/v2/Tooltip/Tooltip.tsx @@ -43,23 +43,25 @@ export const Tooltip = ({ onOpenChange={onOpenChange} > {children} - - {content} - - + + + {content} + + + ) : ( // eslint-disable-next-line react/jsx-no-useless-fragment diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 296c6a87d..63446c95f 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -26,6 +26,7 @@ export const envConfig = { import.meta.env.VITE_TELEMETRY_CAPTURING_ENABLED === true ); }, + get PLATFORM_VERSION() { return import.meta.env.VITE_INFISICAL_PLATFORM_VERSION; } diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index e1fd9e365..900067180 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -37,10 +37,13 @@ import { TeamCityConnectionMethod, TerraformCloudConnectionMethod, VercelConnectionMethod, - WindmillConnectionMethod + WindmillConnectionMethod, + ZabbixConnectionMethod } from "@app/hooks/api/appConnections/types"; +import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/bitbucket-connection"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; +import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; export const APP_CONNECTION_MAP: Record< @@ -88,7 +91,10 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Render]: { name: "Render", image: "Render.png" }, [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, - [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" } + [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, + [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, + [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, + [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -120,6 +126,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case VercelConnectionMethod.ApiToken: case OnePassConnectionMethod.ApiToken: case CloudflareConnectionMethod.ApiToken: + case BitbucketConnectionMethod.ApiToken: + case ZabbixConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -140,6 +148,12 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Simple Bind", icon: faLink }; case HerokuConnectionMethod.AuthToken: return { name: "Auth Token", icon: faKey }; + case RailwayConnectionMethod.AccountToken: + return { name: "Account Token", icon: faKey }; + case RailwayConnectionMethod.TeamToken: + return { name: "Team Token", icon: faKey }; + case RailwayConnectionMethod.ProjectToken: + return { name: "Project Token", icon: faKey }; case RenderConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; default: diff --git a/frontend/src/helpers/secretScanningV2.ts b/frontend/src/helpers/secretScanningV2.ts index 877a57468..8021142b5 100644 --- a/frontend/src/helpers/secretScanningV2.ts +++ b/frontend/src/helpers/secretScanningV2.ts @@ -19,6 +19,11 @@ export const SECRET_SCANNING_DATA_SOURCE_MAP: Record< name: "GitHub", image: "GitHub.png", size: 45 + }, + [SecretScanningDataSource.Bitbucket]: { + name: "Bitbucket", + image: "Bitbucket.png", + size: 45 } }; @@ -26,7 +31,8 @@ export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record< SecretScanningDataSource, AppConnection > = { - [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar + [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket }; export const RESOURCE_DESCRIPTION_HELPER: Record< @@ -45,6 +51,13 @@ export const RESOURCE_DESCRIPTION_HELPER: Record< singularNoun: "repository", pluralTitle: "Repositories", singularTitle: "Repository" + }, + [SecretScanningDataSource.Bitbucket]: { + verb: "push", + pluralNoun: "repositories", + singularNoun: "repository", + pluralTitle: "Repositories", + singularTitle: "Repository" } }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index d6395397d..5821ce959 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -81,6 +81,14 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, [SecretSync.GitLab]: AppConnection.Gitlab, - [SecretSync.CloudflarePages]: AppConnection.Cloudflare + [SecretSync.CloudflarePages]: AppConnection.Cloudflare, + [SecretSync.Zabbix]: AppConnection.Zabbix, + [SecretSync.Railway]: AppConnection.Railway }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index cde04ee61..70e9b883e 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -170,7 +170,7 @@ export type TCreateAccessPolicyDTO = { approvers?: Approver[]; bypassers?: Bypasser[]; approvals?: number; - secretPath?: string; + secretPath: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index c628df955..871c7288c 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -10,6 +10,7 @@ import { AdminGetUsersFilters, AdminIntegrationsConfig, OrganizationWithProjects, + TGetEnvOverrides, TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig @@ -31,7 +32,8 @@ export const adminQueryKeys = { getAdminSlackConfig: () => ["admin-slack-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const, getInvalidateCache: () => ["admin-invalidate-cache"] as const, - getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const + getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const, + getEnvOverrides: () => ["env-overrides"] as const }; export const fetchServerConfig = async () => { @@ -163,3 +165,13 @@ export const useGetInvalidatingCacheStatus = (enabled = true) => { refetchInterval: (data) => (data ? 3000 : false) }); }; + +export const useGetEnvOverrides = () => { + return useQuery({ + queryKey: adminQueryKeys.getEnvOverrides(), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/admin/env-overrides"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c5d92b9da..e7d1f6a48 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -40,6 +40,7 @@ export type TServerConfig = { trustLdapEmails: boolean; trustOidcEmails: boolean; isSecretScanningDisabled: boolean; + kubernetesAutoFetchServiceAccountToken: boolean; defaultAuthOrgSlug: string | null; defaultAuthOrgId: string | null; defaultAuthOrgAuthMethod?: string | null; @@ -48,6 +49,7 @@ export type TServerConfig = { authConsentContent?: string; pageFrameContent?: string; invalidatingCache: boolean; + envOverrides?: Record; }; export type TUpdateServerConfigDTO = { @@ -61,6 +63,7 @@ export type TUpdateServerConfigDTO = { gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; } & Partial; export type TCreateAdminUserDTO = { @@ -138,3 +141,10 @@ export type TInvalidateCacheDTO = { export type TGetInvalidatingCacheStatus = { invalidating: boolean; }; + +export interface TGetEnvOverrides { + [key: string]: { + name: string; + fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/frontend/src/hooks/api/appConnections/bitbucket/index.ts b/frontend/src/hooks/api/appConnections/bitbucket/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/bitbucket/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/bitbucket/queries.tsx b/frontend/src/hooks/api/appConnections/bitbucket/queries.tsx new file mode 100644 index 000000000..bfae0534f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/bitbucket/queries.tsx @@ -0,0 +1,70 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { + TBitbucketConnectionListRepositoriesResponse, + TBitbucketConnectionListWorkspacesResponse, + TBitbucketRepo, + TBitbucketWorkspace +} from "./types"; + +const bitbucketConnectionKeys = { + all: [...appConnectionKeys.all, "bitbucket"] as const, + listRepos: (connectionId: string, workspaceSlug: string) => + [...bitbucketConnectionKeys.all, "repos", connectionId, workspaceSlug] as const, + listWorkspaces: (connectionId: string) => + [...bitbucketConnectionKeys.all, "workspaces", connectionId] as const +}; + +export const useBitbucketConnectionListWorkspaces = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TBitbucketWorkspace[], + unknown, + TBitbucketWorkspace[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: bitbucketConnectionKeys.listWorkspaces(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/bitbucket/${connectionId}/workspaces` + ); + + return data.workspaces; + }, + ...options + }); +}; + +export const useBitbucketConnectionListRepositories = ( + connectionId: string, + workspaceSlug: string, + options?: Omit< + UseQueryOptions< + TBitbucketRepo[], + unknown, + TBitbucketRepo[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: bitbucketConnectionKeys.listRepos(connectionId, workspaceSlug), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/bitbucket/${connectionId}/repositories?workspaceSlug=${encodeURIComponent(workspaceSlug)}` + ); + + return data.repositories; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/bitbucket/types.ts b/frontend/src/hooks/api/appConnections/bitbucket/types.ts new file mode 100644 index 000000000..e7e653a93 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/bitbucket/types.ts @@ -0,0 +1,17 @@ +export type TBitbucketWorkspace = { + slug: string; +}; + +export type TBitbucketRepo = { + uuid: string; + slug: string; + full_name: string; // workspace-slug/repo-slug +}; + +export type TBitbucketConnectionListWorkspacesResponse = { + workspaces: TBitbucketWorkspace[]; +}; + +export type TBitbucketConnectionListRepositoriesResponse = { + repositories: TBitbucketRepo[]; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 8097720a7..e6b623995 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -27,5 +27,8 @@ export enum AppConnection { Render = "render", Flyio = "flyio", Gitlab = "gitlab", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + Bitbucket = "bitbucket", + Zabbix = "zabbix", + Railway = "railway" } diff --git a/frontend/src/hooks/api/appConnections/railway/index.ts b/frontend/src/hooks/api/appConnections/railway/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/railway/queries.tsx b/frontend/src/hooks/api/appConnections/railway/queries.tsx new file mode 100644 index 000000000..0b1e0d51a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TRailwayProject } from "./types"; + +const railwayConnectionKeys = { + all: [...appConnectionKeys.all, "railway"] as const, + listSecretScopes: (connectionId: string) => + [...railwayConnectionKeys.all, "workspace-scopes", connectionId] as const +}; + +export const useRailwayConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRailwayProject[], + unknown, + TRailwayProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: railwayConnectionKeys.listSecretScopes(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TRailwayProject[] }>( + `/api/v1/app-connections/railway/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/railway/types.ts b/frontend/src/hooks/api/appConnections/railway/types.ts new file mode 100644 index 000000000..1c1279454 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/types.ts @@ -0,0 +1,12 @@ +export type TRailwayProject = { + id: string; + name: string; + environments: Array<{ + id: string; + name: string; + }>; + services: Array<{ + id: string; + name: string; + }>; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index b71370da7..c5e5fdda6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -132,6 +132,18 @@ export type TCloudflareConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Cloudflare; }; +export type TBitbucketConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Bitbucket; +}; + +export type TZabbixConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Zabbix; +}; + +export type TRailwayConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Railway; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -159,7 +171,10 @@ export type TAppConnectionOption = | TRenderConnectionOption | TFlyioConnectionOption | TGitlabConnectionOption - | TCloudflareConnectionOption; + | TCloudflareConnectionOption + | TBitbucketConnectionOption + | TZabbixConnectionOption + | TRailwayConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -191,4 +206,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Flyio]: TFlyioConnectionOption; [AppConnection.Gitlab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; + [AppConnection.Bitbucket]: TBitbucketConnectionOption; + [AppConnection.Zabbix]: TZabbixConnectionOption; + [AppConnection.Railway]: TRailwayConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/bitbucket-connection.ts b/frontend/src/hooks/api/appConnections/types/bitbucket-connection.ts new file mode 100644 index 000000000..42b4129e0 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/bitbucket-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum BitbucketConnectionMethod { + ApiToken = "api-token" +} + +export type TBitbucketConnection = TRootAppConnection & { app: AppConnection.Bitbucket } & { + method: BitbucketConnectionMethod.ApiToken; + credentials: { + email: string; + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2eaebb45a..524b988d7 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -7,6 +7,7 @@ import { TAzureAppConfigurationConnection } from "./azure-app-configuration-conn import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureDevOpsConnection } from "./azure-devops-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; +import { TBitbucketConnection } from "./bitbucket-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TCloudflareConnection } from "./cloudflare-connection"; import { TDatabricksConnection } from "./databricks-connection"; @@ -24,11 +25,13 @@ import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TRailwayConnection } from "./railway-connection"; import { TRenderConnection } from "./render-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; +import { TZabbixConnection } from "./zabbix-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; @@ -37,6 +40,7 @@ export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; export * from "./azure-devops-connection"; export * from "./azure-key-vault-connection"; +export * from "./bitbucket-connection"; export * from "./camunda-connection"; export * from "./cloudflare-connection"; export * from "./databricks-connection"; @@ -59,6 +63,7 @@ export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; +export * from "./zabbix-connection"; export type TAppConnection = | TAwsConnection @@ -89,7 +94,10 @@ export type TAppConnection = | TRenderConnection | TFlyioConnection | TGitLabConnection - | TCloudflareConnection; + | TCloudflareConnection + | TBitbucketConnection + | TZabbixConnection + | TRailwayConnection; export type TAvailableAppConnection = Pick; @@ -146,4 +154,7 @@ export type TAppConnectionMap = { [AppConnection.Flyio]: TFlyioConnection; [AppConnection.Gitlab]: TGitLabConnection; [AppConnection.Cloudflare]: TCloudflareConnection; + [AppConnection.Bitbucket]: TBitbucketConnection; + [AppConnection.Zabbix]: TZabbixConnection; + [AppConnection.Railway]: TRailwayConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/railway-connection.ts b/frontend/src/hooks/api/appConnections/types/railway-connection.ts new file mode 100644 index 000000000..db961c7c3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/railway-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum RailwayConnectionMethod { + AccountToken = "account-token", + ProjectToken = "project-token", + TeamToken = "team-token" +} + +export type TRailwayConnection = TRootAppConnection & { app: AppConnection.Railway } & { + method: RailwayConnectionMethod; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts b/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts new file mode 100644 index 000000000..b8eaa9636 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum ZabbixConnectionMethod { + ApiToken = "api-token" +} + +export type TZabbixConnection = TRootAppConnection & { app: AppConnection.Zabbix } & { + method: ZabbixConnectionMethod.ApiToken; + credentials: { + apiToken: string; + instanceUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/zabbix/index.ts b/frontend/src/hooks/api/appConnections/zabbix/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/zabbix/queries.tsx b/frontend/src/hooks/api/appConnections/zabbix/queries.tsx new file mode 100644 index 000000000..5c7d1cc19 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TZabbixHost } from "./types"; + +const zabbixConnectionKeys = { + all: [...appConnectionKeys.all, "zabbix"] as const, + listHosts: (connectionId: string) => [...zabbixConnectionKeys.all, "hosts", connectionId] as const +}; + +export const useZabbixConnectionListHosts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TZabbixHost[], + unknown, + TZabbixHost[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: zabbixConnectionKeys.listHosts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/zabbix/${connectionId}/hosts` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/zabbix/types.ts b/frontend/src/hooks/api/appConnections/zabbix/types.ts new file mode 100644 index 000000000..be33ffca2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/types.ts @@ -0,0 +1,25 @@ +export type TZabbixHost = { + host: string; + hostId: string; +}; + +export enum ZabbixSyncScope { + Host = "host", + Global = "global" +} + +export enum ZabbixMacroType { + Text = 0, + Secret = 1 +} + +export const ZABBIX_SYNC_SCOPES = { + [ZabbixSyncScope.Host]: { + name: "Host", + description: "Sync secrets to a specific host in Zabbix." + }, + [ZabbixSyncScope.Global]: { + name: "Global", + description: "Sync secrets to a global scope in Zabbix." + } +}; diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index 3b851f1cb..52a35cb85 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -30,8 +30,8 @@ export const useGetAuditLogs = ( params: { ...filters, offset: pageParam, - startDate: filters?.startDate?.toISOString(), - endDate: filters?.endDate?.toISOString(), + startDate: filters.startDate.toISOString(), + endDate: filters.endDate.toISOString(), ...(filters.eventMetadata && Object.keys(filters.eventMetadata).length ? { eventMetadata: Object.entries(filters.eventMetadata) diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 56dc11aeb..0b9e201fe 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -14,8 +14,8 @@ export type TGetAuditLogsFilter = { actor?: string; // user ID format secretPath?: string; secretKey?: string; - startDate?: Date; - endDate?: Date; + startDate: Date; + endDate: Date; limit: number; }; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 4dc635fa2..84b618153 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -54,7 +54,8 @@ export enum SqlProviders { export enum DynamicSecretAwsIamAuth { AssumeRole = "assume-role", - AccessKey = "access-key" + AccessKey = "access-key", + IRSA = "irsa" } export type TDynamicSecretProvider = @@ -111,6 +112,14 @@ export type TDynamicSecretProvider = policyDocument?: string; userGroups?: string; policyArns?: string; + } + | { + method: DynamicSecretAwsIamAuth.IRSA; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; }; } | { diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index eab946ba6..b70ee2ab9 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -4,7 +4,7 @@ export { useDeleteIntegrationAuth, useDeleteIntegrationAuths, useGetIntegrationAuthApps, - useGetIntegrationAuthBitBucketWorkspaces, + useGetIntegrationAuthBitbucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, useGetIntegrationAuthCircleCIOrganizations, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index f62887043..044ec8d2a 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -6,8 +6,8 @@ import { TReactQueryOptions } from "@app/types/reactQuery"; import { workspaceKeys } from "../workspace"; import { App, - BitBucketEnvironment, - BitBucketWorkspace, + BitbucketEnvironment, + BitbucketWorkspace, ChecklyGroup, CircleCIOrganization, Environment, @@ -98,9 +98,9 @@ const integrationAuthKeys = { integrationAuthId: string; appId: string; }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const, - getIntegrationAuthBitBucketWorkspaces: (integrationAuthId: string) => + getIntegrationAuthBitbucketWorkspaces: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthBitbucketWorkspaces"] as const, - getIntegrationAuthBitBucketEnvironments: ( + getIntegrationAuthBitbucketEnvironments: ( integrationAuthId: string, workspaceSlug: string, repoSlug: string @@ -446,23 +446,23 @@ const fetchIntegrationAuthRailwayServices = async ({ return services; }; -const fetchIntegrationAuthBitBucketWorkspaces = async (integrationAuthId: string) => { +const fetchIntegrationAuthBitbucketWorkspaces = async (integrationAuthId: string) => { const { data: { workspaces } - } = await apiRequest.get<{ workspaces: BitBucketWorkspace[] }>( + } = await apiRequest.get<{ workspaces: BitbucketWorkspace[] }>( `/api/v1/integration-auth/${integrationAuthId}/bitbucket/workspaces` ); return workspaces; }; -const fetchIntegrationAuthBitBucketEnvironments = async ( +const fetchIntegrationAuthBitbucketEnvironments = async ( integrationAuthId: string, workspaceSlug: string, repoSlug: string ) => { const { data: { environments } - } = await apiRequest.get<{ environments: BitBucketEnvironment[] }>( + } = await apiRequest.get<{ environments: BitbucketEnvironment[] }>( `/api/v1/integration-auth/${integrationAuthId}/bitbucket/environments`, { params: { @@ -833,10 +833,10 @@ export const useGetIntegrationAuthRailwayServices = ({ }); }; -export const useGetIntegrationAuthBitBucketWorkspaces = (integrationAuthId: string) => { +export const useGetIntegrationAuthBitbucketWorkspaces = (integrationAuthId: string) => { return useQuery({ - queryKey: integrationAuthKeys.getIntegrationAuthBitBucketWorkspaces(integrationAuthId), - queryFn: () => fetchIntegrationAuthBitBucketWorkspaces(integrationAuthId), + queryKey: integrationAuthKeys.getIntegrationAuthBitbucketWorkspaces(integrationAuthId), + queryFn: () => fetchIntegrationAuthBitbucketWorkspaces(integrationAuthId), enabled: true }); }; @@ -858,7 +858,7 @@ export const useGetIntegrationAuthOctopusDeployScopeValues = ( ...options }); -export const useGetIntegrationAuthBitBucketEnvironments = ( +export const useGetIntegrationAuthBitbucketEnvironments = ( { integrationAuthId, workspaceSlug, @@ -871,13 +871,13 @@ export const useGetIntegrationAuthBitBucketEnvironments = ( options?: TReactQueryOptions["options"] ) => { return useQuery({ - queryKey: integrationAuthKeys.getIntegrationAuthBitBucketEnvironments( + queryKey: integrationAuthKeys.getIntegrationAuthBitbucketEnvironments( integrationAuthId, workspaceSlug, repoSlug ), queryFn: () => - fetchIntegrationAuthBitBucketEnvironments(integrationAuthId, workspaceSlug, repoSlug), + fetchIntegrationAuthBitbucketEnvironments(integrationAuthId, workspaceSlug, repoSlug), ...options }); }; diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index b57e5aeca..a808e9e18 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -78,13 +78,13 @@ export type Service = { serviceId: string; }; -export type BitBucketWorkspace = { +export type BitbucketWorkspace = { uuid: string; name: string; slug: string; }; -export type BitBucketEnvironment = { +export type BitbucketEnvironment = { uuid: string; name: string; slug: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index 4cf4ede93..2150a17bc 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -21,7 +21,7 @@ export const useUpdateOIDCConfig = () => { clientId, clientSecret, isActive, - orgSlug, + organizationId, manageGroupMemberships, jwtSignatureAlgorithm }: { @@ -36,7 +36,7 @@ export const useUpdateOIDCConfig = () => { clientSecret?: string; isActive?: boolean; configurationType?: string; - orgSlug: string; + organizationId: string; manageGroupMemberships?: boolean; jwtSignatureAlgorithm?: OIDCJWTSignatureAlgorithm; }) => { @@ -50,7 +50,7 @@ export const useUpdateOIDCConfig = () => { tokenEndpoint, userinfoEndpoint, clientId, - orgSlug, + organizationId, clientSecret, isActive, manageGroupMemberships, @@ -60,7 +60,7 @@ export const useUpdateOIDCConfig = () => { return data; }, onSuccess(_, dto) { - queryClient.invalidateQueries({ queryKey: oidcConfigKeys.getOIDCConfig(dto.orgSlug) }); + queryClient.invalidateQueries({ queryKey: oidcConfigKeys.getOIDCConfig(dto.organizationId) }); queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations }); } }); @@ -81,7 +81,7 @@ export const useCreateOIDCConfig = () => { clientId, clientSecret, isActive, - orgSlug, + organizationId, manageGroupMemberships, jwtSignatureAlgorithm }: { @@ -95,7 +95,7 @@ export const useCreateOIDCConfig = () => { clientId: string; clientSecret: string; isActive: boolean; - orgSlug: string; + organizationId: string; allowedEmailDomains?: string; manageGroupMemberships?: boolean; jwtSignatureAlgorithm?: OIDCJWTSignatureAlgorithm; @@ -112,7 +112,7 @@ export const useCreateOIDCConfig = () => { clientId, clientSecret, isActive, - orgSlug, + organizationId, manageGroupMemberships, jwtSignatureAlgorithm }); @@ -120,7 +120,7 @@ export const useCreateOIDCConfig = () => { return data; }, onSuccess(_, dto) { - queryClient.invalidateQueries({ queryKey: oidcConfigKeys.getOIDCConfig(dto.orgSlug) }); + queryClient.invalidateQueries({ queryKey: oidcConfigKeys.getOIDCConfig(dto.organizationId) }); } }); }; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx index 38c939520..b9ee943f7 100644 --- a/frontend/src/hooks/api/oidcConfig/queries.tsx +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -5,18 +5,18 @@ import { apiRequest } from "@app/config/request"; import { OIDCConfigData } from "./types"; export const oidcConfigKeys = { - getOIDCConfig: (orgSlug: string) => [{ orgSlug }, "organization-oidc"] as const, + getOIDCConfig: (orgId: string) => [{ orgId }, "organization-oidc"] as const, getOIDCManageGroupMembershipsEnabled: (orgId: string) => ["oidc-manage-group-memberships", orgId] as const }; -export const useGetOIDCConfig = (orgSlug: string) => { +export const useGetOIDCConfig = (orgId: string) => { return useQuery({ - queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), + queryKey: oidcConfigKeys.getOIDCConfig(orgId), queryFn: async () => { try { const { data } = await apiRequest.get( - `/api/v1/sso/oidc/config?orgSlug=${orgSlug}` + `/api/v1/sso/oidc/config?organizationId=${orgId}` ); return data; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 15afcf119..eeb734115 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -49,7 +49,7 @@ export type TCreateSecretPolicyDTO = { workspaceId: string; name?: string; environment: string; - secretPath?: string | null; + secretPath: string; approvers?: Approver[]; bypassers?: Bypasser[]; approvals?: number; @@ -62,7 +62,7 @@ export type TUpdateSecretPolicyDTO = { name?: string; approvers?: Approver[]; bypassers?: Bypasser[]; - secretPath?: string | null; + secretPath?: string; approvals?: number; allowedSelfApprovals?: boolean; enforcementLevel?: EnforcementLevel; diff --git a/frontend/src/hooks/api/secretScanningV2/enums.ts b/frontend/src/hooks/api/secretScanningV2/enums.ts index 082f3d760..40e5ea7dd 100644 --- a/frontend/src/hooks/api/secretScanningV2/enums.ts +++ b/frontend/src/hooks/api/secretScanningV2/enums.ts @@ -1,5 +1,6 @@ export enum SecretScanningDataSource { - GitHub = "github" + GitHub = "github", + Bitbucket = "bitbucket" } export enum SecretScanningScanStatus { diff --git a/frontend/src/hooks/api/secretScanningV2/types/bitbucket-data-source.ts b/frontend/src/hooks/api/secretScanningV2/types/bitbucket-data-source.ts new file mode 100644 index 000000000..d9e7eb929 --- /dev/null +++ b/frontend/src/hooks/api/secretScanningV2/types/bitbucket-data-source.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { SecretScanningDataSource } from "../enums"; +import { TSecretScanningDataSourceBase } from "./shared"; + +export type TBitbucketDataSource = TSecretScanningDataSourceBase & { + type: SecretScanningDataSource.Bitbucket; + config: { + workspaceSlug: string; + includeRepos: string[]; + }; +}; + +export type TBitbucketDataSourceOption = { + name: string; + type: SecretScanningDataSource.Bitbucket; + connection: AppConnection.Bitbucket; +}; diff --git a/frontend/src/hooks/api/secretScanningV2/types/index.ts b/frontend/src/hooks/api/secretScanningV2/types/index.ts index 07beaef5e..078a7051d 100644 --- a/frontend/src/hooks/api/secretScanningV2/types/index.ts +++ b/frontend/src/hooks/api/secretScanningV2/types/index.ts @@ -8,9 +8,10 @@ import { SecretScanningScanStatus, SecretScanningScanType } from "../enums"; +import { TBitbucketDataSource, TBitbucketDataSourceOption } from "./bitbucket-data-source"; import { TGitHubDataSource, TGitHubDataSourceOption } from "./github-data-source"; -export type TSecretScanningDataSource = TGitHubDataSource; +export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt: string | null; @@ -23,7 +24,7 @@ export type TListSecretScanningDataSources = { dataSources: TSecretScanningDataSourceWithDetails[]; }; -export type TSecretScanningDataSourceOption = TGitHubDataSourceOption; +export type TSecretScanningDataSourceOption = TGitHubDataSourceOption | TBitbucketDataSourceOption; export type TListSecretScanningDataSourceOptions = { dataSourceOptions: TSecretScanningDataSourceOption[]; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 66834ced5..a6ba1fd40 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -20,7 +20,9 @@ export enum SecretSync { Render = "render", Flyio = "flyio", GitLab = "gitlab", - CloudflarePages = "cloudflare-pages" + CloudflarePages = "cloudflare-pages", + Zabbix = "zabbix", + Railway = "railway" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 3e254e9aa..7071576bf 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -19,10 +19,12 @@ import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; +import { TRailwaySync } from "./railway-sync"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; import { TWindmillSync } from "./windmill-sync"; +import { TZabbixSync } from "./zabbix-sync"; export type TSecretSyncOption = { name: string; @@ -53,7 +55,9 @@ export type TSecretSync = | TRenderSync | TFlyioSync | TGitLabSync - | TCloudflarePagesSync; + | TCloudflarePagesSync + | TZabbixSync + | TRailwaySync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts b/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts new file mode 100644 index 000000000..7a99bca7a --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts @@ -0,0 +1,22 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TRailwaySync = TRootSecretSync & { + destination: SecretSync.Railway; + destinationConfig: { + projectId: string; + projectName: string; + + environmentName: string; + environmentId: string; + + serviceId?: string; + serviceName?: string; + }; + connection: { + app: AppConnection.Railway; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts b/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts new file mode 100644 index 000000000..cb8a4bac6 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts @@ -0,0 +1,25 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +import { ZabbixSyncScope } from "../../appConnections/zabbix"; + +export type TZabbixSync = TRootSecretSync & { + destination: SecretSync.Zabbix; + destinationConfig: + | { + scope: ZabbixSyncScope.Host; + hostId: string; + hostName: string; + macroType: number; + } + | { + scope: ZabbixSyncScope.Global; + macroType: number; + }; + connection: { + app: AppConnection.Zabbix; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index caa4a76f8..b224c1b6d 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -5,6 +5,7 @@ export { usePagination } from "./usePagination"; export { usePersistentState } from "./usePersistentState"; export { usePopUp } from "./usePopUp"; export { useResetPageHelper } from "./useResetPageHelper"; +export * from "./useResizableHeaderHeight"; export { useSyntaxHighlight } from "./useSyntaxHighlight"; export { useTimedReset } from "./useTimedReset"; export { useToggle } from "./useToggle"; diff --git a/frontend/src/hooks/useResizableHeaderHeight.tsx b/frontend/src/hooks/useResizableHeaderHeight.tsx new file mode 100644 index 000000000..9624e9052 --- /dev/null +++ b/frontend/src/hooks/useResizableHeaderHeight.tsx @@ -0,0 +1,71 @@ +import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; + +type Params = { + minHeight: number; + maxHeight: number; + initialHeight: number; +}; + +export const useResizableHeaderHeight = ({ minHeight, maxHeight, initialHeight }: Params) => { + const [headerHeight, setHeaderHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + const startY = useRef(0); + const startHeight = useRef(0); + + const handleMouseDown = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + startY.current = e.clientY; + startHeight.current = headerHeight; + }, + [headerHeight] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const deltaY = e.clientY - startY.current; + const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight.current + deltaY)); + + setHeaderHeight(newHeight); + }, + [isResizing] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (isResizing) { + document.addEventListener( + "mousemove", + // @ts-expect-error native discrepancy + handleMouseMove + ); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + } + + return () => { + document.removeEventListener( + "mousemove", + // @ts-expect-error native discrepancy + handleMouseMove + ); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + return { + headerHeight, + handleMouseDown, + isResizing + }; +}; diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx index d5a8d2a4b..e1cd223f8 100644 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ b/frontend/src/layouts/AdminLayout/Sidebar.tsx @@ -2,7 +2,7 @@ import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; const generalTabs = [ { @@ -29,6 +29,11 @@ const generalTabs = [ label: "Caching", icon: "note", link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: "unlock", + link: "/admin/environment" } ]; @@ -45,7 +50,7 @@ const resourceTabs = [ }, { label: "Machine Identities", - icon: "key-user", + icon: "wrench", link: "/admin/resources/machine-identities" } ]; @@ -56,30 +61,6 @@ export const AdminSidebar = () => { return ( ); diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 4e9184789..4feda0610 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -54,33 +54,55 @@ const getPlan = (subscription: SubscriptionPlan) => { return "Free"; }; +const getFormattedSupportEmailLink = (variables: { org_id: string; domain: string }) => { + const email = "support@infisical.com"; + + const body = `Hello Infisical Support Team, + +Issue Details: +[What you did] +[What you expected to happen] +[What actually happened] +[Any error request IDs] +[Any supporting screenshots or video recording of the issue/request at hand] + +Account Info: +- Organization ID: ${variables.org_id} +- Domain: ${variables.domain} + +Thank you, +[Your Name]`; + + return `mailto:${email}?body=${encodeURIComponent(body)}`; +}; + export const INFISICAL_SUPPORT_OPTIONS = [ [ , "Support Forum", - "https://infisical.com/slack" + () => "https://infisical.com/slack" ], [ , "Read Docs", - "https://infisical.com/docs/documentation/getting-started/introduction" + () => "https://infisical.com/docs/documentation/getting-started/introduction" ], [ , "GitHub Issues", - "https://github.com/Infisical/infisical/issues" + () => "https://github.com/Infisical/infisical/issues" ], [ , "Email Support", - "mailto:support@infisical.com" + getFormattedSupportEmailLink ], [ , "Instance Admins", - "server-admins" + () => "server-admins" ] -]; +] as const; export const Navbar = () => { const { user } = useUser(); @@ -258,7 +280,15 @@ export const Navbar = () => {
- {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => { + {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, getUrl]) => { + const url = + text === "Email Support" + ? getUrl({ + org_id: currentOrg.id, + domain: window.location.origin + }) + : getUrl(); + if (url === "server-admins" && isInfisicalCloud()) { return null; } diff --git a/frontend/src/layouts/PersonalSettingsLayout/PersonalSettingsLayout.tsx b/frontend/src/layouts/PersonalSettingsLayout/PersonalSettingsLayout.tsx index c2835996a..182e1ebc1 100644 --- a/frontend/src/layouts/PersonalSettingsLayout/PersonalSettingsLayout.tsx +++ b/frontend/src/layouts/PersonalSettingsLayout/PersonalSettingsLayout.tsx @@ -1,19 +1,11 @@ import { useTranslation } from "react-i18next"; -import { faArrowLeft, faInfo, faMobile, faQuestion } from "@fortawesome/free-solid-svg-icons"; +import { faArrowLeft, faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet } from "@tanstack/react-router"; import { WishForm } from "@app/components/features/WishForm"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@app/components/v2"; -import { envConfig } from "@app/config/env"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { INFISICAL_SUPPORT_OPTIONS } from "../OrganizationLayout/components/NavBar/Navbar"; export const PersonalSettingsLayout = () => { const { t } = useTranslation(); @@ -36,37 +28,6 @@ export const PersonalSettingsLayout = () => {
{(window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("https://gamma.infisical.com")) && } - - -
- - Help & Support -
-
- - {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => ( - - -
- {icon} -
{text}
-
-
-
- ))} - {envConfig.PLATFORM_VERSION && ( -
- - Version: {envConfig.PLATFORM_VERSION} -
- )} -
-
) diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index 1efcf3939..d4c7a3f56 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -113,7 +113,7 @@ export const ProjectSelect = () => {
- +
{currentWorkspace?.name}
@@ -176,7 +176,7 @@ export const ProjectSelect = () => { >
- +
{workspace.name}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c2c54be08..e99210352 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -63,7 +63,7 @@ const router = createRouter({ context: { serverConfig: null, queryClient }, defaultPendingComponent: () => (
- +
), defaultNotFoundComponent: NotFoundPage, diff --git a/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx new file mode 100644 index 000000000..000dd1f0d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx @@ -0,0 +1,27 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader } from "@app/components/v2"; + +import { EnvironmentPageForm } from "./components"; + +export const EnvironmentPage = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Admin" })} + +
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx new file mode 100644 index 000000000..0d2adba9d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Control, Controller, useForm, useWatch } from "react-hook-form"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faChevronRight, + faExclamationTriangle, + faMagnifyingGlass +} 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, FormControl, Input, SecretInput, Tooltip } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; +import { useGetEnvOverrides, useUpdateServerConfig } from "@app/hooks/api"; + +type TForm = Record; + +export const GroupContainer = ({ + group, + control, + search +}: { + group: { + fields: { + key: string; + value: string; + hasEnvEntry: boolean; + description?: string; + }[]; + name: string; + }; + control: Control; + search: string; +}) => { + const [open, setOpen] = useState(false); + + return ( +
+
setOpen((o) => !o)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setOpen((o) => !o); + } + }} + > + + +
{group.name}
+
+ + {(open || search) && ( +
+ {group.fields.map((field) => ( +
+
+ + + + + + +
+ +
+ {field.hasEnvEntry && ( + + + + )} + + ( + + + + )} + /> +
+
+ ))} +
+ )} +
+ ); +}; + +export const EnvironmentPageForm = () => { + const { data: envOverrides } = useGetEnvOverrides(); + const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); + const [search, setSearch] = useState(""); + + const allFields = useMemo(() => { + if (!envOverrides) return []; + return Object.values(envOverrides).flatMap((group) => group.fields); + }, [envOverrides]); + + const formSchema = useMemo(() => { + return z.object(Object.fromEntries(allFields.map((field) => [field.key, z.string()]))); + }, [allFields]); + + const defaultValues = useMemo(() => { + const values: Record = {}; + allFields.forEach((field) => { + values[field.key] = field.value ?? ""; + }); + return values; + }, [allFields]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues + }); + + const formValues = useWatch({ control }); + + const filteredData = useMemo(() => { + if (!envOverrides) return []; + + const searchTerm = search.toLowerCase().trim(); + if (!searchTerm) { + return Object.values(envOverrides); + } + + return Object.values(envOverrides) + .map((group) => { + const filteredFields = group.fields.filter( + (field) => + field.key.toLowerCase().includes(searchTerm) || + (field.description ?? "").toLowerCase().includes(searchTerm) + ); + + if (filteredFields.length > 0) { + return { ...group, fields: filteredFields }; + } + return null; + }) + .filter(Boolean); + }, [search, formValues, envOverrides]); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const onSubmit = useCallback( + async (formData: TForm) => { + try { + const filteredFormData = Object.fromEntries( + Object.entries(formData).filter(([, value]) => value !== "") + ); + await updateServerConfig({ + envOverrides: filteredFormData + }); + + createNotification({ + type: "success", + text: "Environment overrides updated successfully. It can take up to 5 minutes to take effect." + }); + + reset(formData); + } catch (error) { + const errorMessage = + (error as any)?.response?.data?.message || + (error as any)?.message || + "An unknown error occurred"; + createNotification({ + type: "error", + title: "Failed to update environment overrides", + text: errorMessage + }); + } + }, + [reset, updateServerConfig] + ); + + return ( +
+
+
+
+

Overrides

+ +
+ + Docs + +
+
+
+

+ Override specific environment variables. After saving, it may take up to 5 minutes for + variables to propagate throughout every container. +

+
+ +
+ +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search for keys, descriptions, and values..." + className="flex-1" + /> +
+ {filteredData.map((group) => ( + + ))} +
+ + ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/index.ts b/frontend/src/pages/admin/EnvironmentPage/components/index.ts new file mode 100644 index 000000000..44b82c206 --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/index.ts @@ -0,0 +1 @@ +export { EnvironmentPageForm } from "./EnvironmentPageForm"; diff --git a/frontend/src/pages/admin/EnvironmentPage/route.tsx b/frontend/src/pages/admin/EnvironmentPage/route.tsx new file mode 100644 index 000000000..8f9ac4c7e --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/route.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { EnvironmentPage } from "./EnvironmentPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/admin/_admin-layout/environment" +)({ + component: EnvironmentPage, + beforeLoad: () => { + return { + breadcrumbs: [ + { + label: "Admin", + link: linkOptions({ to: "/admin" }) + }, + { + label: "Environment", + link: linkOptions({ + to: "/admin/environment" + }) + } + ] + }; + } +}); diff --git a/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx b/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx index 92c485ed3..b163134eb 100644 --- a/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx +++ b/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx @@ -1,19 +1,33 @@ import { Helmet } from "react-helmet"; +import { Link } from "@tanstack/react-router"; export const EmailNotVerifiedPage = () => { return ( -
+
Request a New Invite -
-

Oops.

-

Your email was not verified.

-

Please try again.

-

- Note: If it still doesn't work, please reach out to us at support@infisical.com + +

+ Infisical Logo +
+ +
+

+ Your email was not verified +

+

+ Please try again.
Note: If it still doesn't work, please reach out to us at + support@infisical.com

+
+ + + Back to Login + + +
); diff --git a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx index 8b6be6b9a..17c96e8a2 100644 --- a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { z } from "zod"; import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; @@ -36,7 +36,12 @@ export const PasswordResetPage = () => { const navigate = useNavigate(); return ( -
+
+ +
+ Infisical Logo +
+ {step === Steps.ConfirmEmail && ( { diff --git a/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx index 1f1a79490..bf7cfcdec 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx @@ -19,17 +19,21 @@ export const ConfirmEmailStep = ({ onComplete }: Props) => { isPending: isVerifyPasswordResetLoading } = useVerifyPasswordResetCode(); return ( -
-

+

+

Confirm your email +

+

+ Reset password for {email}.

- verify email -
+
diff --git a/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx index de7bcd3f3..4f021ec34 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx @@ -1,7 +1,7 @@ import crypto from "crypto"; import { Controller, useForm } from "react-hook-form"; -import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useSearch } from "@tanstack/react-router"; @@ -169,17 +169,15 @@ export const EnterPasswordStep = ({ return (
-

+

Enter new password +

+

+ Make sure you save it somewhere safe.

-
-

- Make sure you save it somewhere safe. -

-
-
+
+
+ +
{passwordErrorTooShort || passwordErrorTooLong || passwordErrorNoLetterChar || @@ -210,33 +222,33 @@ export const EnterPasswordStep = ({ passwordErrorEscapeChar || passwordErrorLowEntropy || passwordErrorBreached ? ( -
-
Password should contain:
-
+
+
Password should contain:
+
{passwordErrorTooShort ? ( - + ) : ( - + )}
at least 14 characters
-
+
{passwordErrorTooLong ? ( - + ) : ( - + )}
at most 100 characters
-
+
{passwordErrorNoLetterChar ? ( - + ) : ( - + )}
-
+
{passwordErrorNoNumOrSpecialChar ? ( - + ) : ( - + )}
-
+
{passwordErrorRepeatedChar ? ( - + ) : ( - + )}
-
+
{passwordErrorEscapeChar ? ( - + ) : ( - + )}
-
+
{passwordErrorLowEntropy ? ( - + ) : ( - + )}
-
+
{passwordErrorBreached ? ( - + ) : ( - + )}
Password was found in a data breach.
- ) : ( -
- )} -
-
- -
-
+ ) : null} ); }; diff --git a/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx index 54a5d5e9d..8f84af3bd 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx @@ -43,18 +43,15 @@ export const InputBackupKeyStep = ({ verificationToken, onComplete }: Props) => return (
-

+

Enter your backup key +

+

+ You can find it in your emergency kit you downloaded during signup.

-
-

- You can find it in your emergency kit. You had to download the emergency kit during - signup. -

-
-
+
)} />
-
-
- -
+
+
); diff --git a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx index 9777f564a..0f9da2991 100644 --- a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx +++ b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -2,8 +2,7 @@ import { FormEvent, useState } from "react"; import { Helmet } from "react-helmet"; import { Link } from "@tanstack/react-router"; -import InputField from "@app/components/basic/InputField"; -import { Button, EmailServiceSetupModal } from "@app/components/v2"; +import { Button, EmailServiceSetupModal, Input } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useSendPasswordResetEmail } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -44,9 +43,9 @@ export const VerifyEmailPage = () => { }; return ( -
+
- Login + Reset Password @@ -56,66 +55,80 @@ export const VerifyEmailPage = () => { /> -
+
long logo
{step === 1 && (
-

+

Forgot your password? +

+

+ Enter your email to start the password reset process.
You will receive an email + with instructions.

-
-

- Enter your email to start the password reset process. You will receive an email with - instructions. -

-
-
- + setEmail(e.target.value)} + type="email" + placeholder="Enter your email..." isRequired autoComplete="username" + className="h-10" />
-
-
- -
+
+ +
+
+ + + Back to Login + +
)} {step === 2 && ( -
-

- Look for an email in your inbox. +

+

+ Look for an email in your inbox +

+

+ If the email is in our system, you will receive an email at{" "} + {email} with instructions on how to reset your password.

-
-

- If the email is in our system, you will receive an email at{" "} - {email} with instructions on how to reset your - password. -

+
+ + + Back to Login + +
)} - handlePopUpToggle("setUpEmail", isOpen)} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 52abfee5d..a84ad8aa0 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -16,6 +16,7 @@ import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConn import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnectionForm"; import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm"; import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; +import { BitbucketConnectionForm } from "./BitbucketConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; import { CloudflareConnectionForm } from "./CloudflareConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; @@ -33,11 +34,13 @@ import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { RailwayConnectionForm } from "./RailwayConnectionForm"; import { RenderConnectionForm } from "./RenderConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; +import { ZabbixConnectionForm } from "./ZabbixConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -134,6 +137,12 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Cloudflare: return ; + case AppConnection.Bitbucket: + return ; + case AppConnection.Zabbix: + return ; + case AppConnection.Railway: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -228,6 +237,12 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Cloudflare: return ; + case AppConnection.Bitbucket: + return ; + case AppConnection.Zabbix: + return ; + case AppConnection.Railway: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/BitbucketConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/BitbucketConnectionForm.tsx new file mode 100644 index 000000000..7f283d3be --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/BitbucketConnectionForm.tsx @@ -0,0 +1,144 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { BitbucketConnectionMethod, TBitbucketConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TBitbucketConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Bitbucket) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(BitbucketConnectionMethod.ApiToken), + credentials: z.object({ + email: z.string().email().trim().min(1, "Email required"), + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const BitbucketConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Bitbucket, + method: BitbucketConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx new file mode 100644 index 000000000..e6403627f --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + RailwayConnectionMethod, + TRailwayConnection +} from "@app/hooks/api/appConnections/types/railway-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TRailwayConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Railway) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.nativeEnum(RailwayConnectionMethod), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const RailwayConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Railway, + method: RailwayConnectionMethod.AccountToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx new file mode 100644 index 000000000..72bbd03f7 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx @@ -0,0 +1,137 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TZabbixConnection, ZabbixConnectionMethod } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TZabbixConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Zabbix) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required"), + instanceUrl: z.string().trim().url("Invalid instance URL") + }) + }) +]); + +type FormData = z.infer; + +export const ZabbixConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Zabbix, + method: ZabbixConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx index 38df23aad..cc8748953 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogFilterItem.tsx @@ -1,3 +1,5 @@ +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { Button, Tooltip } from "@app/components/v2"; @@ -8,25 +10,41 @@ type Props = { label: string; onClear: () => void; children: React.ReactNode; + tooltipText?: string; }; -export const LogFilterItem = ({ label, onClear, hoverTooltip, children, className }: Props) => { +export const LogFilterItem = ({ + label, + onClear, + hoverTooltip, + children, + className, + tooltipText +}: Props) => { return ( - -
-
-

{label}

- -
- {children} +
+
+

{label}

+ {tooltipText && ( + + + + )} +
- + +
{children}
+
+
); }; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx new file mode 100644 index 000000000..87957ad22 --- /dev/null +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faArrowRight, faCalendar, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; + +import { + Button, + DatePicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; + +import { + auditLogDateFilterFormSchema, + AuditLogDateFilterType, + TAuditLogDateFilterFormData +} from "./types"; + +type Props = { + setFilter: (data: TAuditLogDateFilterFormData) => void; + filter: TAuditLogDateFilterFormData; +}; +const RELATIVE_VALUES = ["5m", "30m", "1h", "3h", "12h"]; +export const LogsDateFilter = ({ setFilter, filter }: Props) => { + const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); + const [isPopupOpen, setIsPopOpen] = useState(false); + + const { control, watch, handleSubmit, formState } = useForm({ + resolver: zodResolver(auditLogDateFilterFormSchema), + values: filter + }); + const selectType = watch("type"); + const isCustomRelative = + filter.type === AuditLogDateFilterType.Relative && + !RELATIVE_VALUES.includes(filter.relativeModeValue || ""); + + const onSubmit = (data: TAuditLogDateFilterFormData) => { + const endDate = data.type === AuditLogDateFilterType.Relative ? new Date() : data.endDate; + const startDate = + data.type === AuditLogDateFilterType.Relative && data.relativeModeValue + ? new Date(Number(new Date()) - ms(data.relativeModeValue)) + : data.startDate; + setFilter({ + ...data, + startDate, + endDate + }); + setIsPopOpen(false); + }; + + return ( + setIsPopOpen(el)}> +
+ {filter.type === AuditLogDateFilterType.Relative ? ( + <> + {RELATIVE_VALUES.map((el) => ( + + ))} + + ) : ( + <> +
+ {format(filter.startDate, "yyyy-MM-dd HH:mm")} +
+
+ +
+
+ {format(filter.endDate, "yyyy-MM-dd HH:mm")} +
+ + )} + + + +
+ +
+ ( + + + + )} + /> + {selectType === AuditLogDateFilterType.Relative && ( + ( + + + + )} + /> + )} + {selectType === AuditLogDateFilterType.Absolute && ( +
+ { + return ( + + + + ); + }} + /> +
+
+ +
+ { + return ( + + + + ); + }} + /> +
+ )} +
+ +
+ + + + ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 367cfefc0..73cc76ed7 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -1,28 +1,15 @@ /* eslint-disable no-nested-ternary */ -import { useMemo, useState } from "react"; -import { - Control, - Controller, - UseFormGetFieldState, - UseFormReset, - UseFormResetField, - UseFormSetValue, - UseFormWatch -} from "react-hook-form"; -import { - faArrowRight, - faCaretDown, - faCheckCircle, - faFilterCircleXmark -} from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { Badge, Button, - DatePicker, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -44,7 +31,7 @@ import { EventType } from "@app/hooks/api/auditLogs/enums"; import { UserAgentType } from "@app/hooks/api/auth/types"; import { LogFilterItem } from "./LogFilterItem"; -import { AuditLogFilterFormData, Presets } from "./types"; +import { auditLogFilterFormSchema, Presets, TAuditLogFilterFormData } from "./types"; const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); const userAgentTypes = Object.entries(userAgentTypeToNameMap).map(([value, label]) => ({ @@ -54,78 +41,54 @@ const userAgentTypes = Object.entries(userAgentTypeToNameMap).map(([value, label type Props = { presets?: Presets; - control: Control; - reset: UseFormReset; - resetField: UseFormResetField; - watch: UseFormWatch; - getFieldState: UseFormGetFieldState; - setValue: UseFormSetValue; + setFilter: (data: TAuditLogFilterFormData) => void; + filter: TAuditLogFilterFormData; }; -const getActiveFilterCount = ( - getFieldState: UseFormGetFieldState, - watch: UseFormWatch -) => { +const getActiveFilterCount = (filter: TAuditLogFilterFormData) => { const fields = [ "actor", "project", "eventType", - "startDate", - "endDate", "environment", "secretPath", "userAgentType", "secretKey" - ] as Partial[]; + ] as Partial[]; let filterCount = 0; // either start or end date should only be counted as one filter - let dateProcessed = false; - fields.forEach((field) => { - const fieldState = getFieldState(field); - - if ( - field === "userAgentType" || - field === "environment" || - field === "secretKey" || - field === "secretPath" - ) { - const value = watch(field); - - if (value !== undefined && value !== "") { - filterCount += 1; - } - } else if (fieldState.isDirty && !dateProcessed) { + const value = filter?.[field]; + if (Array.isArray(value) ? value.length : value) { filterCount += 1; - - if (field === "startDate" || field === "endDate") { - dateProcessed = true; - } } }); return filterCount; }; -export const LogsFilter = ({ - presets, - control, - reset, - resetField, - watch, - getFieldState, - setValue -}: Props) => { - const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); - +export const LogsFilter = ({ presets, setFilter, filter }: Props) => { const { data: workspaces = [] } = useGetUserWorkspaces(); const { currentOrg } = useOrganization(); const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id); + const { control, watch, resetField, setValue, handleSubmit, formState } = + useForm({ + resolver: zodResolver(auditLogFilterFormSchema), + defaultValues: { + project: null, + environment: undefined, + secretKey: "", + secretPath: "", + actor: presets?.actorId, + eventType: filter?.eventType || [], + userAgentType: undefined + }, + values: filter + }); const selectedEventTypes = watch("eventType") as EventType[] | undefined; const selectedProject = watch("project"); @@ -140,408 +103,341 @@ export const LogsFilter = ({ return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? []; }, [selectedProject, workspacesInOrg]); - const activeFilterCount = getActiveFilterCount(getFieldState, watch); + const activeFilterCount = getActiveFilterCount(filter); return ( - -
-
-
-
- Filters - - {activeFilterCount} - +
+
+
+
+
+ Filters + + {activeFilterCount} + +
+
-
+ +
+ { + resetField("eventType"); }} - variant="link" - className="text-mineshaft-400" - size="xs" > - Clear filters + ( + + + +
+ {selectedEventTypes?.length === 1 + ? eventTypes.find( + (eventType) => eventType.value === selectedEventTypes[0] + )?.label + : selectedEventTypes?.length === 0 + ? "All events" + : `${selectedEventTypes?.length} events selected`} + +
+
+ +
+ {eventTypes && eventTypes.length > 0 ? ( + eventTypes.map((eventType) => { + const isSelected = selectedEventTypes?.includes( + eventType.value as EventType + ); + + return ( + + eventTypes.length > 1 && event.preventDefault() + } + onClick={() => { + if ( + selectedEventTypes?.includes(eventType.value as EventType) + ) { + field.onChange( + selectedEventTypes?.filter( + (e: string) => e !== eventType.value + ) + ); + } else { + field.onChange([ + ...(selectedEventTypes || []), + eventType.value + ]); + } + }} + key={`event-type-${eventType.value}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {eventType.label} + + ); + }) + ) : ( +
+ )} +
+ + + + )} + /> + + { + resetField("userAgentType"); + }} + > + ( + + + + )} + /> + + { + resetField("project"); + resetField("environment"); + setValue("secretPath", ""); + setValue("secretKey", ""); + }} + > + ( + + { + if (e === null) { + setValue("secretPath", ""); + setValue("secretKey", ""); + } + resetField("environment"); + onChange(e); + }} + placeholder="All projects" + options={workspacesInOrg.map(({ name, id, defaultProduct }) => ({ + name, + id, + type: defaultProduct + }))} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + + {showSecretsSection && ( + +
+

Secrets

+
+
+ { + resetField("environment"); + }} + > + ( + + onChange(e)} + placeholder="All environments" + options={availableEnvironments.map(({ name, slug }) => ({ + name, + slug + }))} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + { + setValue("secretPath", ""); + }} + > + ( + + onChange(e.target.value)} + /> + + )} + /> + + + { + setValue("secretKey", ""); + }} + > + ( + + + setValue("secretKey", e.target.value, { shouldDirty: true }) + } + /> + + )} + /> + + + )} + +
+
+
- -
- { - resetField("eventType"); - }} - > - ( - - - -
- {selectedEventTypes?.length === 1 - ? eventTypes.find( - (eventType) => eventType.value === selectedEventTypes[0] - )?.label - : selectedEventTypes?.length === 0 - ? "All events" - : `${selectedEventTypes?.length} events selected`} - -
-
- -
- {eventTypes && eventTypes.length > 0 ? ( - eventTypes.map((eventType) => { - const isSelected = selectedEventTypes?.includes( - eventType.value as EventType - ); - - return ( - - eventTypes.length > 1 && event.preventDefault() - } - onClick={() => { - if ( - selectedEventTypes?.includes(eventType.value as EventType) - ) { - field.onChange( - selectedEventTypes?.filter( - (e: string) => e !== eventType.value - ) - ); - } else { - field.onChange([ - ...(selectedEventTypes || []), - eventType.value - ]); - } - }} - key={`event-type-${eventType.value}`} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {eventType.label} - - ); - }) - ) : ( -
- )} -
- - - - )} - /> - - { - resetField("userAgentType"); - }} - > - ( - - - - )} - /> - - - { - resetField("startDate"); - resetField("endDate"); - }} - > -
- { - return ( - - - - ); - }} - /> - -
-
- -
- - { - return ( - - - - ); - }} - /> -
- - - {showSecretsSection && ( - -
-

Secrets

-
-
- - { - resetField("project"); - resetField("environment"); - setValue("secretPath", ""); - setValue("secretKey", ""); - }} - > - ( - - { - if (e === null) { - setValue("secretPath", ""); - setValue("secretKey", ""); - } - resetField("environment"); - onChange(e); - }} - placeholder="All projects" - options={workspacesInOrg.map(({ name, id, defaultProduct }) => ({ - name, - id, - type: defaultProduct - }))} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - - )} - /> - - - { - resetField("environment"); - }} - > - ( - - onChange(e)} - placeholder="All environments" - options={availableEnvironments.map(({ name, slug }) => ({ - name, - slug - }))} - getOptionValue={(option) => option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> - - { - setValue("secretPath", ""); - }} - > - ( - - onChange(e.target.value)} - /> - - )} - /> - - - { - setValue("secretKey", ""); - }} - > - ( - - - setValue("secretKey", e.target.value, { shouldDirty: true }) - } - /> - - )} - /> - - - )} - -
-
+ ); diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx index 1878a49e3..7f676121f 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx @@ -1,17 +1,20 @@ -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import ms from "ms"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; -import { useDebounce } from "@app/hooks"; -import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { usePopUp } from "@app/hooks/usePopUp"; +import { LogsDateFilter } from "./LogsDateFilter"; import { LogsFilter } from "./LogsFilter"; import { LogsTable } from "./LogsTable"; -import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types"; +import { + AuditLogDateFilterType, + Presets, + TAuditLogDateFilterFormData, + TAuditLogFilterFormData +} from "./types"; type Props = { presets?: Presets; @@ -24,74 +27,45 @@ export const LogsSection = withPermission( const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); - - const { control, reset, watch, getFieldState, resetField, setValue } = - useForm({ - resolver: zodResolver(auditLogFilterFormSchema), - defaultValues: { - project: null, - environment: undefined, - secretKey: "", - secretPath: "", - actor: presets?.actorId, - eventType: presets?.eventType || [], - userAgentType: undefined, - startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), - endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) - } - }); + const [logFilter, setLogFilter] = useState({ + eventType: presets?.eventType || [], + actor: presets?.actorId + }); + const [dateFilter, setDateFilter] = useState({ + startDate: new Date(Number(new Date()) - ms("1h")), + endDate: new Date(), + type: AuditLogDateFilterType.Relative, + relativeModeValue: "1h" + }); useEffect(() => { if (subscription && !subscription.auditLogs) { handlePopUpOpen("upgradePlan"); } }, [subscription]); - - const eventType = watch("eventType") as EventType[] | undefined; - const userAgentType = watch("userAgentType") as UserAgentType | undefined; - const actor = watch("actor"); - const projectId = watch("project")?.id; - const environment = watch("environment")?.slug; - const secretPath = watch("secretPath"); - const secretKey = watch("secretKey"); - - const startDate = watch("startDate"); - const endDate = watch("endDate"); - - const [debouncedSecretPath] = useDebounce(secretPath!, 500); - const [debouncedSecretKey] = useDebounce(secretKey!, 500); - return (
+ {showFilters && } {showFilters && ( - + )}
- { // Determine the project ID for filtering diff --git a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx index 0e0d8cc8a..8e0ec9ba9 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx @@ -3,23 +3,33 @@ import { z } from "zod"; import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { ProjectType } from "@app/hooks/api/workspace/types"; -export const auditLogFilterFormSchema = z +export enum AuditLogDateFilterType { + Relative = "relative", + Absolute = "absolute" +} + +export const auditLogFilterFormSchema = z.object({ + eventMetadata: z.object({}).optional(), + project: z + .object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) }) + .optional() + .nullable(), + environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(), + eventType: z.nativeEnum(EventType).array(), + actor: z.string().optional(), + userAgentType: z.nativeEnum(UserAgentType).optional(), + secretPath: z.string().optional(), + secretKey: z.string().optional(), + page: z.coerce.number().optional(), + perPage: z.coerce.number().optional() +}); + +export const auditLogDateFilterFormSchema = z .object({ - eventMetadata: z.object({}).optional(), - project: z - .object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) }) - .optional() - .nullable(), - environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(), - eventType: z.nativeEnum(EventType).array(), - actor: z.string().optional(), - userAgentType: z.nativeEnum(UserAgentType), - secretPath: z.string().optional(), - secretKey: z.string().optional(), - startDate: z.date().optional(), - endDate: z.date().optional(), - page: z.coerce.number().optional(), - perPage: z.coerce.number().optional() + type: z.nativeEnum(AuditLogDateFilterType), + relativeModeValue: z.string().optional(), + startDate: z.date(), + endDate: z.date() }) .superRefine((el, ctx) => { if (el.endDate && el.startDate && el.endDate < el.startDate) { @@ -31,10 +41,11 @@ export const auditLogFilterFormSchema = z } }); -export type AuditLogFilterFormData = z.infer; +export type TAuditLogFilterFormData = z.infer; +export type TAuditLogDateFilterFormData = z.infer; export type SetValueType = ( - name: keyof AuditLogFilterFormData, + name: keyof TAuditLogFilterFormData, value: any, options?: { shouldValidate?: boolean; diff --git a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OIDCModal.tsx b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OIDCModal.tsx index 52df20285..03f946ace 100644 --- a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OIDCModal.tsx +++ b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OIDCModal.tsx @@ -105,7 +105,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateOIDCConfig(); const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle(false); - const { data } = useGetOIDCConfig(currentOrg?.slug ?? ""); + const { data } = useGetOIDCConfig(currentOrg?.id ?? ""); const { control, handleSubmit, reset, setValue, watch } = useForm({ resolver: zodResolver(schema), @@ -134,7 +134,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele clientId: "", clientSecret: "", isActive: false, - orgSlug: currentOrg.slug + organizationId: currentOrg.id }); createNotification({ @@ -196,7 +196,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele clientId, clientSecret, isActive: true, - orgSlug: currentOrg.slug, + organizationId: currentOrg.id, jwtSignatureAlgorithm }); } else { @@ -212,7 +212,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele clientId, clientSecret, isActive: true, - orgSlug: currentOrg.slug, + organizationId: currentOrg.id, jwtSignatureAlgorithm }); } diff --git a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgOIDCSection.tsx b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgOIDCSection.tsx index 8d5021d53..3956ba894 100644 --- a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgOIDCSection.tsx +++ b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgOIDCSection.tsx @@ -21,7 +21,7 @@ export const OrgOIDCSection = (): JSX.Element => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); - const { data, isPending } = useGetOIDCConfig(currentOrg?.slug ?? ""); + const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); const { mutateAsync: updateOrg } = useUpdateOrg(); @@ -41,7 +41,7 @@ export const OrgOIDCSection = (): JSX.Element => { } await mutateAsync({ - orgSlug: currentOrg?.slug, + organizationId: currentOrg?.id, isActive: value }); @@ -114,7 +114,7 @@ export const OrgOIDCSection = (): JSX.Element => { } await mutateAsync({ - orgSlug: currentOrg?.slug, + organizationId: currentOrg?.id, manageGroupMemberships: value }); diff --git a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx index ca27b7517..9b11c1302 100644 --- a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -38,7 +38,7 @@ export const OrgSsoTab = withPermission( const { subscription } = useSubscription(); const { data: oidcConfig, isPending: isLoadingOidcConfig } = useGetOIDCConfig( - currentOrg?.slug ?? "" + currentOrg?.id ?? "" ); const { data: samlConfig, isPending: isLoadingSamlConfig } = useGetSSOConfig( currentOrg?.id ?? "" diff --git a/frontend/src/pages/project/layout.tsx b/frontend/src/pages/project/layout.tsx index f803329ca..8f85cb1a5 100644 --- a/frontend/src/pages/project/layout.tsx +++ b/frontend/src/pages/project/layout.tsx @@ -1,41 +1,37 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router"; -import { BreadcrumbTypes } from '@app/components/v2' -import { workspaceKeys } from '@app/hooks/api' -import { - fetchUserProjectPermissions, - roleQueryKeys, -} from '@app/hooks/api/roles/queries' -import { fetchWorkspaceById } from '@app/hooks/api/workspace/queries' -import { ProjectLayout } from '@app/layouts/ProjectLayout' -import { ProjectSelect } from '@app/layouts/ProjectLayout/components/ProjectSelect' +import { BreadcrumbTypes } from "@app/components/v2"; +import { workspaceKeys } from "@app/hooks/api"; +import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries"; +import { fetchWorkspaceById } from "@app/hooks/api/workspace/queries"; +import { ProjectLayout } from "@app/layouts/ProjectLayout"; +import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect"; export const Route = createFileRoute( - '/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout', + "/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout" )({ component: ProjectLayout, beforeLoad: async ({ params, context }) => { const project = await context.queryClient.ensureQueryData({ queryKey: workspaceKeys.getWorkspaceById(params.projectId), - queryFn: () => fetchWorkspaceById(params.projectId), - }) + queryFn: () => fetchWorkspaceById(params.projectId) + }); await context.queryClient.ensureQueryData({ queryKey: roleQueryKeys.getUserProjectPermissions({ - workspaceId: params.projectId, + workspaceId: params.projectId }), - queryFn: () => - fetchUserProjectPermissions({ workspaceId: params.projectId }), - }) + queryFn: () => fetchUserProjectPermissions({ workspaceId: params.projectId }) + }); return { project, breadcrumbs: [ { type: BreadcrumbTypes.Component, - component: ProjectSelect, - }, - ], - } - }, -}) + component: ProjectSelect + } + ] + }; + } +}); diff --git a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx index 5f360237f..8c0687d61 100644 --- a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx @@ -70,7 +70,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => { upgrade your subscription - )} + )}{" "} to view integration logs

diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx new file mode 100644 index 000000000..05370793f --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TRailwaySync } from "@app/hooks/api/secretSyncs/types/railway-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TRailwaySync; +}; + +export const RailwaySyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 01064ef1d..1b357cffb 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -17,11 +17,13 @@ import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; +import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; +import { ZabbixSyncDestinationCol } from "./ZabbixSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -73,6 +75,10 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.CloudflarePages: return ; + case SecretSync.Zabbix: + return ; + case SecretSync.Railway: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx new file mode 100644 index 000000000..849e64d55 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TZabbixSync } from "@app/hooks/api/secretSyncs/types/zabbix-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TZabbixSync; +}; + +export const ZabbixSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 26c9144d7..a8bad462d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -1,4 +1,5 @@ import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { @@ -144,6 +145,21 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.projectName; secondaryText = destinationConfig.environment; break; + case SecretSync.Zabbix: + if (destinationConfig.scope === ZabbixSyncScope.Host) { + primaryText = destinationConfig.hostName; + secondaryText = destinationConfig.hostId; + } else if (destinationConfig.scope === ZabbixSyncScope.Global) { + primaryText = "Global"; + secondaryText = ""; + } else { + throw new Error(`Unhandled Zabbix Scope Destination Col Values ${destination}`); + } + break; + case SecretSync.Railway: + primaryText = "Railway Project"; + secondaryText = destinationConfig.projectName; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 78582db2d..f58edbd33 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; @@ -58,6 +58,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { HeaderResizer } from "@app/components/v2/HeaderResizer/HeaderResizer"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, @@ -73,7 +74,14 @@ import { PreferenceKey, setUserTablePreference } from "@app/helpers/userTablePreferences"; -import { useDebounce, usePagination, usePopUp, useResetPageHelper, useToggle } from "@app/hooks"; +import { + useDebounce, + usePagination, + usePopUp, + useResetPageHelper, + useResizableHeaderHeight, + useToggle +} from "@app/hooks"; import { useCreateFolder, useCreateSecretV3, @@ -97,6 +105,7 @@ import { useSecretRotationOverview } from "@app/hooks/utils"; import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow"; +import { getHeaderStyle } from "@app/pages/secret-manager/OverviewPage/components/utils"; import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm"; import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; @@ -142,6 +151,8 @@ const DEFAULT_FILTER_STATE = { [RowType.SecretRotation]: true }; +const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120; + export const OverviewPage = () => { const { t } = useTranslation(); @@ -159,7 +170,7 @@ export const OverviewPage = () => { const [scrollOffset, setScrollOffset] = useState(0); const [debouncedScrollOffset] = useDebounce(scrollOffset); const { permission } = useProjectPermission(); - + const tableRef = useRef(null); const { currentWorkspace } = useWorkspace(); const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; const workspaceId = currentWorkspace?.id as string; @@ -861,10 +872,26 @@ export const OverviewPage = () => { ); }, [importedByEnvs, selectedEntries, selectedKeysCount]); + const storedHeight = Number.parseInt( + localStorage.getItem("overview-header-height") ?? DEFAULT_COLLAPSED_HEADER_HEIGHT.toString(), + 10 + ); + const { headerHeight, handleMouseDown, isResizing } = useResizableHeaderHeight({ + initialHeight: Number.isNaN(storedHeight) ? DEFAULT_COLLAPSED_HEADER_HEIGHT : storedHeight, + minHeight: DEFAULT_COLLAPSED_HEADER_HEIGHT, + maxHeight: 288 + }); + + const debouncedHeaderHeight = useDebounce(headerHeight); + + useEffect(() => { + localStorage.setItem("overview-header-height", debouncedHeaderHeight.toString()); + }, [debouncedHeaderHeight]); + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { return (
- +
); } @@ -892,7 +919,7 @@ export const OverviewPage = () => { -
+
{ - + {/*