diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index dfcb87deb..34cd3eed8 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -69,13 +69,21 @@ RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user WORKDIR /app -# Required for pkcs11js +# Required for pkcs11js and ODBC RUN apt-get update && apt-get install -y \ python3 \ make \ g++ \ + unixodbc \ + unixodbc-dev \ + freetds-dev \ + freetds-bin \ + tdsodbc \ && rm -rf /var/lib/apt/lists/* +# Configure ODBC +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini + COPY backend/package*.json ./ RUN npm ci --only-production @@ -91,13 +99,21 @@ ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ WORKDIR /app -# Required for pkcs11js +# Required for pkcs11js and ODBC RUN apt-get update && apt-get install -y \ python3 \ make \ g++ \ + unixodbc \ + unixodbc-dev \ + freetds-dev \ + freetds-bin \ + tdsodbc \ && rm -rf /var/lib/apt/lists/* +# Configure ODBC +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini + COPY backend/package*.json ./ RUN npm ci --only-production @@ -108,13 +124,24 @@ RUN mkdir frontend-build # Production stage FROM base AS production -# Install necessary packages +# Install necessary packages including ODBC RUN apt-get update && apt-get install -y \ ca-certificates \ curl \ git \ + python3 \ + make \ + g++ \ + unixodbc \ + unixodbc-dev \ + freetds-dev \ + freetds-bin \ + tdsodbc \ && rm -rf /var/lib/apt/lists/* +# Configure ODBC in production +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini + # Install Infisical CLI RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ && apt-get update && apt-get install -y infisical=0.31.1 \ diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 41c898b79..cd5477083 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -72,8 +72,16 @@ RUN addgroup --system --gid 1001 nodejs \ WORKDIR /app -# Required for pkcs11js -RUN apk add --no-cache python3 make g++ +# Install all required dependencies for build +RUN apk --update add \ + python3 \ + make \ + g++ \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev COPY backend/package*.json ./ RUN npm ci --only-production @@ -88,8 +96,19 @@ FROM base AS backend-runner WORKDIR /app -# Required for pkcs11js -RUN apk add --no-cache python3 make g++ +# Install all required dependencies for runtime +RUN apk --update add \ + python3 \ + make \ + g++ \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev + +# Configure ODBC +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini COPY backend/package*.json ./ RUN npm ci --only-production @@ -100,11 +119,32 @@ RUN mkdir frontend-build # Production stage FROM base AS production + RUN apk add --upgrade --no-cache ca-certificates RUN apk add --no-cache bash curl && curl -1sLf \ 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ && apk add infisical=0.31.1 && apk add --no-cache git +WORKDIR / + +# Install all required runtime dependencies +RUN apk --update add \ + python3 \ + make \ + g++ \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev \ + bash \ + curl \ + git + +# Configure ODBC in production +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini + +# Setup user permissions RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 non-root-user @@ -127,7 +167,6 @@ ARG CAPTCHA_SITE_KEY ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY -WORKDIR / COPY --from=backend-runner /app /backend @@ -149,4 +188,4 @@ EXPOSE 443 USER non-root-user -CMD ["./standalone-entrypoint.sh"] +CMD ["./standalone-entrypoint.sh"] \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 582264946..0bb358ee0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,15 @@ RUN apk --update add \ make \ g++ +# install dependencies for TDS driver (required for SAP ASE dynamic secrets) +RUN apk add --no-cache \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev + + COPY package*.json ./ RUN npm ci --only-production @@ -28,6 +37,17 @@ RUN apk --update add \ make \ g++ +# install dependencies for TDS driver (required for SAP ASE dynamic secrets) +RUN apk add --no-cache \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev + + +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini + RUN npm ci --only-production && npm cache clean --force COPY --from=build /app . diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 97bc2c6a3..3eda2ad03 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -7,7 +7,7 @@ ARG SOFTHSM2_VERSION=2.5.0 ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \ SOFTHSM2_SOURCES=/tmp/softhsm2 -# install build dependencies including python3 +# install build dependencies including python3 (required for pkcs11js and partially TDS driver) RUN apk --update add \ alpine-sdk \ autoconf \ @@ -19,7 +19,19 @@ RUN apk --update add \ make \ g++ +# install dependencies for TDS driver (required for SAP ASE dynamic secrets) +RUN apk add --no-cache \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev + + +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini + # build and install SoftHSM2 + RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES} WORKDIR ${SOFTHSM2_SOURCES} diff --git a/backend/package-lock.json b/backend/package-lock.json index cbcdfe76f..2113d21a6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "@fastify/multipart": "8.3.0", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", @@ -81,6 +82,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.4", "nodemailer": "^6.9.9", + "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", @@ -5529,6 +5531,15 @@ "toad-cache": "^3.3.0" } }, + "node_modules/@fastify/request-context": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz", + "integrity": "sha512-PM7wrLJOEylVDpxabOFLaYsdAiaa0lpDUcP2HMFJ1JzgiWuC6k4r3duf6Pm9YLnzlGmT+Yp4tkQjqsu7V/pSOA==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^4.0.0" + } + }, "node_modules/@fastify/send": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", @@ -17878,6 +17889,27 @@ "jsonwebtoken": "^9.0.2" } }, + "node_modules/odbc": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/odbc/-/odbc-2.4.9.tgz", + "integrity": "sha512-sHFWOKfyj4oFYds7YBlN+fq9ZjC2J6CsCN5CNMABpKLp+NZdb8bnanb57OaoDy1VFXEOTE91S+F900J/aIPu6w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.5", + "async": "^3.0.1", + "node-addon-api": "^3.0.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/odbc/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 65e06701f..1aabdde2e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -132,6 +132,7 @@ "@fastify/multipart": "8.3.0", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", @@ -189,6 +190,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.4", "nodemailer": "^6.9.9", + "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", diff --git a/backend/src/@types/fastify-request-context.d.ts b/backend/src/@types/fastify-request-context.d.ts new file mode 100644 index 000000000..caef4d5b2 --- /dev/null +++ b/backend/src/@types/fastify-request-context.d.ts @@ -0,0 +1,7 @@ +import "@fastify/request-context"; + +declare module "@fastify/request-context" { + interface RequestContextData { + requestId: string; + } +} diff --git a/backend/src/@types/fastify-zod.d.ts b/backend/src/@types/fastify-zod.d.ts index 393579391..440e3393f 100644 --- a/backend/src/@types/fastify-zod.d.ts +++ b/backend/src/@types/fastify-zod.d.ts @@ -1,6 +1,6 @@ import { FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from "fastify"; -import { Logger } from "pino"; +import { CustomLogger } from "@app/lib/logger/logger"; import { ZodTypeProvider } from "@app/server/plugins/fastify-zod"; declare global { @@ -8,7 +8,7 @@ declare global { RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, - Readonly, + Readonly, ZodTypeProvider >; diff --git a/backend/src/db/migrations/20241119143026_add-project-descripton.ts b/backend/src/db/migrations/20241119143026_add-project-descripton.ts new file mode 100644 index 000000000..3c78c99e2 --- /dev/null +++ b/backend/src/db/migrations/20241119143026_add-project-descripton.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasProjectDescription = await knex.schema.hasColumn(TableName.Project, "description"); + + if (!hasProjectDescription) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.string("description"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasProjectDescription = await knex.schema.hasColumn(TableName.Project, "description"); + + if (hasProjectDescription) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("description"); + }); + } +} diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts index d15e1dff8..c9c1ebda5 100644 --- a/backend/src/db/schemas/kms-root-config.ts +++ b/backend/src/db/schemas/kms-root-config.ts @@ -12,7 +12,7 @@ import { TImmutableDBKeys } from "./models"; export const KmsRootConfigSchema = z.object({ id: z.string().uuid(), encryptedRootKey: zodBuffer, - encryptionStrategy: z.string(), + encryptionStrategy: z.string().default("SOFTWARE").nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index deba51b9a..5c5f9774b 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -23,7 +23,8 @@ export const ProjectsSchema = z.object({ kmsCertificateKeyId: z.string().uuid().nullable().optional(), auditLogsRetentionDays: z.number().nullable().optional(), kmsSecretManagerKeyId: z.string().uuid().nullable().optional(), - kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional() + kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(), + description: z.string().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index 2cb862029..5fd218f19 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -80,7 +80,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin } }; - const addUserToInfisicalGroup = async (userId: string) => { + const $addUserToInfisicalGroup = async (userId: string) => { // figure out if the default user is already in the group, if it is, then we shouldn't add it again const addUserToGroupCommand = new ModifyUserGroupCommand({ @@ -96,7 +96,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin await ensureInfisicalGroupExists(clusterName); await elastiCache.send(new CreateUserCommand(creationInput)); // First create the user - await addUserToInfisicalGroup(creationInput.UserId); // Then add the user to the group. We know the group is already a part of the cluster because of ensureInfisicalGroupExists() + await $addUserToInfisicalGroup(creationInput.UserId); // Then add the user to the group. We know the group is already a part of the cluster because of ensureInfisicalGroupExists() return { userId: creationInput.UserId, @@ -212,7 +212,7 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { }; const renew = async (inputs: unknown, entityId: string) => { - // Do nothing + // No renewal necessary return { entityId }; }; 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 3feafa534..64ea6a02e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -33,7 +33,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const client = new IAMClient({ region: providerInputs.region, credentials: { @@ -47,7 +47,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const isConnected = await client.send(new GetUserCommand({})).then(() => true); return isConnected; @@ -55,7 +55,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = generateUsername(); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; @@ -118,7 +118,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = entityId; @@ -179,9 +179,8 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; const renew = async (_inputs: unknown, entityId: string) => { - // do nothing - const username = entityId; - return { entityId: username }; + // No renewal necessary + return { entityId }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index e2dfe2d4b..9e876f616 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -23,7 +23,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return providerInputs; }; - const getToken = async ( + const $getToken = async ( tenantId: string, applicationId: string, clientSecret: string @@ -51,18 +51,13 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); return data.success; }; - const renew = async (inputs: unknown, entityId: string) => { - // Do nothing - return { entityId }; - }; - const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); if (!data.success) { throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); } @@ -98,7 +93,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { }; const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => { - const data = await getToken(tenantId, applicationId, clientSecret); + const data = await $getToken(tenantId, applicationId, clientSecret); if (!data.success) { throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); } @@ -127,6 +122,11 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return users; }; + const renew = async (inputs: unknown, entityId: string) => { + // No renewal necessary + return { entityId }; + }; + return { validateProviderInputs, validateConnection, diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index aea0b9c99..c030a6813 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -27,7 +27,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const client = new cassandra.Client({ sslOptions, @@ -47,7 +47,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true); await client.shutdown(); @@ -56,7 +56,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -82,7 +82,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = entityId; const { keyspace } = providerInputs; @@ -99,20 +99,24 @@ export const CassandraProvider = (): TDynamicProviderFns => { const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + if (!providerInputs.renewStatement) return { entityId }; + + const client = await $getClient(providerInputs); - const username = entityId; const expiration = new Date(expireAt).toISOString(); const { keyspace } = providerInputs; - const renewStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace, expiration }); + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + keyspace, + expiration + }); const queries = renewStatement.toString().split(";").filter(Boolean); - for (const query of queries) { - // eslint-disable-next-line + for await (const query of queries) { await client.execute(query); } await client.shutdown(); - return { entityId: username }; + return { entityId }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index bfe0ac443..50ab2c694 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -24,7 +24,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const connection = new ElasticSearchClient({ node: { url: new URL(`${providerInputs.host}:${providerInputs.port}`), @@ -55,7 +55,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const infoResponse = await connection .info() @@ -67,7 +67,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -85,7 +85,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); await connection.security.deleteUser({ username: entityId @@ -96,7 +96,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { }; const renew = async (inputs: unknown, entityId: string) => { - // Do nothing + // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index e51462be6..9ed757a37 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -6,16 +6,17 @@ import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; import { LdapProvider } from "./ldap"; -import { DynamicSecretProviders } from "./models"; +import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; import { MongoDBProvider } from "./mongo-db"; import { RabbitMqProvider } from "./rabbit-mq"; import { RedisDatabaseProvider } from "./redis"; +import { SapAseProvider } from "./sap-ase"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; -export const buildDynamicSecretProviders = () => ({ +export const buildDynamicSecretProviders = (): Record => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), @@ -29,5 +30,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.Ldap]: LdapProvider(), [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), - [DynamicSecretProviders.Totp]: TotpProvider() + [DynamicSecretProviders.Totp]: TotpProvider(), + [DynamicSecretProviders.SapAse]: SapAseProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index f94e61629..992c9098e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -52,7 +52,7 @@ export const LdapProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer): Promise => { + const $getClient = async (providerInputs: z.infer): Promise => { return new Promise((resolve, reject) => { const client = ldapjs.createClient({ url: providerInputs.url, @@ -83,7 +83,7 @@ export const LdapProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); return client.connected; }; @@ -191,7 +191,7 @@ export const LdapProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); if (providerInputs.credentialType === LdapCredentialType.Static) { const dnMatch = providerInputs.rotationLdif.match(/^dn:\s*(.+)/m); @@ -235,7 +235,7 @@ export const LdapProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); if (providerInputs.credentialType === LdapCredentialType.Static) { const dnMatch = providerInputs.rotationLdif.match(/^dn:\s*(.+)/m); @@ -268,7 +268,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }; const renew = async (inputs: unknown, entityId: string) => { - // Do nothing + // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index d803aab5b..e8b9e6548 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -4,7 +4,8 @@ export enum SqlProviders { Postgres = "postgres", MySQL = "mysql2", Oracle = "oracledb", - MsSQL = "mssql" + MsSQL = "mssql", + SapAse = "sap-ase" } export enum ElasticSearchAuthTypes { @@ -118,6 +119,16 @@ export const DynamicSecretCassandraSchema = z.object({ ca: z.string().optional() }); +export const DynamicSecretSapAseSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim() +}); + export const DynamicSecretAwsIamSchema = z.object({ accessKey: z.string().trim().min(1), secretAccessKey: z.string().trim().min(1), @@ -274,12 +285,14 @@ export enum DynamicSecretProviders { Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", - Totp = "totp" + Totp = "totp", + SapAse = "sap-ase" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), + z.object({ type: z.literal(DynamicSecretProviders.SapAse), inputs: DynamicSecretSapAseSchema }), z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }), z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.SapHana), inputs: DynamicSecretSapHanaSchema }), diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 69f54ce77..95d7e590f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -22,7 +22,7 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const client = axios.create({ baseURL: "https://cloud.mongodb.com/api/atlas", headers: { @@ -40,7 +40,7 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const isConnected = await client({ method: "GET", @@ -59,7 +59,7 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -87,7 +87,7 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = entityId; const isExisting = await client({ @@ -114,7 +114,7 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = entityId; const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index b824f5aa8..5a64e0f7b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -23,7 +23,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const isSrv = !providerInputs.port; const uri = isSrv ? `mongodb+srv://${providerInputs.host}` @@ -42,7 +42,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const isConnected = await client .db(providerInputs.database) @@ -55,7 +55,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -74,7 +74,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = entityId; @@ -88,6 +88,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { }; const renew = async (_inputs: unknown, entityId: string) => { + // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 00d3b538f..9647ec6d8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -84,7 +84,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const axiosInstance = axios.create({ baseURL: `${removeTrailingSlash(providerInputs.host)}:${providerInputs.port}/api`, auth: { @@ -105,7 +105,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const infoResponse = await connection.get("/whoami").then(() => true); @@ -114,7 +114,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -134,7 +134,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); await deleteRabbitMqUser({ axiosInstance: connection, usernameToDelete: entityId }); @@ -142,7 +142,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { }; const renew = async (inputs: unknown, entityId: string) => { - // Do nothing + // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 0e7ae99a0..92ba1d4f9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -55,7 +55,7 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { let connection: Redis | null = null; try { connection = new Redis({ @@ -92,7 +92,7 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const pingResponse = await connection .ping() @@ -104,7 +104,7 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -126,7 +126,7 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + const connection = await $getClient(providerInputs); const username = entityId; @@ -141,7 +141,9 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await getClient(providerInputs); + if (!providerInputs.renewStatement) return { entityId }; + + const connection = await $getClient(providerInputs); const username = entityId; const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts new file mode 100644 index 000000000..f349d36bd --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -0,0 +1,145 @@ +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; +import odbc from "odbc"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(25); +}; + +enum SapCommands { + CreateLogin = "sp_addlogin", + DropLogin = "sp_droplogin" +} + +export const SapAseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); + + verifyHostInputValidity(providerInputs.host); + return providerInputs; + }; + + const $getClient = async (providerInputs: z.infer, useMaster?: boolean) => { + const connectionString = + `DRIVER={FreeTDS};` + + `SERVER=${providerInputs.host};` + + `PORT=${providerInputs.port};` + + `DATABASE=${useMaster ? "master" : providerInputs.database};` + + `UID=${providerInputs.username};` + + `PWD=${providerInputs.password};` + + `TDS_VERSION=5.0`; + + const client = await odbc.connect(connectionString); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const masterClient = await $getClient(providerInputs, true); + const client = await $getClient(providerInputs); + + const [resultFromMasterDatabase] = await masterClient.query<{ version: string }>("SELECT @@VERSION AS version"); + const [resultFromSelectedDatabase] = await client.query<{ version: string }>("SELECT @@VERSION AS version"); + + if (!resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection, version query failed" + }); + } + + if (resultFromMasterDatabase.version !== resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection (master), version mismatch" + }); + } + + return true; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const username = `inf_${generateUsername()}`; + const password = `${generatePassword()}`; + + const client = await $getClient(providerInputs); + const masterClient = await $getClient(providerInputs, true); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + + for await (const query of queries) { + // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. + // If not done, then the newly created user won't be able to authenticate. + await (query.startsWith(SapCommands.CreateLogin) ? masterClient : client).query(query); + } + + await masterClient.close(); + await client.close(); + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + + const client = await $getClient(providerInputs); + const masterClient = await $getClient(providerInputs, true); + + // Get all processes for this login and kill them. If there are active connections to the database when drop login happens, it will throw an error. + const result = await masterClient.query<{ spid?: string }>(`sp_who '${username}'`); + + if (result && result.length > 0) { + for await (const row of result) { + if (row.spid) { + await masterClient.query(`KILL ${row.spid.trim()}`); + } + } + } + + for await (const query of queries) { + await (query.startsWith(SapCommands.DropLogin) ? masterClient : client).query(query); + } + + await masterClient.close(); + await client.close(); + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index d120cf4fe..ecd7ba3d3 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -32,7 +32,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const client = hdb.createClient({ host: providerInputs.host, port: providerInputs.port, @@ -64,9 +64,9 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); - const testResult: boolean = await new Promise((resolve, reject) => { + const testResult = await new Promise((resolve, reject) => { client.exec("SELECT 1 FROM DUMMY;", (err: any) => { if (err) { reject(); @@ -86,7 +86,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ username, password, @@ -114,7 +114,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, username: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); const queries = revokeStatement.toString().split(";").filter(Boolean); for await (const query of queries) { @@ -135,13 +135,15 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return { entityId: username }; }; - const renew = async (inputs: unknown, username: string, expireAt: number) => { + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + if (!providerInputs.renewStatement) return { entityId }; + + const client = await $getClient(providerInputs); try { const expiration = new Date(expireAt).toISOString(); - const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username: entityId, expiration }); const queries = renewStatement.toString().split(";").filter(Boolean); for await (const query of queries) { await new Promise((resolve, reject) => { @@ -161,7 +163,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { client.disconnect(); } - return { entityId: username }; + return { entityId }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index 27ac3f49c..1b4376f43 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -34,7 +34,7 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const client = snowflake.createConnection({ account: `${providerInputs.orgId}-${providerInputs.accountId}`, username: providerInputs.username, @@ -49,7 +49,7 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); let isValidConnection: boolean; @@ -72,7 +72,7 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); const username = generateUsername(); const password = generatePassword(); @@ -107,7 +107,7 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, username: string) => { const providerInputs = await validateProviderInputs(inputs); - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); try { const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); @@ -131,17 +131,16 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return { entityId: username }; }; - const renew = async (inputs: unknown, username: string, expireAt: number) => { + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); + if (!providerInputs.renewStatement) return { entityId }; - if (!providerInputs.renewStatement) return { entityId: username }; - - const client = await getClient(providerInputs); + const client = await $getClient(providerInputs); try { const expiration = getDaysToExpiry(new Date(expireAt)); const renewStatement = handlebars.compile(providerInputs.renewStatement)({ - username, + username: entityId, expiration }); @@ -161,7 +160,7 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { client.destroy(noop); } - return { entityId: username }; + return { entityId }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 6acf23b06..835761211 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -32,7 +32,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { return providerInputs; }; - const getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer) => { const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const db = knex({ client: providerInputs.client, @@ -52,7 +52,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const db = await getClient(providerInputs); + const db = await $getClient(providerInputs); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; @@ -63,7 +63,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const db = await getClient(providerInputs); + const db = await $getClient(providerInputs); const username = generateUsername(providerInputs.client); const password = generatePassword(providerInputs.client); @@ -90,7 +90,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const db = await getClient(providerInputs); + const db = await $getClient(providerInputs); const username = entityId; const { database } = providerInputs; @@ -110,13 +110,19 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const renew = async (inputs: unknown, entityId: string, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const db = await getClient(providerInputs); + if (!providerInputs.renewStatement) return { entityId }; + + const db = await $getClient(providerInputs); - const username = entityId; const expiration = new Date(expireAt).toISOString(); const { database } = providerInputs; - const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database }); + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + expiration, + database + }); + if (renewStatement) { const queries = renewStatement.toString().split(";").filter(Boolean); await db.transaction(async (tx) => { @@ -128,7 +134,7 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { } await db.destroy(); - return { entityId: username }; + return { entityId }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/totp.ts b/backend/src/ee/services/dynamic-secret/providers/totp.ts index 4e3ab6eb2..d16b82306 100644 --- a/backend/src/ee/services/dynamic-secret/providers/totp.ts +++ b/backend/src/ee/services/dynamic-secret/providers/totp.ts @@ -1,7 +1,6 @@ import { authenticator } from "otplib"; import { HashAlgorithms } from "otplib/core"; -import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretTotpSchema, TDynamicProviderFns, TotpConfigType } from "./models"; @@ -76,10 +75,9 @@ export const TotpProvider = (): TDynamicProviderFns => { }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const renew = async (_inputs: unknown, _entityId: string) => { - throw new BadRequestError({ - message: "Lease renewal is not supported for TOTPs" - }); + const renew = async (_inputs: unknown, entityId: string) => { + // No renewal necessary + return { entityId }; }; return { diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index f91f9a004..3124e1012 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -27,7 +27,7 @@ export const initializeHsmModule = () => { logger.info("PKCS#11 module initialized"); } catch (err) { - logger.error("Failed to initialize PKCS#11 module:", err); + logger.error(err, "Failed to initialize PKCS#11 module"); throw err; } }; @@ -39,7 +39,7 @@ export const initializeHsmModule = () => { isInitialized = false; logger.info("PKCS#11 module finalized"); } catch (err) { - logger.error("Failed to finalize PKCS#11 module:", err); + logger.error(err, "Failed to finalize PKCS#11 module"); throw err; } } diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 66d799583..99b0d8d9b 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -36,8 +36,7 @@ export const testLDAPConfig = async (ldapConfig: TLDAPConfig): Promise }); ldapClient.on("error", (err) => { - logger.error("LDAP client error:", err); - logger.error(err); + logger.error(err, "LDAP client error"); resolve(false); }); diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index dc56e7bc3..6becaaf2b 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -161,8 +161,8 @@ export const licenseServiceFactory = ({ } } catch (error) { logger.error( - `getPlan: encountered an error when fetching pan [orgId=${orgId}] [projectId=${projectId}] [error]`, - error + error, + `getPlan: encountered an error when fetching pan [orgId=${orgId}] [projectId=${projectId}] [error]` ); await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(orgId), diff --git a/backend/src/ee/services/rate-limit/rate-limit-service.ts b/backend/src/ee/services/rate-limit/rate-limit-service.ts index 208fa8428..61b18be91 100644 --- a/backend/src/ee/services/rate-limit/rate-limit-service.ts +++ b/backend/src/ee/services/rate-limit/rate-limit-service.ts @@ -46,7 +46,7 @@ export const rateLimitServiceFactory = ({ rateLimitDAL, licenseService }: TRateL } return rateLimit; } catch (err) { - logger.error("Error fetching rate limits %o", err); + logger.error(err, "Error fetching rate limits"); return undefined; } }; @@ -69,12 +69,12 @@ export const rateLimitServiceFactory = ({ rateLimitDAL, licenseService }: TRateL mfaRateLimit: rateLimit.mfaRateLimit }; - logger.info(`syncRateLimitConfiguration: rate limit configuration: %o`, newRateLimitMaxConfiguration); + logger.info(newRateLimitMaxConfiguration, "syncRateLimitConfiguration: rate limit configuration"); Object.freeze(newRateLimitMaxConfiguration); rateLimitMaxConfiguration = newRateLimitMaxConfiguration; } } catch (error) { - logger.error(`Error syncing rate limit configurations: %o`, error); + logger.error(error, "Error syncing rate limit configurations"); } }; diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts index 1907ddd9a..42ff90055 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts @@ -238,11 +238,11 @@ export const secretScanningQueueFactory = ({ }); queueService.listen(QueueName.SecretPushEventScan, "failed", (job, err) => { - logger.error("Failed to secret scan on push", job?.data, err); + logger.error(err, "Failed to secret scan on push", job?.data); }); queueService.listen(QueueName.SecretFullRepoScan, "failed", (job, err) => { - logger.error("Failed to do full repo secret scan", job?.data, err); + logger.error(err, "Failed to do full repo secret scan", job?.data); }); return { startFullRepoScan, startPushEventScan }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0a4c65ac1..99822da29 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -391,6 +391,7 @@ export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", projectName: "The name of the project to create.", + projectDescription: "An optional description label for the project.", slug: "An optional slug for the project.", template: "The name of the project template, if specified, to apply to this project." }, @@ -403,6 +404,7 @@ export const PROJECTS = { UPDATE: { workspaceId: "The ID of the project to update.", name: "The new name of the project.", + projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project." }, GET_KEY: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 279ca057d..12ab33118 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,7 +1,7 @@ -import { Logger } from "pino"; import { z } from "zod"; import { removeTrailingSlash } from "../fn"; +import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; export const GITLAB_URL = "https://gitlab.com"; @@ -212,7 +212,7 @@ let envCfg: Readonly>; export const getConfig = () => envCfg; // cannot import singleton logger directly as it needs config to load various transport -export const initEnvConfig = (logger?: Logger) => { +export const initEnvConfig = (logger?: CustomLogger) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { (logger ?? console).error("Invalid environment variables. Check the error below"); diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index 942efc40a..5563499e5 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ // logger follows a singleton pattern // easier to use it that's all. +import { requestContext } from "@fastify/request-context"; import pino, { Logger } from "pino"; import { z } from "zod"; @@ -13,14 +15,37 @@ const logLevelToSeverityLookup: Record = { "60": "CRITICAL" }; -// eslint-disable-next-line import/no-mutable-exports -export let logger: Readonly; // akhilmhdh: // The logger is not placed in the main app config to avoid a circular dependency. // The config requires the logger to display errors when an invalid environment is supplied. // On the other hand, the logger needs the config to obtain credentials for AWS or other transports. // By keeping the logger separate, it becomes an independent package. +// We define our own custom logger interface to enforce structure to the logging methods. + +export interface CustomLogger extends Omit { + info: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (obj: unknown, msg?: string, ...args: any[]): void; + }; + + error: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (obj: unknown, msg?: string, ...args: any[]): void; + }; + warn: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (obj: unknown, msg?: string, ...args: any[]): void; + }; + debug: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (obj: unknown, msg?: string, ...args: any[]): void; + }; +} + +// eslint-disable-next-line import/no-mutable-exports +export let logger: Readonly; + const loggerConfig = z.object({ AWS_CLOUDWATCH_LOG_GROUP_NAME: z.string().default("infisical-log-stream"), AWS_CLOUDWATCH_LOG_REGION: z.string().default("us-east-1"), @@ -62,6 +87,17 @@ const redactedKeys = [ "config" ]; +const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID"; + +const extractRequestId = () => { + try { + return requestContext.get("requestId") || UNKNOWN_REQUEST_ID; + } catch (err) { + console.log("failed to get request context", err); + return UNKNOWN_REQUEST_ID; + } +}; + export const initLogger = async () => { const cfg = loggerConfig.parse(process.env); const targets: pino.TransportMultiOptions["targets"][number][] = [ @@ -94,6 +130,30 @@ export const initLogger = async () => { targets }); + const wrapLogger = (originalLogger: Logger): CustomLogger => { + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => { + return originalLogger.child({ requestId: extractRequestId() }).info(obj, msg, ...args); + }; + + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => { + return originalLogger.child({ requestId: extractRequestId() }).error(obj, msg, ...args); + }; + + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => { + return originalLogger.child({ requestId: extractRequestId() }).warn(obj, msg, ...args); + }; + + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => { + return originalLogger.child({ requestId: extractRequestId() }).debug(obj, msg, ...args); + }; + + return originalLogger; + }; + logger = pino( { mixin(_context, level) { @@ -113,5 +173,6 @@ export const initLogger = async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument transport ); - return logger; + + return wrapLogger(logger); }; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index cf7dd622a..83c34e5a7 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -10,13 +10,15 @@ import fastifyFormBody from "@fastify/formbody"; import helmet from "@fastify/helmet"; import type { FastifyRateLimitOptions } from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit"; +import { fastifyRequestContext } from "@fastify/request-context"; import fastify from "fastify"; import { Knex } from "knex"; -import { Logger } from "pino"; import { HsmModule } from "@app/ee/services/hsm/hsm-types"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED } from "@app/lib/config/env"; +import { CustomLogger } from "@app/lib/logger/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TQueueServiceFactory } from "@app/queue"; import { TSmtpService } from "@app/services/smtp/smtp-service"; @@ -35,7 +37,7 @@ type TMain = { auditLogDb?: Knex; db: Knex; smtp: TSmtpService; - logger?: Logger; + logger?: CustomLogger; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; hsmModule: HsmModule; @@ -47,7 +49,9 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key const server = fastify({ logger: appCfg.NODE_ENV === "test" ? false : logger, + genReqId: () => `req-${alphaNumericNanoId(14)}`, trustProxy: true, + connectionTimeout: appCfg.isHsmConfigured ? 90_000 : 30_000, ignoreTrailingSlash: true, pluginTimeout: 40_000 @@ -104,6 +108,13 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(maintenanceMode); + await server.register(fastifyRequestContext, { + defaultStoreValues: (request) => ({ + requestId: request.id, + log: request.log.child({ requestId: request.id }) + }) + }); + await server.register(registerRoutes, { smtp, queue, db, auditLogDb, keyStore, hsmModule }); if (appCfg.isProductionMode) { diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 63cffa987..0cbf30f09 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -39,29 +39,42 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider if (error instanceof BadRequestError) { void res .status(HttpStatusCodes.BadRequest) - .send({ statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); + .send({ requestId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); } else if (error instanceof NotFoundError) { void res .status(HttpStatusCodes.NotFound) - .send({ statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); + .send({ requestId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); } else if (error instanceof UnauthorizedError) { - void res - .status(HttpStatusCodes.Unauthorized) - .send({ statusCode: HttpStatusCodes.Unauthorized, message: error.message, error: error.name }); + void res.status(HttpStatusCodes.Unauthorized).send({ + requestId: req.id, + statusCode: HttpStatusCodes.Unauthorized, + message: error.message, + error: error.name + }); } else if (error instanceof DatabaseError || error instanceof InternalServerError) { - void res - .status(HttpStatusCodes.InternalServerError) - .send({ statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); + void res.status(HttpStatusCodes.InternalServerError).send({ + requestId: req.id, + statusCode: HttpStatusCodes.InternalServerError, + message: "Something went wrong", + error: error.name + }); } else if (error instanceof GatewayTimeoutError) { - void res - .status(HttpStatusCodes.GatewayTimeout) - .send({ statusCode: HttpStatusCodes.GatewayTimeout, message: error.message, error: error.name }); + void res.status(HttpStatusCodes.GatewayTimeout).send({ + requestId: req.id, + statusCode: HttpStatusCodes.GatewayTimeout, + message: error.message, + error: error.name + }); } else if (error instanceof ZodError) { - void res - .status(HttpStatusCodes.Unauthorized) - .send({ statusCode: HttpStatusCodes.Unauthorized, error: "ValidationFailure", message: error.issues }); + void res.status(HttpStatusCodes.Unauthorized).send({ + requestId: req.id, + statusCode: HttpStatusCodes.Unauthorized, + error: "ValidationFailure", + message: error.issues + }); } else if (error instanceof ForbiddenError) { void res.status(HttpStatusCodes.Forbidden).send({ + requestId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}`, @@ -74,48 +87,54 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider }); } else if (error instanceof ForbiddenRequestError) { void res.status(HttpStatusCodes.Forbidden).send({ + requestId: req.id, statusCode: HttpStatusCodes.Forbidden, message: error.message, error: error.name }); } else if (error instanceof RateLimitError) { void res.status(HttpStatusCodes.TooManyRequests).send({ + requestId: req.id, statusCode: HttpStatusCodes.TooManyRequests, message: error.message, error: error.name }); } else if (error instanceof ScimRequestError) { void res.status(error.status).send({ + requestId: req.id, schemas: error.schemas, status: error.status, detail: error.detail }); } else if (error instanceof OidcAuthError) { - void res - .status(HttpStatusCodes.InternalServerError) - .send({ statusCode: HttpStatusCodes.InternalServerError, message: error.message, error: error.name }); + void res.status(HttpStatusCodes.InternalServerError).send({ + requestId: req.id, + statusCode: HttpStatusCodes.InternalServerError, + message: error.message, + error: error.name + }); } else if (error instanceof jwt.JsonWebTokenError) { - const message = (() => { - if (error.message === JWTErrors.JwtExpired) { - return "Your token has expired. Please re-authenticate."; - } - if (error.message === JWTErrors.JwtMalformed) { - return "The provided access token is malformed. Please use a valid token or generate a new one and try again."; - } - if (error.message === JWTErrors.InvalidAlgorithm) { - return "The access token is signed with an invalid algorithm. Please provide a valid token and try again."; - } + let errorMessage = error.message; - return error.message; - })(); + if (error.message === JWTErrors.JwtExpired) { + errorMessage = "Your token has expired. Please re-authenticate."; + } else if (error.message === JWTErrors.JwtMalformed) { + errorMessage = + "The provided access token is malformed. Please use a valid token or generate a new one and try again."; + } else if (error.message === JWTErrors.InvalidAlgorithm) { + errorMessage = + "The access token is signed with an invalid algorithm. Please provide a valid token and try again."; + } void res.status(HttpStatusCodes.Forbidden).send({ + requestId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "TokenError", - message + message: errorMessage }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ + requestId: req.id, statusCode: HttpStatusCodes.InternalServerError, error: "InternalServerError", message: "Something went wrong" diff --git a/backend/src/server/plugins/secret-scanner.ts b/backend/src/server/plugins/secret-scanner.ts index d20008de7..d9b5801b9 100644 --- a/backend/src/server/plugins/secret-scanner.ts +++ b/backend/src/server/plugins/secret-scanner.ts @@ -19,7 +19,7 @@ export const registerSecretScannerGhApp = async (server: FastifyZodProvider) => app.on("installation", async (context) => { const { payload } = context; - logger.info("Installed secret scanner to:", { repositories: payload.repositories }); + logger.info({ repositories: payload.repositories }, "Installed secret scanner to"); }); app.on("push", async (context) => { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 78575e35d..3fbbc60e3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -30,27 +30,32 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({ export const DefaultResponseErrorsSchema = { 400: z.object({ + requestId: z.string(), statusCode: z.literal(400), message: z.string(), error: z.string() }), 404: z.object({ + requestId: z.string(), statusCode: z.literal(404), message: z.string(), error: z.string() }), 401: z.object({ + requestId: z.string(), statusCode: z.literal(401), message: z.any(), error: z.string() }), 403: z.object({ + requestId: z.string(), statusCode: z.literal(403), message: z.string(), details: z.any().optional(), error: z.string() }), 500: z.object({ + requestId: z.string(), statusCode: z.literal(500), message: z.string(), error: z.string() @@ -207,6 +212,7 @@ export const SanitizedAuditLogStreamSchema = z.object({ export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, + description: true, slug: true, autoCapitalization: true, orgId: true, diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 86d321852..40141e2c0 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -9,6 +9,7 @@ import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { IntegrationMetadataSchema } from "@app/services/integration/integration-schema"; +import { Integrations } from "@app/services/integration-auth/integration-list"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; import {} from "../sanitizedSchemas"; @@ -206,6 +207,33 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { id: req.params.integrationId }); + if (integration.region) { + integration.metadata = { + ...(integration.metadata || {}), + region: integration.region + }; + } + + if ( + integration.integration === Integrations.AWS_SECRET_MANAGER || + integration.integration === Integrations.AWS_PARAMETER_STORE + ) { + const awsRoleDetails = await server.services.integration.getIntegrationAWSIamRole({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationId + }); + + if (awsRoleDetails) { + integration.metadata = { + ...(integration.metadata || {}), + awsIamRole: awsRoleDetails.role + }; + } + } + return { integration }; } }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index e5e2f636c..f27462d02 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -296,6 +296,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .max(64, { message: "Name must be 64 or fewer characters" }) .optional() .describe(PROJECTS.UPDATE.name), + description: z + .string() + .trim() + .max(256, { message: "Description must be 256 or fewer characters" }) + .optional() + .describe(PROJECTS.UPDATE.projectDescription), autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) }), response: { @@ -313,6 +319,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, update: { name: req.body.name, + description: req.body.description, autoCapitalization: req.body.autoCapitalization }, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index c2aa446b4..0e271eb0e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -161,6 +161,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ], body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), + projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), slug: z .string() .min(5) @@ -194,6 +195,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, workspaceName: req.body.projectName, + workspaceDescription: req.body.projectDescription, slug: req.body.slug, kmsKeyId: req.body.kmsKeyId, template: req.body.template @@ -312,8 +314,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { slug: slugSchema.describe("The slug of the project to update.") }), body: z.object({ - name: z.string().trim().optional().describe("The new name of the project."), - autoCapitalization: z.boolean().optional().describe("The new auto-capitalization setting.") + name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), + description: z.string().trim().optional().describe(PROJECTS.UPDATE.projectDescription), + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) }), response: { 200: SanitizedProjectSchema @@ -330,6 +333,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, update: { name: req.body.name, + description: req.body.description, autoCapitalization: req.body.autoCapitalization }, actorId: req.permission.id, diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index d801e85ef..e95254816 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -119,13 +119,6 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { if (!userAgent) throw new Error("user agent header is required"); const appCfg = getConfig(); - const serverCfg = await getServerCfg(); - if (!serverCfg.allowSignUp) { - throw new ForbiddenRequestError({ - message: "Signup's are disabled" - }); - } - const { user, accessToken, refreshToken, organizationId } = await server.services.signup.completeEmailAccountSignup({ ...req.body, diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index b55d01308..a652c2a5b 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -9,7 +9,7 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; -import { NotFoundError } from "@app/lib/errors"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -23,6 +23,7 @@ import { TOrgServiceFactory } from "../org/org-service"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { getServerCfg } from "../super-admin/super-admin-service"; import { TUserDALFactory } from "../user/user-dal"; import { UserEncryption } from "../user/user-types"; import { TAuthDALFactory } from "./auth-dal"; @@ -151,6 +152,8 @@ export const authSignupServiceFactory = ({ authorization }: TCompleteAccountSignupDTO) => { const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + const user = await userDAL.findOne({ username: email }); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); @@ -163,6 +166,12 @@ export const authSignupServiceFactory = ({ authMethod = userAuthMethod; organizationId = orgId; } else { + // disallow signup if disabled. we are not doing this for providerAuthToken because we allow signups via saml or sso + if (!serverCfg.allowSignUp) { + throw new ForbiddenRequestError({ + message: "Signup's are disabled" + }); + } validateSignUpAuthorization(authorization, user.id); } diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index a49b15c1b..7f9cf920e 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -182,7 +182,12 @@ export const identityProjectServiceFactory = ({ // validate custom roles input const customInputRoles = roles.filter( - ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ({ role }) => + !Object.values(ProjectMembershipRole) + // we don't want to include custom in this check; + // this unintentionally enables setting slug to custom which is reserved + .filter((r) => r !== ProjectMembershipRole.Custom) + .includes(role as ProjectMembershipRole) ); const hasCustomRole = Boolean(customInputRoles.length); const customRoles = hasCustomRole diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 12f4c77de..1db10405d 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -9,6 +9,7 @@ import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; import { deleteIntegrationSecrets } from "../integration-auth/integration-delete-secret"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretQueueFactory } from "../secret/secret-queue"; @@ -237,6 +238,46 @@ export const integrationServiceFactory = ({ return { ...integration, envId: integration.environment.id }; }; + const getIntegrationAWSIamRole = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => { + const integration = await integrationDAL.findById(id); + + if (!integration) { + throw new NotFoundError({ + message: `Integration with ID '${id}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration?.projectId || "", + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + const integrationAuth = await integrationAuthDAL.findById(integration.integrationAuthId); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: integration.projectId + }); + let awsIamRole: string | null = null; + if (integrationAuth.encryptedAwsAssumeIamRoleArn) { + const awsAssumeRoleArn = secretManagerDecryptor({ + cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn) + }).toString(); + if (awsAssumeRoleArn) { + const [, role] = awsAssumeRoleArn.split(":role/"); + awsIamRole = role; + } + } + + return { + role: awsIamRole + }; + }; + const deleteIntegration = async ({ actorId, id, @@ -329,6 +370,7 @@ export const integrationServiceFactory = ({ deleteIntegration, listIntegrationByProject, getIntegration, + getIntegrationAWSIamRole, syncIntegration }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 74b830c6d..b4826b54b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -280,7 +280,12 @@ export const projectMembershipServiceFactory = ({ // validate custom roles input const customInputRoles = roles.filter( - ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ({ role }) => + !Object.values(ProjectMembershipRole) + // we don't want to include custom in this check; + // this unintentionally enables setting slug to custom which is reserved + .filter((r) => r !== ProjectMembershipRole.Custom) + .includes(role as ProjectMembershipRole) ); const hasCustomRole = Boolean(customInputRoles.length); if (hasCustomRole) { diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 4e7425326..e5e447145 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -191,6 +191,10 @@ export const projectDALFactory = (db: TDbClient) => { return project; } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + throw new DatabaseError({ error, name: "Find all projects" }); } }; @@ -240,6 +244,10 @@ export const projectDALFactory = (db: TDbClient) => { return project; } catch (error) { + if (error instanceof NotFoundError || error instanceof UnauthorizedError) { + throw error; + } + throw new DatabaseError({ error, name: "Find project by slug" }); } }; @@ -260,7 +268,7 @@ export const projectDALFactory = (db: TDbClient) => { } throw new BadRequestError({ message: "Invalid filter type" }); } catch (error) { - if (error instanceof BadRequestError) { + if (error instanceof BadRequestError || error instanceof NotFoundError || error instanceof UnauthorizedError) { throw error; } throw new DatabaseError({ error, name: `Failed to find project by ${filter.type}` }); diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index d59bde6c1..e845ebd35 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -285,11 +285,14 @@ export const projectQueueFactory = ({ if (!orgMembership) { // This can happen. Since we don't remove project memberships and project keys when a user is removed from an org, this is a valid case. - logger.info("User is not in organization", { - userId: key.receiverId, - orgId: project.orgId, - projectId: project.id - }); + logger.info( + { + userId: key.receiverId, + orgId: project.orgId, + projectId: project.id + }, + "User is not in organization" + ); // eslint-disable-next-line no-continue continue; } @@ -551,10 +554,10 @@ export const projectQueueFactory = ({ .catch(() => [null]); if (!project) { - logger.error("Failed to upgrade project, because no project was found", data); + logger.error(data, "Failed to upgrade project, because no project was found"); } else { await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed); - logger.error("Failed to upgrade project", err, { + logger.error(err, "Failed to upgrade project", { extra: { project, jobData: data diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index dfe2ce3ec..53e934716 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -149,6 +149,7 @@ export const projectServiceFactory = ({ actorOrgId, actorAuthMethod, workspaceName, + workspaceDescription, slug: projectSlug, kmsKeyId, tx: trx, @@ -206,6 +207,7 @@ export const projectServiceFactory = ({ const project = await projectDAL.create( { name: workspaceName, + description: workspaceDescription, orgId: organization.id, slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), kmsSecretManagerKeyId: kmsKeyId, @@ -496,6 +498,7 @@ export const projectServiceFactory = ({ const updatedProject = await projectDAL.updateById(project.id, { name: update.name, + description: update.description, autoCapitalization: update.autoCapitalization }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 28cda2d95..b826f2a6a 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -29,6 +29,7 @@ export type TCreateProjectDTO = { actorId: string; actorOrgId?: string; workspaceName: string; + workspaceDescription?: string; slug?: string; kmsKeyId?: string; createDefaultEnvs?: boolean; @@ -69,6 +70,7 @@ export type TUpdateProjectDTO = { filter: Filter; update: { name?: string; + description?: string; autoCapitalization?: boolean; }; } & Omit; diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index ffa4b4a04..58f51f880 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -142,7 +142,7 @@ export const fnTriggerWebhook = async ({ !isDisabled && picomatch.isMatch(secretPath, hookSecretPath, { strictSlashes: false }) ); if (!toBeTriggeredHooks.length) return; - logger.info("Secret webhook job started", { environment, secretPath, projectId }); + logger.info({ environment, secretPath, projectId }, "Secret webhook job started"); const project = await projectDAL.findById(projectId); const webhooksTriggered = await Promise.allSettled( toBeTriggeredHooks.map((hook) => @@ -195,5 +195,5 @@ export const fnTriggerWebhook = async ({ ); } }); - logger.info("Secret webhook job ended", { environment, secretPath, projectId }); + logger.info({ environment, secretPath, projectId }, "Secret webhook job ended"); }; diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 6f02408fd..b872b0e61 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -111,7 +111,7 @@ var exportCmd = &cobra.Command{ accessToken = token.Token } else { log.Debug().Msg("GetAllEnvironmentVariables: Trying to fetch secrets using logged in details") - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 05655e97c..df6bfcc60 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -41,7 +41,7 @@ var initCmd = &cobra.Command{ } } - userCreds, err := util.GetCurrentLoggedInUserDetails() + userCreds, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "Unable to get your login details") } diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index fff2ccf31..8f29c907a 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -154,6 +154,8 @@ var loginCmd = &cobra.Command{ DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { + presetDomain := config.INFISICAL_URL + clearSelfHostedDomains, err := cmd.Flags().GetBool("clear-domains") if err != nil { util.HandleError(err) @@ -198,7 +200,7 @@ var loginCmd = &cobra.Command{ // standalone user auth if loginMethod == "user" { - currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) // if the key can't be found or there is an error getting current credentials from key ring, allow them to override if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) { log.Debug().Err(err) @@ -216,11 +218,19 @@ var loginCmd = &cobra.Command{ return } } + + usePresetDomain, err := usePresetDomain(presetDomain) + + if err != nil { + util.HandleError(err) + } + //override domain domainQuery := true if config.INFISICAL_URL_MANUAL_OVERRIDE != "" && config.INFISICAL_URL_MANUAL_OVERRIDE != fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_EU_URL) && - config.INFISICAL_URL_MANUAL_OVERRIDE != fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_US_URL) { + config.INFISICAL_URL_MANUAL_OVERRIDE != fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_US_URL) && + !usePresetDomain { overrideDomain, err := DomainOverridePrompt() if err != nil { util.HandleError(err) @@ -228,7 +238,7 @@ var loginCmd = &cobra.Command{ //if not override set INFISICAL_URL to exported var //set domainQuery to false - if !overrideDomain { + if !overrideDomain && !usePresetDomain { domainQuery = false config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE) config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", strings.TrimSuffix(config.INFISICAL_URL, "/api")) @@ -237,7 +247,7 @@ var loginCmd = &cobra.Command{ } //prompt user to select domain between Infisical cloud and self-hosting - if domainQuery { + if domainQuery && !usePresetDomain { err = askForDomain() if err != nil { util.HandleError(err, "Unable to parse domain url") @@ -526,6 +536,45 @@ func DomainOverridePrompt() (bool, error) { return selectedOption == OVERRIDE, err } +func usePresetDomain(presetDomain string) (bool, error) { + infisicalConfig, err := util.GetConfigFile() + if err != nil { + return false, fmt.Errorf("askForDomain: unable to get config file because [err=%s]", err) + } + + preconfiguredUrl := strings.TrimSuffix(presetDomain, "/api") + + if preconfiguredUrl != "" && preconfiguredUrl != util.INFISICAL_DEFAULT_US_URL && preconfiguredUrl != util.INFISICAL_DEFAULT_EU_URL { + parsedDomain := strings.TrimSuffix(strings.Trim(preconfiguredUrl, "/"), "/api") + + _, err := url.ParseRequestURI(parsedDomain) + if err != nil { + return false, errors.New(fmt.Sprintf("Invalid domain URL: '%s'", parsedDomain)) + } + + config.INFISICAL_URL = fmt.Sprintf("%s/api", parsedDomain) + config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", parsedDomain) + + if !slices.Contains(infisicalConfig.Domains, parsedDomain) { + infisicalConfig.Domains = append(infisicalConfig.Domains, parsedDomain) + err = util.WriteConfigFile(&infisicalConfig) + + if err != nil { + return false, fmt.Errorf("askForDomain: unable to write domains to config file because [err=%s]", err) + } + } + + whilte := color.New(color.FgGreen) + boldWhite := whilte.Add(color.Bold) + time.Sleep(time.Second * 1) + boldWhite.Printf("[INFO] Using domain '%s' from domain flag or INFISICAL_API_URL environment variable\n", parsedDomain) + + return true, nil + } + + return false, nil +} + func askForDomain() error { // query user to choose between Infisical cloud or self-hosting diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index c533f3415..04af9cce8 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -54,7 +54,7 @@ func init() { util.CheckForUpdate() } - loggedInDetails, err := util.GetCurrentLoggedInUserDetails() + loggedInDetails, err := util.GetCurrentLoggedInUserDetails(false) if !silent && err == nil && loggedInDetails.IsUserLoggedIn && !loggedInDetails.LoginExpired { token, err := util.GetInfisicalToken(cmd) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index eff011c5e..e93d58885 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -194,7 +194,7 @@ var secretsSetCmd = &cobra.Command{ projectId = workspaceFile.WorkspaceId } - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "unable to authenticate [err=%v]") } @@ -278,7 +278,7 @@ var secretsDeleteCmd = &cobra.Command{ util.RequireLogin() util.RequireLocalWorkspaceFile() - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "Unable to authenticate") } diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index e2851f88f..531e622e9 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -41,7 +41,7 @@ var tokensCreateCmd = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { // get plain text workspace key - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "Unable to retrieve your logged in your details. Please login in then try again") diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index cb5b94080..03722dc41 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -55,7 +55,7 @@ func GetUserCredsFromKeyRing(userEmail string) (credentials models.UserCredentia return userCredentials, err } -func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { +func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails, error) { if ConfigFileExists() { configFile, err := GetConfigFile() if err != nil { @@ -75,18 +75,20 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { } } + if setConfigVariables { + config.INFISICAL_URL_MANUAL_OVERRIDE = config.INFISICAL_URL + //configFile.LoggedInUserDomain + //if not empty set as infisical url + if configFile.LoggedInUserDomain != "" { + config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) + } + } + // check to to see if the JWT is still valid httpClient := resty.New(). SetAuthToken(userCreds.JTWToken). SetHeader("Accept", "application/json") - config.INFISICAL_URL_MANUAL_OVERRIDE = config.INFISICAL_URL - //configFile.LoggedInUserDomain - //if not empty set as infisical url - if configFile.LoggedInUserDomain != "" { - config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) - } - isAuthenticated := api.CallIsAuthenticated(httpClient) // TODO: add refresh token // if !isAuthenticated { diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index c7f6de630..4715c71c3 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -20,7 +20,7 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder log.Debug().Msg("GetAllFolders: Trying to fetch folders using logged in details") - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) if err != nil { return nil, err } @@ -177,7 +177,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er if params.InfisicalToken == "" { RequireLogin() RequireLocalWorkspaceFile() - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) if err != nil { return models.SingleFolder{}, err @@ -224,7 +224,7 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, RequireLogin() RequireLocalWorkspaceFile() - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) if err != nil { return nil, err diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 5e19ea664..5a2a0ec24 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -246,7 +246,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo log.Debug().Msg("GetAllEnvironmentVariables: Trying to fetch secrets using logged in details") - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) isConnected := ValidateInfisicalAPIConnection() if isConnected { diff --git a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx new file mode 100644 index 000000000..3b7a895fb --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx @@ -0,0 +1,116 @@ +--- +title: "SAP ASE" +description: "Learn how to dynamically generate SAP ASE database account credentials." +--- + +The Infisical SAP ASE dynamic secret allows you to generate SAP ASE database credentials on demand. + +## Prerequisite + +- Infisical requires that you have a user in your SAP ASE instance, configured with the appropriate permissions. This user will facilitate the creation of new accounts as needed. + Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. + The user used for authentication must have access to the `master` database. You can use the `sa` user for this purpose or create a new user with the necessary permissions. + +- The SAP ASE instance should be reachable by Infisical. + +## Set up Dynamic Secrets with SAP ASE + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-modal.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 when a secret is generate) + + + + The maximum time-to-live for a generated secret + + + + Your SAP ASE instance host (IP or domain) + + + + Your SAP ASE instance port. On default SAP ASE instances this is usually `5000`. + + + + The database name that you want to generate credentials for. This database must exist on the SAP ASE instance. + Please note that the user/password used for authentication must have access to this database, **and** the `master` database. + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png) + + + Due to SAP ASE limitations, the attached SQL statements are not executed as a transaction. + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + 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 fall 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.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret. + diff --git a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-modal.png b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-modal.png new file mode 100644 index 000000000..2d48a93a3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png new file mode 100644 index 000000000..ceb474770 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png new file mode 100644 index 000000000..9ac56f456 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png differ diff --git a/docs/mint.json b/docs/mint.json index be7064873..19f957847 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -188,6 +188,7 @@ "documentation/platform/dynamic-secrets/mongo-db", "documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/ldap", + "documentation/platform/dynamic-secrets/sap-ase", "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", "documentation/platform/dynamic-secrets/totp" diff --git a/frontend/next.config.js b/frontend/next.config.js index e07695ed6..028000ed8 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,7 +2,7 @@ const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - connect-src 'self' https://*.posthog.com; + connect-src 'self' https://*.posthog.com http://127.0.0.1:*; script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx new file mode 100644 index 000000000..8f2cf79e8 --- /dev/null +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -0,0 +1,328 @@ +import { FC, useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { faInfoCircle } 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 { OrgPermissionCan } from "@app/components/permissions"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission, + useSubscription, + useUser +} from "@app/context"; +import { + fetchOrgUsers, + useAddUserToWsNonE2EE, + useCreateWorkspace, + useGetExternalKmsList +} from "@app/hooks/api"; +import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; +import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; + +const formSchema = z.object({ + name: z.string().trim().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), + description: z + .string() + .trim() + .max(256, "Description too long, max length is 256 characters") + .optional(), + addMembers: z.boolean(), + kmsKeyId: z.string(), + template: z.string() +}); + +type TAddProjectFormData = z.infer; + +interface NewProjectModalProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +type NewProjectFormProps = Pick; + +const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { permission } = useOrgPermission(); + const { user } = useUser(); + const createWs = useCreateWorkspace(); + const addUsersToProject = useAddUserToWsNonE2EE(); + const { subscription } = useSubscription(); + + const canReadProjectTemplates = permission.can( + OrgPermissionActions.Read, + OrgPermissionSubjects.ProjectTemplates + ); + + const { data: projectTemplates = [] } = useListProjectTemplates({ + enabled: Boolean(canReadProjectTemplates && subscription?.projectTemplates) + }); + + const { data: externalKmsList } = useGetExternalKmsList(currentOrg?.id!, { + enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms) + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + kmsKeyId: INTERNAL_KMS_KEY_ID, + template: InfisicalProjectTemplate.Default + } + }); + + useEffect(() => { + if (Object.keys(errors).length > 0) { + console.log("Current form errors:", errors); + } + }, [errors]); + + const onCreateProject = async ({ + name, + description, + addMembers, + kmsKeyId, + template + }: TAddProjectFormData) => { + // type check + if (!currentOrg) return; + if (!user) return; + try { + const { + data: { + project: { id: newProjectId } + } + } = await createWs.mutateAsync({ + projectName: name, + projectDescription: description, + kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined, + template + }); + + if (addMembers) { + const orgUsers = await fetchOrgUsers(currentOrg.id); + await addUsersToProject.mutateAsync({ + usernames: orgUsers + .filter( + (member) => member.user.username !== user.username && member.status === "accepted" + ) + .map((member) => member.user.username), + projectId: newProjectId, + orgId: currentOrg.id + }); + } + // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + createNotification({ text: "Project created", type: "success" }); + reset(); + onOpenChange(false); + router.push(`/project/${newProjectId}/secrets/overview`); + } catch (err) { + console.error(err); + createNotification({ text: "Failed to create project", type: "error" }); + } + }; + const onSubmit = handleSubmit((data) => { + return onCreateProject(data); + }); + return ( +
+
+ ( + + + + )} + /> + ( + +