diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 5d9f384f7f..f3265f3e99 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -135,10 +135,10 @@ jobs: TAG_NAME="${{ github.ref_name }}" echo "Checking for tag: $TAG_NAME" - EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME") + EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME 2>/dev/null | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME" || true) if [ "$EXACT_MATCH" == "refs/tags/$TAG_NAME" ]; then - echo "Tag $TAG_NAME already exists, skipping..." + echo "Tag $TAG_NAME already exists, skipping..." else echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" LATEST_SHA=$(gh api repos/Infisical/infisical-omnibus/git/refs/heads/main --jq '.object.sha') diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 273e6d9825..a2bc332f8a 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -135,9 +135,23 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + ip?: string; + userAgent?: string; orgId?: string; + orgName?: string; + userAuthInfo?: { + userId: string; + email: string; + }; + projectDetails?: { + id: string; + name: string; + slug: string; + }; identityAuthInfo?: { identityId: string; + identityName: string; + authMethod: string; oidc?: { claims: Record; }; diff --git a/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts b/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts new file mode 100644 index 0000000000..88a38c6d62 --- /dev/null +++ b/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts @@ -0,0 +1,68 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +// Fix for 20250722152841_add-policies-environments-table.ts migration. +// 20250722152841_add-policies-environments-table.ts introduced a bug where you can no longer delete a project if it has any approval policy environments. + +export async function up(knex: Knex): Promise { + // Fix SecretApprovalPolicyEnvironment to cascade delete when environment is deleted + // note: this won't actually happen, as we prevent deletion of environments with active approval policies + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.SecretApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix AccessApprovalPolicyEnvironment to cascade delete when environment is deleted + // note: this won't actually happen, as we prevent deletion of environments with active approval policies + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.AccessApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix SecretApprovalPolicy to CASCADE instead of SET NULL + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix AccessApprovalPolicy to CASCADE instead of SET NULL + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); +} + +export async function down(knex: Knex): Promise { + // Revert SecretApprovalPolicyEnvironment + await knex.schema.alterTable(TableName.SecretApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment); + }); + + // Revert AccessApprovalPolicyEnvironment + await knex.schema.alterTable(TableName.AccessApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment); + }); + + // Revert SecretApprovalPolicy back to SET NULL + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); + + // Revert AccessApprovalPolicy back to SET NULL + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); +} diff --git a/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts new file mode 100644 index 0000000000..b3ad123e8c --- /dev/null +++ b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.string("rotationStatus").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.binary("encryptedLastRotationMessage").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationStatus"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("encryptedLastRotationMessage"); + }); + } +} diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 7e78e0874b..4f097a16d8 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -21,7 +21,9 @@ export const PamAccountsSchema = z.object({ updatedAt: z.date(), rotationEnabled: z.boolean().default(false), rotationIntervalSeconds: z.number().nullable().optional(), - lastRotatedAt: z.date().nullable().optional() + lastRotatedAt: z.date().nullable().optional(), + rotationStatus: z.string().nullable().optional(), + encryptedLastRotationMessage: zodBuffer.nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index c6c0afcca3..8215325981 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,14 +1,14 @@ +import { + CreateMySQLResourceSchema, + MySQLResourceSchema, + UpdateMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; -import { - CreateMySQLResourceSchema, - MySQLResourceSchema, - UpdateMySQLResourceSchema -} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 76bff60e86..00add4adc3 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import { MultiSamlStrategy } from "@node-saml/passport-saml"; import { FastifyRequest } from "fastify"; @@ -17,6 +18,7 @@ import { ApiDocsTags, SamlSso } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config"; @@ -102,15 +104,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req, profile, cb) => { + if (!profile) throw new BadRequestError({ message: "Missing profile" }); + + const email = + profile?.email ?? + // entra sends data in this format + (profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ?? + (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\ + try { - if (!profile) throw new BadRequestError({ message: "Missing profile" }); - - const email = - profile?.email ?? - // entra sends data in this format - (profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ?? - (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\ - const firstName = (profile.firstName ?? // entra sends data in this format profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string; @@ -144,7 +146,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }) .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); - const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ + const { isUserCompleted, providerAuthToken, user, organization } = await server.services.saml.samlLogin({ externalId: profile.nameID, email: email.toLowerCase(), firstName, @@ -154,8 +156,32 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, metadata: userMetadata }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email.toLowerCase(), + "infisical.user.id": user.id, + "infisical.organization.id": organization.id, + "infisical.organization.name": organization.name, + "infisical.auth.method": AuthAttemptAuthMethod.SAML, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email.toLowerCase(), + "infisical.auth.method": AuthAttemptAuthMethod.SAML, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + logger.error(error); cb(error as Error); } diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index e80ec7cf5b..cbe1bed7ec 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; @@ -15,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -471,7 +473,7 @@ export const oidcConfigServiceFactory = ({ }); } - return { isUserCompleted, providerAuthToken }; + return { isUserCompleted, providerAuthToken, user }; }; const updateOidcCfg = async ({ @@ -754,10 +756,35 @@ export const oidcConfigServiceFactory = ({ callbackPort, manageGroupMemberships: oidcCfg.manageGroupMemberships }) - .then(({ isUserCompleted, providerAuthToken }) => { + .then(({ isUserCompleted, providerAuthToken, user }) => { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": claims?.email?.toLowerCase(), + "infisical.user.id": user.id, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.auth.method": AuthAttemptAuthMethod.OIDC, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); }) .catch((error) => { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": claims?.email?.toLowerCase(), + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.auth.method": AuthAttemptAuthMethod.OIDC, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(error); }); } diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts index fdc440991a..aae703eeb0 100644 --- a/backend/src/ee/services/pam-account/pam-account-fns.ts +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -45,17 +45,47 @@ export const decryptAccountCredentials = async ({ return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; }; -export const decryptAccount = async ( +export const decryptAccountMessage = async ({ + projectId, + encryptedMessage, + kmsService +}: { + projectId: string; + encryptedMessage: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedMessage + }); + + return decryptedPlainTextBlob.toString(); +}; + +export const decryptAccount = async < + T extends { encryptedCredentials: Buffer; encryptedLastRotationMessage?: Buffer | null } +>( account: T, projectId: string, kmsService: Pick -): Promise => { +): Promise => { return { ...account, credentials: await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId, kmsService - }) - } as T & { credentials: TPamAccountCredentials }; + }), + lastRotationMessage: account.encryptedLastRotationMessage + ? await decryptAccountMessage({ + encryptedMessage: account.encryptedLastRotationMessage, + projectId, + kmsService + }) + : null + }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index fd36150135..00b84943f0 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -15,6 +15,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -353,6 +354,7 @@ export const pamAccountServiceFactory = ({ TPamAccounts & { resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; + lastRotationMessage: string | null; } > = []; @@ -376,6 +378,7 @@ export const pamAccountServiceFactory = ({ ) { // Decrypt the account only if the user has permission to read it const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ ...decryptedAccount, resource: { @@ -575,10 +578,10 @@ export const pamAccountServiceFactory = ({ for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); - const rotationPromises = batch.map(async (account) => - pamAccountDAL.transaction(async (tx) => { - let logResourceType = "unknown"; - try { + const rotationPromises = batch.map(async (account) => { + let logResourceType = "unknown"; + try { + await pamAccountDAL.transaction(async (tx) => { const resource = await pamResourceDAL.findById(account.resourceId, tx); if (!resource || !resource.encryptedRotationAccountCredentials) return; logResourceType = resource.resourceType; @@ -619,7 +622,9 @@ export const pamAccountServiceFactory = ({ account.id, { encryptedCredentials, - lastRotatedAt: new Date() + lastRotatedAt: new Date(), + rotationStatus: "success", + encryptedLastRotationMessage: null }, tx ); @@ -640,32 +645,45 @@ export const pamAccountServiceFactory = ({ } } }); - } catch (error) { - logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; - await auditLogService.createAuditLog({ - projectId: account.projectId, - actor: { - type: ActorType.PLATFORM, - metadata: {} - }, - event: { - type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, - metadata: { - accountId: account.id, - accountName: account.name, - resourceId: account.resourceId, - resourceType: logResourceType, - errorMessage - } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: account.projectId + }); + + const { cipherTextBlob: encryptedMessage } = encryptor({ + plainText: Buffer.from(errorMessage) + }); + + await pamAccountDAL.updateById(account.id, { + rotationStatus: "failed", + encryptedLastRotationMessage: encryptedMessage + }); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: account.resourceId, + resourceType: logResourceType, + errorMessage } - }); - throw error; // Rollback transaction - } - }) - ); + } + }); + } + }); // eslint-disable-next-line no-await-in-loop await Promise.all(rotationPromises); diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 7f6165d88c..17ed1ccd19 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -33,7 +33,9 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ resourceType: true }).extend({ rotationCredentialsConfigured: z.boolean() - }) + }), + lastRotationMessage: z.string().nullable().optional(), + rotationStatus: z.string().nullable().optional() }); export const BaseCreatePamAccountSchema = z.object({ diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index a171b39b58..99ce2d25f2 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -41,7 +41,10 @@ export interface SqlResourceConnection { * * @returns Promise to be resolved with the new credentials */ - rotateCredentials: (currentCredentials: TSqlAccountCredentials) => Promise; + rotateCredentials: ( + currentCredentials: TSqlAccountCredentials, + newPassword: string + ) => Promise; /** * Close the connection. @@ -113,8 +116,7 @@ const makeSqlConnection = ( }); } }, - rotateCredentials: async (currentCredentials) => { - const newPassword = alphaNumericNanoId(32); + rotateCredentials: async (currentCredentials, newPassword) => { // Note: The generated random password is not really going to make SQL Injection possible. // The reason we are not using parameters binding is that the "ALTER USER" syntax is DDL, // parameters binding is not supported. But just in case if the this code got copied @@ -295,6 +297,7 @@ export const sqlResourceFactory: TPamResourceFactory { + const newPassword = alphaNumericNanoId(32); try { return await executeWithGateway( { @@ -305,7 +308,7 @@ export const sqlResourceFactory: TPamResourceFactory client.rotateCredentials(currentCredentials) + (client) => client.rotateCredentials(currentCredentials, newPassword) ); } catch (error) { if (error instanceof BadRequestError) { @@ -328,8 +331,10 @@ export const sqlResourceFactory: TPamResourceFactory Promise<{ isUserCompleted: boolean; providerAuthToken: string; + user: TUsers; + organization: TOrganizations; }>; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2aa14adfff..3a62a4790c 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2348,6 +2348,9 @@ export const AppConnections = { RAILWAY: { apiToken: "The API token used to authenticate with Railway." }, + NORTHFLANK: { + apiToken: "The API token used to authenticate with Northflank." + }, CHECKLY: { apiKey: "The API key used to authenticate with Checkly." }, @@ -2630,6 +2633,12 @@ export const SecretSyncs = { CHEF: { dataBagName: "The name of the Chef data bag to sync secrets to.", dataBagItemName: "The name of the Chef data bag item to sync secrets to." + }, + NORTHFLANK: { + projectId: "The ID of the Northflank project to sync secrets to.", + projectName: "The name of the Northflank project to sync secrets to.", + secretGroupId: "The ID of the Northflank secret group to sync secrets to.", + secretGroupName: "The name of the Northflank secret group to sync secrets to." } } }; diff --git a/backend/src/lib/telemetry/metrics.ts b/backend/src/lib/telemetry/metrics.ts new file mode 100644 index 0000000000..5f650ffc62 --- /dev/null +++ b/backend/src/lib/telemetry/metrics.ts @@ -0,0 +1,100 @@ +import { requestContext } from "@fastify/request-context"; +import opentelemetry from "@opentelemetry/api"; + +import { getConfig } from "../config/env"; + +const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + +export enum AuthAttemptAuthMethod { + EMAIL = "email", + SAML = "saml", + OIDC = "oidc", + GOOGLE = "google", + GITHUB = "github", + GITLAB = "gitlab", + TOKEN_AUTH = "token-auth", + UNIVERSAL_AUTH = "universal-auth", + KUBERNETES_AUTH = "kubernetes-auth", + GCP_AUTH = "gcp-auth", + ALICLOUD_AUTH = "alicloud-auth", + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth", + TLS_CERT_AUTH = "tls-cert-auth", + OCI_AUTH = "oci-auth", + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth", + LDAP_AUTH = "ldap-auth" +} + +export enum AuthAttemptAuthResult { + SUCCESS = "success", + FAILURE = "failure" +} + +export const authAttemptCounter = infisicalMeter.createCounter("infisical.auth.attempt.count", { + description: "Authentication attempts (both successful and failed)", + unit: "{attempt}" +}); + +export const secretReadCounter = infisicalMeter.createCounter("infisical.secret.read.count", { + description: "Number of secret read operations", + unit: "{operation}" +}); + +export const recordSecretReadMetric = (params: { environment: string; secretPath: string; name?: string }) => { + const appCfg = getConfig(); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + const attributes: Record = { + "infisical.environment": params.environment, + "infisical.secret.path": params.secretPath, + ...(params.name ? { "infisical.secret.name": params.name } : {}) + }; + + const orgId = requestContext.get("orgId"); + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + + const orgName = requestContext.get("orgName"); + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + const projectDetails = requestContext.get("projectDetails"); + if (projectDetails?.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails?.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + + const userAuthInfo = requestContext.get("userAuthInfo"); + if (userAuthInfo?.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo?.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + + const identityAuthInfo = requestContext.get("identityAuthInfo"); + if (identityAuthInfo?.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo?.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + + const userAgent = requestContext.get("userAgent"); + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + const ip = requestContext.get("ip"); + if (ip) { + attributes["client.address"] = ip; + } + + secretReadCounter.add(1, attributes); + } +}; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index f1176b932b..60b678f638 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -141,7 +141,9 @@ export const main = async ({ await server.register(fastifyRequestContext, { defaultStoreValues: (req) => ({ reqId: req.id, - log: req.log.child({ reqId: req.id }) + log: req.log.child({ reqId: req.id }), + ip: req.realIp, + userAgent: req.headers["user-agent"] }) }); diff --git a/backend/src/server/plugins/api-metrics.ts b/backend/src/server/plugins/api-metrics.ts index 2e3a20a23a..4233bc86dc 100644 --- a/backend/src/server/plugins/api-metrics.ts +++ b/backend/src/server/plugins/api-metrics.ts @@ -1,12 +1,26 @@ +import { requestContext } from "@fastify/request-context"; import opentelemetry from "@opentelemetry/api"; import fp from "fastify-plugin"; -export const apiMetrics = fp(async (fastify) => { - const apiMeter = opentelemetry.metrics.getMeter("API"); - const latencyHistogram = apiMeter.createHistogram("API_latency", { - unit: "ms" - }); +const apiMeter = opentelemetry.metrics.getMeter("API"); +const latencyHistogram = apiMeter.createHistogram("API_latency", { + unit: "ms" +}); + +const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + +const requestCounter = infisicalMeter.createCounter("infisical.http.server.request.count", { + description: "Total number of API requests to Infisical (covers both human users and machine identities)", + unit: "{request}" +}); + +const requestDurationHistogram = infisicalMeter.createHistogram("infisical.http.server.request.duration", { + description: "API request latency", + unit: "s" +}); + +export const apiMetrics = fp(async (fastify) => { fastify.addHook("onResponse", async (request, reply) => { const { method } = request; const route = request.routerPath; @@ -17,5 +31,67 @@ export const apiMetrics = fp(async (fastify) => { method, statusCode }); + + const orgId = requestContext.get("orgId"); + const orgName = requestContext.get("orgName"); + const userAuthInfo = requestContext.get("userAuthInfo"); + const identityAuthInfo = requestContext.get("identityAuthInfo"); + const projectDetails = requestContext.get("projectDetails"); + const userAgent = requestContext.get("userAgent"); + const ip = requestContext.get("ip"); + + const attributes: Record = { + "http.request.method": method, + "http.route": route, + "http.response.status_code": statusCode + }; + + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + if (userAuthInfo) { + if (userAuthInfo.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + } + + if (identityAuthInfo) { + if (identityAuthInfo.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + if (identityAuthInfo.authMethod) { + attributes["infisical.auth.method"] = identityAuthInfo.authMethod; + } + } + + if (projectDetails) { + if (projectDetails.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + } + + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + if (ip) { + attributes["client.address"] = ip; + } + + requestCounter.add(1, attributes); + requestDurationHistogram.record(reply.elapsedTime / 1000, attributes); }); }); diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 9de15cd346..31f3139ec9 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -1,4 +1,4 @@ -import { requestContext } from "@fastify/request-context"; +import { requestContext, RequestContextData } from "@fastify/request-context"; import { FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import type { JwtPayload } from "jsonwebtoken"; @@ -159,10 +159,11 @@ export const injectIdentity = fp( switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId, orgId, rootOrgId, parentOrgId } = + const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } = await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); requestContext.set("orgId", orgId); - + requestContext.set("orgName", orgName); + requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" }); req.auth = { authMode: AuthMode.JWT, user, @@ -186,6 +187,7 @@ export const injectIdentity = fp( ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); + requestContext.set("orgName", identity.orgName); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, @@ -198,24 +200,23 @@ export const injectIdentity = fp( isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), token }; + const identityAuthInfo: RequestContextData["identityAuthInfo"] = { + identityId: identity.identityId, + identityName: identity.name, + authMethod: identity.authMethod + }; + if (token?.identityAuth?.oidc) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - oidc: token?.identityAuth?.oidc - }); + identityAuthInfo.oidc = token?.identityAuth?.oidc; } if (token?.identityAuth?.kubernetes) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - kubernetes: token?.identityAuth?.kubernetes - }); + identityAuthInfo.kubernetes = token?.identityAuth?.kubernetes; } if (token?.identityAuth?.aws) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - aws: token?.identityAuth?.aws - }); + identityAuthInfo.aws = token?.identityAuth?.aws; } + + requestContext.set("identityAuthInfo", identityAuthInfo); break; } case AuthMode.SERVICE_TOKEN: { diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 62df05eecf..8d10a86306 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -1,4 +1,5 @@ import { ForbiddenError, PureAbility } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import opentelemetry from "@opentelemetry/api"; import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; @@ -47,6 +48,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider unit: "1" }); + const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + const errorCounter = infisicalMeter.createCounter("infisical.http.server.error.count", { + description: "Total number of API errors in Infisical (covers both human users and machine identities)", + unit: "{error}" + }); + server.setErrorHandler((error, req, res) => { req.log.error(error); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { @@ -61,6 +68,67 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider type: errorType, name: error.name }); + + const orgId = requestContext.get("orgId"); + const orgName = requestContext.get("orgName"); + const userAuthInfo = requestContext.get("userAuthInfo"); + const identityAuthInfo = requestContext.get("identityAuthInfo"); + const projectDetails = requestContext.get("projectDetails"); + + const attributes: Record = { + "http.request.method": method, + "http.route": route, + "error.type": errorType, + "error.name": error.name + }; + + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + if (userAuthInfo) { + if (userAuthInfo.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + } + + if (identityAuthInfo) { + if (identityAuthInfo.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + if (identityAuthInfo.authMethod) { + attributes["infisical.auth.method"] = identityAuthInfo.authMethod; + } + } + + if (projectDetails) { + if (projectDetails.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + } + + const userAgent = req.headers["user-agent"]; + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + if (req.realIp) { + attributes["client.address"] = req.realIp; + } + + errorCounter.add(1, attributes); } if (error instanceof BadRequestError) { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 31d8602014..cf268b24ec 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -608,6 +608,10 @@ export const registerRoutes = async ( const membershipGroupService = membershipGroupServiceFactory({ membershipGroupDAL, membershipRoleDAL, + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + secretApprovalPolicyDAL, + secretApprovalPolicyApproverDAL: sapApproverDAL, roleDAL, permissionService, orgDAL @@ -1705,7 +1709,8 @@ export const registerRoutes = async ( licenseService, permissionService, kmsService, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }); const identityAwsAuthService = identityAwsAuthServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 29f22621f4..9a4d7f38b2 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -89,6 +89,10 @@ import { NetlifyConnectionListItemSchema, SanitizedNetlifyConnectionSchema } from "@app/services/app-connection/netlify"; +import { + NorthflankConnectionListItemSchema, + SanitizedNorthflankConnectionSchema +} from "@app/services/app-connection/northflank"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -161,6 +165,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, + ...SanitizedNorthflankConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, @@ -205,6 +210,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, + NorthflankConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index a24e727668..32e9efe4c0 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -30,6 +30,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; +import { registerNorthflankConnectionRouter } from "./northflank-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -84,6 +85,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Northflank, + server, + sanitizedResponseSchema: SanitizedNorthflankConnectionSchema, + createSchema: CreateNorthflankConnectionSchema, + updateSchema: UpdateNorthflankConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const projects = await server.services.appConnection.northflank.listProjects(connectionId, req.permission); + return { projects }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/projects/:projectId/secret-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + projectId: z.string() + }), + response: { + 200: z.object({ + secretGroups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, projectId } = req.params; + const secretGroups = await server.services.appConnection.northflank.listSecretGroups( + connectionId, + projectId, + req.permission + ); + return { secretGroups }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index b6d8665774..d305dc3429 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -24,6 +24,7 @@ import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; +import { registerNorthflankSyncRouter } from "./northflank-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -65,6 +66,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Northflank, + server, + responseSchema: NorthflankSyncSchema, + createSchema: CreateNorthflankSyncSchema, + updateSchema: UpdateNorthflankSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index e3441d9dc4..85adf12f19 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -47,6 +47,7 @@ import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; +import { NorthflankSyncListItemSchema, NorthflankSyncSchema } from "@app/services/secret-sync/northflank"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -86,6 +87,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, + NorthflankSyncSchema, BitbucketSyncSchema, LaravelForgeSyncSchema, ChefSyncSchema @@ -121,6 +123,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, + NorthflankSyncListItemSchema, BitbucketSyncListItemSchema, LaravelForgeSyncListItemSchema, ChefSyncListItemSchema diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 366fa331db..32b09b3371 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; import { CronJob } from "cron"; @@ -21,6 +22,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthMethod } from "@app/services/auth/auth-type"; @@ -51,30 +53,54 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { - try { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); - // @ts-expect-error this is because this is express type and not fastify - const orgSlug = req.session.get("orgSlug"); + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); - const email = profile?.emails?.[0]?.value; - if (!email) - throw new NotFoundError({ - message: "Email not found", - name: "OauthGoogleRegister" + const email = profile?.emails?.[0]?.value; + if (!email) + throw new NotFoundError({ + message: "Email not found", + name: "OauthGoogleRegister" + }); + + try { + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: profile?.name?.givenName || "", + lastName: profile?.name?.familyName || "", + authMethod: AuthMethod.GOOGLE, + callbackPort, + orgSlug }); - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: profile?.name?.givenName || "", - lastName: profile?.name?.familyName || "", - authMethod: AuthMethod.GOOGLE, - callbackPort, - orgSlug - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { logger.error(error); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } cb(error as Error, false); } } @@ -101,27 +127,50 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { + const ghEmails = await fetchGithubEmails(accessToken); + const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; + + if (!email) throw new Error("No primary email found"); + try { - const ghEmails = await fetchGithubEmails(accessToken); - const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; - - if (!email) throw new Error("No primary email found"); - // profile does not get automatically populated so we need to manually fetch user info - const user = await fetchGithubUser(accessToken); + const githubUser = await fetchGithubUser(accessToken); const callbackPort = req.session.get("callbackPort"); - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: user.name || user.login, - lastName: "", - authMethod: AuthMethod.GITHUB, - callbackPort - }); + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: githubUser.name || githubUser.login, + lastName: "", + authMethod: AuthMethod.GITHUB, + callbackPort + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GITHUB, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); } catch (err) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GITHUB, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } logger.error(err); done(err as Error, false); } @@ -147,20 +196,45 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { + const email = profile.emails[0].value; + try { const callbackPort = req.session.get("callbackPort"); - const email = profile.emails[0].value; - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: profile.displayName || profile.username || "", - lastName: "", - authMethod: AuthMethod.GITLAB, - callbackPort - }); + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: profile.displayName || profile.username || "", + lastName: "", + authMethod: AuthMethod.GITLAB, + callbackPort + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GITLAB, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GITLAB, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + logger.error(error); cb(error as Error, false); } diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index fb2ffa643d..1c184a436a 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -39,7 +39,8 @@ export enum AppConnection { Okta = "okta", Redis = "redis", LaravelForge = "laravel-forge", - Chef = "chef" + Chef = "chef", + Northflank = "northflank" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index e3c95631d2..3c511fd4db 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -68,6 +68,7 @@ import { } from "./bitbucket"; import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly"; +import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef"; import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; import { getCloudflareConnectionListItem, @@ -113,6 +114,11 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; +import { + getNorthflankConnectionListItem, + NorthflankConnectionMethod, + validateNorthflankConnectionCredentials +} from "./northflank"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -142,7 +148,6 @@ import { WindmillConnectionMethod } from "./windmill"; import { getZabbixConnectionListItem, validateZabbixConnectionCredentials, ZabbixConnectionMethod } from "./zabbix"; -import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef"; const SECRET_SYNC_APP_CONNECTION_MAP = Object.fromEntries( Object.entries(SECRET_SYNC_CONNECTION_MAP).map(([key, value]) => [value, key]) @@ -204,6 +209,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), + getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem(), getChefConnectionListItem() @@ -334,10 +340,11 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); @@ -379,6 +386,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return "API Token"; @@ -477,6 +485,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, + [AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 93cd18f82b..08ca0c6810 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -41,7 +41,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", [AppConnection.Redis]: "Redis", - [AppConnection.Chef]: "Chef" + [AppConnection.Chef]: "Chef", + [AppConnection.Northflank]: "Northflank" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -85,5 +86,6 @@ export const APP_CONNECTION_PLAN_MAP: Record { + return { + name: "Northflank" as const, + app: AppConnection.Northflank as const, + methods: Object.values(NorthflankConnectionMethod) + }; +}; + +export const validateNorthflankConnectionCredentials = async (config: TNorthflankConnectionConfig) => { + const { credentials } = config; + + try { + await request.get(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate Northflank credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Failed to validate Northflank credentials - verify API token is correct` + }); + } + + return credentials; +}; + +export const listProjects = async (appConnection: TNorthflankConnection): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { projects } + } + } = await request.get<{ data: { projects: TNorthflankProject[] } }>(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + + return projects; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank projects: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank projects", + error + }); + } +}; + +export const listSecretGroups = async ( + appConnection: TNorthflankConnection, + projectId: string +): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { secrets } + } + } = await request.get<{ data: { secrets: TNorthflankSecretGroup[] } }>( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets`, + { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + } + ); + + return secrets; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank secret groups: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank secret groups", + error + }); + } +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts new file mode 100644 index 0000000000..95be757f64 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { NorthflankConnectionMethod } from "./northflank-connection-enums"; + +export const NorthflankConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.NORTHFLANK.apiToken) +}); + +const BaseNorthflankConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Northflank) +}); + +export const NorthflankConnectionSchema = BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema +}); + +export const SanitizedNorthflankConnectionSchema = z.discriminatedUnion("method", [ + BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNorthflankConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(NorthflankConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Northflank).method), + credentials: NorthflankConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Northflank).credentials + ) + }) +]); + +export const CreateNorthflankConnectionSchema = ValidateNorthflankConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Northflank) +); + +export const UpdateNorthflankConnectionSchema = z + .object({ + credentials: NorthflankConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Northflank).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Northflank)); + +export const NorthflankConnectionListItemSchema = z.object({ + name: z.literal("Northflank"), + app: z.literal(AppConnection.Northflank), + methods: z.nativeEnum(NorthflankConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/northflank/northflank-connection-service.ts b/backend/src/services/app-connection/northflank/northflank-connection-service.ts new file mode 100644 index 0000000000..faf248bbc5 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-service.ts @@ -0,0 +1,50 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listProjects as getNorthflankProjects, + listSecretGroups as getNorthflankSecretGroups +} from "./northflank-connection-fns"; +import { TNorthflankConnection, TNorthflankSecretGroup } from "./northflank-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const northflankConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const projects = await getNorthflankProjects(appConnection); + + return projects; + } catch (error) { + logger.error({ error, connectionId, actor: actor.type }, "Failed to establish connection with Northflank"); + return []; + } + }; + + const listSecretGroups = async ( + connectionId: string, + projectId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const secretGroups = await getNorthflankSecretGroups(appConnection, projectId); + + return secretGroups; + } catch (error) { + logger.error({ error, connectionId, projectId, actor: actor.type }, "Failed to list Northflank secret groups"); + return []; + } + }; + + return { + listProjects, + listSecretGroups + }; +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-types.ts b/backend/src/services/app-connection/northflank/northflank-connection-types.ts new file mode 100644 index 0000000000..c007e27d3b --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNorthflankConnectionSchema, + NorthflankConnectionSchema, + ValidateNorthflankConnectionCredentialsSchema +} from "./northflank-connection-schemas"; + +export type TNorthflankConnection = z.infer; + +export type TNorthflankConnectionInput = z.infer & { + app: AppConnection.Northflank; +}; + +export type TValidateNorthflankConnectionCredentialsSchema = typeof ValidateNorthflankConnectionCredentialsSchema; + +export type TNorthflankConnectionConfig = DiscriminativePick< + TNorthflankConnection, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 28a986fe81..fb7213109e 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -210,6 +210,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); let orgId = ""; + let orgName = ""; let rootOrgId = ""; let parentOrgId = ""; if (token.organizationId) { @@ -235,9 +236,11 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); } orgId = subOrganization.id; + orgName = subOrganization.name; rootOrgId = token.organizationId; parentOrgId = subOrganization.parentOrgId as string; } else { + const organization = await orgDAL.findOne({ id: token.organizationId }); const orgMembership = await membershipUserDAL.findOne({ actorUserId: user.id, scopeOrgId: token.organizationId, @@ -253,12 +256,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD } orgId = token.organizationId; + orgName = organization.name; rootOrgId = token.organizationId; parentOrgId = token.organizationId; } } - return { user, tokenVersionId: token.tokenVersionId, orgId, rootOrgId, parentOrgId }; + return { user, tokenVersionId: token.tokenVersionId, orgId, orgName, rootOrgId, parentOrgId }; }; return { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 31a9cc5a80..b9c7597030 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -16,6 +16,7 @@ import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -385,63 +386,94 @@ export const authLoginServiceFactory = ({ providerAuthToken?: string; captchaToken?: string; }) => { - const usersByUsername = await userDAL.findUserEncKeyByUsername({ - username: email - }); - const userEnc = - usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + const appCfg = getConfig(); - if (!userEnc) throw new BadRequestError({ message: "User not found" }); + try { + const usersByUsername = await userDAL.findUserEncKeyByUsername({ + username: email + }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; - if (userEnc.encryptionVersion !== UserEncryption.V2) { - throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); - } + if (!userEnc) throw new BadRequestError({ message: "User not found" }); - if (!userEnc.hashedPassword) { - if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + if (userEnc.encryptionVersion !== UserEncryption.V2) { throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); } - throw new BadRequestError({ message: "No password found" }); - } - - const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); - await verifyCaptcha(userEnc, captchaToken); - - if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) { - await userDAL.update( - { id: userEnc.userId }, - { - $incr: { - consecutiveFailedPasswordAttempts: 1 - } + if (!userEnc.hashedPassword) { + if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + throw new BadRequestError({ + message: "Legacy encryption scheme not supported", + name: "LegacyEncryptionScheme" + }); } - ); - throw new BadRequestError({ message: "Invalid username or email" }); + throw new BadRequestError({ message: "No password found" }); + } + + const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); + await verifyCaptcha(userEnc, captchaToken); + + if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new BadRequestError({ message: "Invalid username or email" }); + } + + const token = await generateUserTokens({ + user: { + ...userEnc, + id: userEnc.userId + }, + ip, + userAgent, + authMethod, + organizationId + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.organization.id": organizationId, + "infisical.user.email": email, + "infisical.user.id": userEnc.userId, + "infisical.auth.method": AuthAttemptAuthMethod.EMAIL, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": ip, + "user_agent.original": userAgent + }); + } + + return { + tokens: { + accessToken: token.access, + refreshToken: token.refresh + }, + user: userEnc + } as const; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.EMAIL, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": ip, + "user_agent.original": userAgent + }); + } + + throw error; } - - const token = await generateUserTokens({ - user: { - ...userEnc, - id: userEnc.userId - }, - ip, - userAgent, - authMethod, - organizationId - }); - - return { - tokens: { - accessToken: token.access, - refreshToken: token.refresh - }, - user: userEnc - } as const; }; const selectOrganization = async ({ @@ -965,7 +997,8 @@ export const authLoginServiceFactory = ({ expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME } ); - return { isUserCompleted, providerAuthToken }; + + return { isUserCompleted, providerAuthToken, user, orgId, orgName }; }; /** diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 02660a0ae9..3479d929e4 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -210,6 +210,7 @@ export const identityAccessTokenServiceFactory = ({ }); } let orgId = ""; + let orgName = ""; let parentOrgId = ""; const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; @@ -229,8 +230,12 @@ export const identityAccessTokenServiceFactory = ({ throw new BadRequestError({ message: "Identity does not belong to any organization" }); } orgId = subOrganization.id; + orgName = subOrganization.name; + parentOrgId = subOrganization.parentOrgId as string; } else { + const organization = await orgDAL.findOne({ id: rootOrgId }); + const identityOrgMembership = await membershipIdentityDAL.findOne({ scope: AccessScope.Organization, actorIdentityId: identityAccessToken.identityId, @@ -242,6 +247,7 @@ export const identityAccessTokenServiceFactory = ({ } orgId = rootOrgId; + orgName = organization.name; parentOrgId = rootOrgId; } @@ -253,7 +259,7 @@ export const identityAccessTokenServiceFactory = ({ await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); - return { ...identityAccessToken, orgId, rootOrgId, parentOrgId }; + return { ...identityAccessToken, orgId, rootOrgId, parentOrgId, orgName }; }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index c6f6f13767..525da1e104 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; @@ -22,6 +23,7 @@ import { } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -65,6 +67,7 @@ export const identityAliCloudAuthServiceFactory = ({ orgDAL }: TIdentityAliCloudAuthServiceFactoryDep) => { const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { + const appCfg = getConfig(); const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); if (!identityAliCloudAuth) { throw new NotFoundError({ @@ -75,73 +78,103 @@ export const identityAliCloudAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAliCloudAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const requestUrl = new URL("https://sts.aliyuncs.com"); + const org = await orgDAL.findById(identity.orgId); - for (const key of Object.keys(params)) { - requestUrl.searchParams.set(key, (params as Record)[key]); - } + try { + const requestUrl = new URL("https://sts.aliyuncs.com"); - const { data } = await request.get(requestUrl.toString()).catch((err: AxiosError) => { - logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); - throw err; - }); + for (const key of Object.keys(params)) { + requestUrl.searchParams.set(key, (params as Record)[key]); + } - if (identityAliCloudAuth.allowedArns) { - // In the future we could do partial checks for role ARNs - const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); + const { data } = await request.get(requestUrl.toString()).catch((err: AxiosError) => { + logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); + throw err; + }); - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: Alibaba Cloud account ARN not allowed." - }); - } + if (identityAliCloudAuth.allowedArns) { + // In the future we could do partial checks for role ARNs + const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); - // Generate the token - const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: Alibaba Cloud account ARN not allowed." + }); + } + + // Generate the token + const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAliCloudAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.ALICLOUD_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityAliCloudAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAliCloudAuth.accessTokenTTL, - accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.ALICLOUD_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityAliCloudAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAliCloudAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityAliCloudAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityAliCloudAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAliCloudAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAliCloudAuth = async ({ diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index e61e8296b1..81fab3fde0 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -1,5 +1,6 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios from "axios"; import RE2 from "re2"; @@ -22,6 +23,7 @@ import { } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -98,6 +100,7 @@ export const identityAwsAuthServiceFactory = ({ orgDAL }: TIdentityAwsAuthServiceFactoryDep) => { const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const appCfg = getConfig(); const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); if (!identityAwsAuth) { throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); @@ -106,127 +109,156 @@ export const identityAwsAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAwsAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); - const body: string = Buffer.from(iamRequestBody, "base64").toString(); + const org = await orgDAL.findById(identity.orgId); + try { + const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const body: string = Buffer.from(iamRequestBody, "base64").toString(); - const authHeader = headers.Authorization || headers.authorization; - const region = authHeader ? awsRegionFromHeader(authHeader) : null; + const authHeader = headers.Authorization || headers.authorization; + const region = authHeader ? awsRegionFromHeader(authHeader) : null; - if (!isValidAwsRegion(region)) { - throw new BadRequestError({ message: "Invalid AWS region" }); - } + if (!isValidAwsRegion(region)) { + throw new BadRequestError({ message: "Invalid AWS region" }); + } - const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; + const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; - const { - data: { - GetCallerIdentityResponse: { - GetCallerIdentityResult: { Account, Arn, UserId } + const { + data: { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { Account, Arn, UserId } + } + } + }: { data: TGetCallerIdentityResponse } = await axios({ + method: iamHttpRequestMethod, + url, + headers, + data: body + }); + + if (identityAwsAuth.allowedAccountIds) { + // validate if Account is in the list of allowed Account IDs + + const isAccountAllowed = identityAwsAuth.allowedAccountIds + .split(",") + .map((accountId) => accountId.trim()) + .some((accountId) => accountId === Account); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: AWS account ID not allowed." + }); + } + + if (identityAwsAuth.allowedPrincipalArns) { + // validate if Arn is in the list of allowed Principal ARNs + + const formattedArn = extractPrincipalArn(Arn); + + const isArnAllowed = identityAwsAuth.allowedPrincipalArns + .split(",") + .map((principalArn) => principalArn.trim()) + .some((principalArn) => { + // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" + // considers exact matches + wildcard matches + // heavily validated in router + const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); + return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true)); + }); + + if (!isArnAllowed) { + logger.error( + `AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]` + ); + + throw new UnauthorizedError({ + message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` + }); } } - }: { data: TGetCallerIdentityResponse } = await axios({ - method: iamHttpRequestMethod, - url, - headers, - data: body - }); - if (identityAwsAuth.allowedAccountIds) { - // validate if Account is in the list of allowed Account IDs - - const isAccountAllowed = identityAwsAuth.allowedAccountIds - .split(",") - .map((accountId) => accountId.trim()) - .some((accountId) => accountId === Account); - - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: AWS account ID not allowed." - }); - } - - if (identityAwsAuth.allowedPrincipalArns) { - // validate if Arn is in the list of allowed Principal ARNs - - const formattedArn = extractPrincipalArn(Arn); - - const isArnAllowed = identityAwsAuth.allowedPrincipalArns - .split(",") - .map((principalArn) => principalArn.trim()) - .some((principalArn) => { - // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" - // considers exact matches + wildcard matches - // heavily validated in router - const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); - return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true)); - }); - - if (!isArnAllowed) { - logger.error( - `AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]` + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, + lastLoginTime: new Date() + }, + tx ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAwsAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.AWS_AUTH + }, + tx + ); + return newToken; + }); - throw new UnauthorizedError({ - message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` - }); - } - } - - const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const splitArn = extractPrincipalArnEntity(Arn); + const accessToken = crypto.jwt().sign( { identityId: identityAwsAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAwsAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AWS_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + aws: { + accountId: Account, + arn: Arn, + userId: UserId, + + // Derived from ARN + partition: splitArn.Partition, + service: splitArn.Service, + resourceType: splitArn.Type, + resourceName: splitArn.FriendlyName + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const splitArn = extractPrincipalArnEntity(Arn); - const accessToken = crypto.jwt().sign( - { - identityId: identityAwsAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - aws: { - accountId: Account, - arn: Arn, - userId: UserId, + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAwsAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - // Derived from ARN - partition: splitArn.Partition, - service: splitArn.Service, - resourceType: splitArn.Type, - resourceName: splitArn.FriendlyName - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityAwsAuth, identityAccessToken, identity }; + return { accessToken, identityAwsAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAwsAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAwsAuth = async ({ diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index f75aeba4fa..17f274e7c9 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +19,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -61,6 +63,7 @@ export const identityAzureAuthServiceFactory = ({ orgDAL }: TIdentityAzureAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const appCfg = getConfig(); const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); if (!identityAzureAuth) { throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); @@ -69,69 +72,99 @@ export const identityAzureAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAzureAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const azureIdentity = await validateAzureIdentity({ - tenantId: identityAzureAuth.tenantId, - resource: identityAzureAuth.resource, - jwt: azureJwt - }); + const org = await orgDAL.findById(identity.orgId); - if (azureIdentity.tid !== identityAzureAuth.tenantId) - throw new UnauthorizedError({ message: "Tenant ID mismatch" }); + try { + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); - if (identityAzureAuth.allowedServicePrincipalIds) { - // validate if the service principal id is in the list of allowed service principal ids + if (azureIdentity.tid !== identityAzureAuth.tenantId) + throw new UnauthorizedError({ message: "Tenant ID mismatch" }); - const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds - .split(",") - .map((servicePrincipalId) => servicePrincipalId.trim()) - .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids - if (!isServicePrincipalAllowed) { - throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + + if (!isServicePrincipalAllowed) { + throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); + } } - } - const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityAzureAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAzureAuth.accessTokenTTL, - accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AZURE_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityAzureAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAzureAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityAzureAuth, identityAccessToken, identity }; + return { accessToken, identityAzureAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAzureAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAzureAuth = async ({ diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 67adb6c1e3..3e0035e828 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +19,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -59,6 +61,7 @@ export const identityGcpAuthServiceFactory = ({ orgDAL }: TIdentityGcpAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const appCfg = getConfig(); const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); if (!identityGcpAuth) { throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); @@ -67,108 +70,140 @@ export const identityGcpAuthServiceFactory = ({ const identity = await identityDAL.findById(identityGcpAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - let gcpIdentityDetails: TGcpIdentityDetails; - switch (identityGcpAuth.type) { - case "gce": { - gcpIdentityDetails = await validateIdTokenIdentity({ - identityId, - jwt: gcpJwt - }); - break; + const org = await orgDAL.findById(identity.orgId); + try { + let gcpIdentityDetails: TGcpIdentityDetails; + switch (identityGcpAuth.type) { + case "gce": { + gcpIdentityDetails = await validateIdTokenIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + case "iam": { + gcpIdentityDetails = await validateIamIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + default: { + throw new BadRequestError({ message: "Invalid GCP Auth type" }); + } } - case "iam": { - gcpIdentityDetails = await validateIamIdentity({ - identityId, - jwt: gcpJwt - }); - break; + + if (identityGcpAuth.allowedServiceAccounts) { + // validate if the service account is in the list of allowed service accounts + + const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts + .split(",") + .map((serviceAccount) => serviceAccount.trim()) + .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + + if (!isServiceAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP service account not allowed." + }); } - default: { - throw new BadRequestError({ message: "Invalid GCP Auth type" }); + + if ( + identityGcpAuth.type === "gce" && + identityGcpAuth.allowedProjects && + gcpIdentityDetails.computeEngineDetails + ) { + // validate if the project that the service account belongs to is in the list of allowed projects + + const isProjectAllowed = identityGcpAuth.allowedProjects + .split(",") + .map((project) => project.trim()) + .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); + + if (!isProjectAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP project not allowed." + }); } - } - if (identityGcpAuth.allowedServiceAccounts) { - // validate if the service account is in the list of allowed service accounts + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { + const isZoneAllowed = identityGcpAuth.allowedZones + .split(",") + .map((zone) => zone.trim()) + .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); - const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts - .split(",") - .map((serviceAccount) => serviceAccount.trim()) - .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + if (!isZoneAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP zone not allowed." + }); + } - if (!isServiceAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP service account not allowed." - }); - } - - if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { - // validate if the project that the service account belongs to is in the list of allowed projects - - const isProjectAllowed = identityGcpAuth.allowedProjects - .split(",") - .map((project) => project.trim()) - .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); - - if (!isProjectAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP project not allowed." - }); - } - - if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { - const isZoneAllowed = identityGcpAuth.allowedZones - .split(",") - .map((zone) => zone.trim()) - .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); - - if (!isZoneAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP zone not allowed." - }); - } - - const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityGcpAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.GCP_AUTH + }, + tx + ); + return newToken; + }); + const accessToken = crypto.jwt().sign( { identityId: identityGcpAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityGcpAuth.accessTokenTTL, - accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.GCP_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityGcpAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityGcpAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityGcpAuth, identityAccessToken, identity }; + return { accessToken, identityGcpAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityGcpAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachGcpAuth = async ({ diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index debd909331..3cf93fd16d 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; @@ -21,6 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -67,6 +69,7 @@ export const identityJwtAuthServiceFactory = ({ orgDAL }: TIdentityJwtAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const appCfg = getConfig(); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); if (!identityJwtAuth) { throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); @@ -75,176 +78,205 @@ export const identityJwtAuthServiceFactory = ({ const identity = await identityDAL.findById(identityJwtAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); - - const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); - if (!decodedToken) { - throw new UnauthorizedError({ - message: "Invalid JWT" + const org = await orgDAL.findById(identity.orgId); + try { + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId }); - } - let tokenData: Record = {}; - - if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { - let client: JwksClient; - if (identityJwtAuth.jwksUrl.includes("https:")) { - const decryptedJwksCaCert = orgDataKeyDecryptor({ - cipherTextBlob: identityJwtAuth.encryptedJwksCaCert - }).toString(); - - const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); - client = new JwksClient({ - jwksUri: identityJwtAuth.jwksUrl, - requestAgent - }); - } else { - client = new JwksClient({ - jwksUri: identityJwtAuth.jwksUrl + const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); + if (!decodedToken) { + throw new UnauthorizedError({ + message: "Invalid JWT" }); } - const { kid } = decodedToken.header as { kid: string }; - const jwtSigningKey = await client.getSigningKey(kid); + let tokenData: Record = {}; - try { - tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - throw new UnauthorizedError({ - message: `Access denied: ${error.message}` + if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { + let client: JwksClient; + if (identityJwtAuth.jwksUrl.includes("https:")) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + } else { + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl }); } - throw error; - } - } else { - const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) - .toString() - .split(","); + const { kid } = decodedToken.header as { kid: string }; + const jwtSigningKey = await client.getSigningKey(kid); - const errors: string[] = []; - let isMatchAnyKey = false; - for (const publicKey of decryptedPublicKeys) { try { - tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record; - isMatchAnyKey = true; + tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { - errors.push(error.message); + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } + } else { + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + const errors: string[] = []; + let isMatchAnyKey = false; + for (const publicKey of decryptedPublicKeys) { + try { + tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record; + isMatchAnyKey = true; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + errors.push(error.message); + } } } - } - if (!isMatchAnyKey) { - throw new UnauthorizedError({ - message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` - }); - } - } - - if (identityJwtAuth.boundIssuer) { - if (tokenData.iss !== identityJwtAuth.boundIssuer) { - throw new ForbiddenRequestError({ - message: "Access denied: issuer mismatch" - }); - } - } - - if (identityJwtAuth.boundSubject) { - if (!tokenData.sub) { - throw new UnauthorizedError({ - message: "Access denied: token has no subject field" - }); - } - - if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { - throw new ForbiddenRequestError({ - message: "Access denied: subject not allowed" - }); - } - } - - if (identityJwtAuth.boundAudiences) { - if (!tokenData.aud) { - throw new UnauthorizedError({ - message: "Access denied: token has no audience field" - }); - } - - if ( - !identityJwtAuth.boundAudiences - .split(", ") - .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) - ) { - throw new UnauthorizedError({ - message: "Access denied: token audience not allowed" - }); - } - } - - if (identityJwtAuth.boundClaims) { - Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { - const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); - - if (!value) { + if (!isMatchAnyKey) { throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` + message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` + }); + } + } + + if (identityJwtAuth.boundIssuer) { + if (tokenData.iss !== identityJwtAuth.boundIssuer) { + throw new ForbiddenRequestError({ + message: "Access denied: issuer mismatch" + }); + } + } + + if (identityJwtAuth.boundSubject) { + if (!tokenData.sub) { + throw new UnauthorizedError({ + message: "Access denied: token has no subject field" }); } - // handle both single and multi-valued claims - if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { - throw new UnauthorizedError({ - message: `Access denied: claim mismatch for field ${claimKey}` + if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: subject not allowed" }); } + } + + if (identityJwtAuth.boundAudiences) { + if (!tokenData.aud) { + throw new UnauthorizedError({ + message: "Access denied: token has no audience field" + }); + } + + if ( + !identityJwtAuth.boundAudiences + .split(", ") + .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: token audience not allowed" + }); + } + } + + if (identityJwtAuth.boundClaims) { + Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + const value = getValueByDot(tokenData, claimKey); + + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { + throw new UnauthorizedError({ + message: `Access denied: claim mismatch for field ${claimKey}` + }); + } + }); + } + + const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityJwtAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.JWT_AUTH + }, + tx + ); + + return newToken; }); - } - const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityJwtAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityJwtAuth.accessTokenTTL, - accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.JWT_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityJwtAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityJwtAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityJwtAuth, identityAccessToken, identity }; + return { accessToken, identityJwtAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityJwtAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachJwtAuth = async ({ diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 8a8fd50915..b633dc4333 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios, { AxiosError } from "axios"; import https from "https"; import RE2 from "re2"; @@ -37,6 +38,7 @@ import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -182,6 +184,7 @@ export const identityKubernetesAuthServiceFactory = ({ }; const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const appCfg = getConfig(); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { throw new NotFoundError({ @@ -192,294 +195,328 @@ export const identityKubernetesAuthServiceFactory = ({ const identity = await identityDAL.findById(identityKubernetesAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); + const org = await orgDAL.findById(identity.orgId); - let caCert = ""; - if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { - caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); - } + try { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId + }); - const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { - logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); - - if (!host || !identityKubernetesAuth.kubernetesHost) { - throw new BadRequestError({ - message: "Kubernetes host is required when token review mode is set to API" - }); + let caCert = ""; + if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { + caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); } - let tokenReviewerJwt = ""; - if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { - tokenReviewerJwt = decryptor({ - cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt - }).toString(); - } else { - // if no token reviewer is provided means the incoming token has to act as reviewer - tokenReviewerJwt = serviceAccountJwt; - } + const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); - let servername = identityKubernetesAuth.kubernetesHost; - if (servername.startsWith("https://") || servername.startsWith("http://")) { - servername = new RE2("^https?:\\/\\/").replace(servername, ""); - } + if (!host || !identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to API" + }); + } - // get the last colon index, if it has a port, remove it, including the colon - const lastColonIndex = servername.lastIndexOf(":"); - if (lastColonIndex !== -1) { - servername = servername.substring(0, lastColonIndex); - } + let tokenReviewerJwt = ""; + if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { + tokenReviewerJwt = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt + }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; + } - const baseUrl = port ? `${host}:${port}` : host; + let servername = identityKubernetesAuth.kubernetesHost; + if (servername.startsWith("https://") || servername.startsWith("http://")) { + servername = new RE2("^https?:\\/\\/").replace(servername, ""); + } - const res = await axios - .post( - `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenReviewerJwt}` + // get the last colon index, if it has a port, remove it, including the colon + const lastColonIndex = servername.lastIndexOf(":"); + if (lastColonIndex !== -1) { + servername = servername.substring(0, lastColonIndex); + } + + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience + ? { audiences: [identityKubernetesAuth.allowedAudience] } + : {}) + } }, - signal: AbortSignal.timeout(10000), - timeout: 10000, - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: Boolean(caCert), - servername - }) - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } - throw err; - }); - - return res.data; - }; - - const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => { - logger.info( - { - host, - port - }, - "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" - ); - - const res = await axios - .post( - `${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount - }, - signal: AbortSignal.timeout(10000), - timeout: 10000 - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - let { message } = err?.response?.data as unknown as { message?: string }; - - if (!message && typeof err.response.data === "string") { - message = err.response.data; - } - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } - throw err; - }); - - return res.data; - }; - - let data: TCreateTokenReviewResponse | undefined; - - if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { - if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { - throw new BadRequestError({ - message: "Gateway ID is required when token review mode is set to Gateway" - }); - } - - data = await $gatewayProxyWrapper( - { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, - reviewTokenThroughGateway: true - }, - tokenReviewCallbackThroughGateway - ); - } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { - if (!identityKubernetesAuth.kubernetesHost) { - throw new BadRequestError({ - message: "Kubernetes host is required when token review mode is set to API" - }); - } - - let { kubernetesHost } = identityKubernetesAuth; - if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { - kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); - } - - const [k8sHost, k8sPort] = kubernetesHost.split(":"); - - data = - identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id - ? await $gatewayProxyWrapper( - { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443, - reviewTokenThroughGateway: false + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` }, - tokenReviewCallbackRaw - ) - : await tokenReviewCallbackRaw(); - } else { - throw new BadRequestError({ - message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` - }); - } + signal: AbortSignal.timeout(10000), + timeout: 10000, + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: Boolean(caCert), + servername + }) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + const { message } = err?.response?.data as unknown as { message?: string }; - if (!data) { - throw new BadRequestError({ - message: "Failed to review token" - }); - } + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); - if ("error" in data.status) - throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); + return res.data; + }; - // check the response to determine if the token is valid - if (!(data.status && data.status.authenticated)) - throw new UnauthorizedError({ - message: "Kubernetes token not authenticated", - name: "KubernetesTokenReviewError" + const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); + + const res = await axios + .post( + `${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience + ? { audiences: [identityKubernetesAuth.allowedAudience] } + : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount + }, + signal: AbortSignal.timeout(10000), + timeout: 10000 + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + let { message } = err?.response?.data as unknown as { message?: string }; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + return res.data; + }; + + let data: TCreateTokenReviewResponse | undefined; + + if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { + throw new BadRequestError({ + message: "Gateway ID is required when token review mode is set to Gateway" + }); + } + + data = await $gatewayProxyWrapper( + { + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + if (!identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to API" + }); + } + + let { kubernetesHost } = identityKubernetesAuth; + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); + + data = + identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id + ? await $gatewayProxyWrapper( + { + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); + } else { + throw new BadRequestError({ + message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` + }); + } + + if (!data) { + throw new BadRequestError({ + message: "Failed to review token" + }); + } + + if ("error" in data.status) + throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); + + // check the response to determine if the token is valid + if (!(data.status && data.status.authenticated)) + throw new UnauthorizedError({ + message: "Kubernetes token not authenticated", + name: "KubernetesTokenReviewError" + }); + + const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); + + if (identityKubernetesAuth.allowedNamespaces) { + // validate if [targetNamespace] is in the list of allowed namespaces + + const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces + .split(",") + .map((namespace) => namespace.trim()) + .some((namespace) => namespace === targetNamespace); + + if (!isNamespaceAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s namespace not allowed." + }); + } + + if (identityKubernetesAuth.allowedNames) { + // validate if [targetName] is in the list of allowed names + + const isNameAllowed = identityKubernetesAuth.allowedNames + .split(",") + .map((name) => name.trim()) + .some((name) => name === targetName); + + if (!isNameAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s name not allowed." + }); + } + + if (identityKubernetesAuth.allowedAudience) { + // validate if [audience] is in the list of allowed audiences + const isAudienceAllowed = data.status.audiences.some( + (audience) => audience === identityKubernetesAuth.allowedAudience + ); + + if (!isAudienceAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s audience not allowed." + }); + } + + const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityKubernetesAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.KUBERNETES_AUTH + }, + tx + ); + return newToken; }); - const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); - - if (identityKubernetesAuth.allowedNamespaces) { - // validate if [targetNamespace] is in the list of allowed namespaces - - const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces - .split(",") - .map((namespace) => namespace.trim()) - .some((namespace) => namespace === targetNamespace); - - if (!isNamespaceAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s namespace not allowed." - }); - } - - if (identityKubernetesAuth.allowedNames) { - // validate if [targetName] is in the list of allowed names - - const isNameAllowed = identityKubernetesAuth.allowedNames - .split(",") - .map((name) => name.trim()) - .some((name) => name === targetName); - - if (!isNameAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s name not allowed." - }); - } - - if (identityKubernetesAuth.allowedAudience) { - // validate if [audience] is in the list of allowed audiences - const isAudienceAllowed = data.status.audiences.some( - (audience) => audience === identityKubernetesAuth.allowedAudience - ); - - if (!isAudienceAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s audience not allowed." - }); - } - - const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityKubernetesAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityKubernetesAuth.accessTokenTTL, - accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.KUBERNETES_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + kubernetes: { + namespace: targetNamespace, + name: targetName + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityKubernetesAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - kubernetes: { - namespace: targetNamespace, - name: targetName - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityKubernetesAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; + return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityKubernetesAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachKubernetesAuth = async ({ diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 272e45c4e4..d327dabeed 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import slugify from "@sindresorhus/slugify"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; @@ -29,6 +30,7 @@ import { } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -151,6 +153,7 @@ export const identityLdapAuthServiceFactory = ({ }; const login = async ({ identityId }: TLoginLdapAuthDTO) => { + const appCfg = getConfig(); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { @@ -162,6 +165,7 @@ export const identityLdapAuthServiceFactory = ({ const identity = await identityDAL.findById(identityLdapAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + const org = await orgDAL.findById(identity.orgId); const plan = await licenseService.getPlan(identity.orgId); if (!plan.ldap) { throw new BadRequestError({ @@ -170,44 +174,72 @@ export const identityLdapAuthServiceFactory = ({ }); } - const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + try { + const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityLdapAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.LDAP_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityLdapAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityLdapAuth.accessTokenTTL, - accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.LDAP_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityLdapAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityLdapAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityLdapAuth, identityAccessToken, identity }; + return { accessToken, identityLdapAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityLdapAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachLdapAuth = async ({ diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index 6d7f0c4d3d..c75abc76bb 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import RE2 from "re2"; @@ -23,6 +24,7 @@ import { } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -63,6 +65,7 @@ export const identityOciAuthServiceFactory = ({ orgDAL }: TIdentityOciAuthServiceFactoryDep) => { const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { + const appCfg = getConfig(); const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); if (!identityOciAuth) { throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); @@ -71,80 +74,109 @@ export const identityOciAuthServiceFactory = ({ const identity = await identityDAL.findById(identityOciAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. - if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { - throw new BadRequestError({ - message: "Invalid OCI host format. Expected format: identity..oraclecloud.com" - }); - } - - const { data } = await request - .get(`https://${headers.host}/20160918/users/${userOcid}`, { - headers - }) - .catch((err: AxiosError) => { - logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud"); - throw err; - }); - - if (data.compartmentId !== identityOciAuth.tenancyOcid) { - throw new UnauthorizedError({ - message: "Access denied: OCI account isn't part of tenancy." - }); - } - - if (identityOciAuth.allowedUsernames) { - const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name); - - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: OCI account username not allowed." + const org = await orgDAL.findById(identity.orgId); + try { + // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. + if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { + throw new BadRequestError({ + message: "Invalid OCI host format. Expected format: identity..oraclecloud.com" }); - } + } - // Generate the token - const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const { data } = await request + .get(`https://${headers.host}/20160918/users/${userOcid}`, { + headers + }) + .catch((err: AxiosError) => { + logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud"); + throw err; + }); + + if (data.compartmentId !== identityOciAuth.tenancyOcid) { + throw new UnauthorizedError({ + message: "Access denied: OCI account isn't part of tenancy." + }); + } + + if (identityOciAuth.allowedUsernames) { + const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: OCI account username not allowed." + }); + } + + // Generate the token + const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOciAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.OCI_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityOciAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityOciAuth.accessTokenTTL, - accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OCI_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityOciAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOciAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityOciAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityOciAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOciAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachOciAuth = async ({ diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 628b69f14d..a03beeb3b4 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios from "axios"; import https from "https"; import jwt from "jsonwebtoken"; @@ -22,6 +23,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -67,6 +69,7 @@ export const identityOidcAuthServiceFactory = ({ orgDAL }: TIdentityOidcAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { + const appCfg = getConfig(); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); if (!identityOidcAuth) { throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); @@ -75,151 +78,180 @@ export const identityOidcAuthServiceFactory = ({ const identity = await identityDAL.findById(identityOidcAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); - - let caCert = ""; - if (identityOidcAuth.encryptedCaCertificate) { - caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString(); - } - - const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); - const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( - `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, - { - httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined - } - ); - const jwksUri = discoveryDoc.jwks_uri; - - const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true }); - if (!decodedToken) { - throw new UnauthorizedError({ - message: "Invalid JWT" - }); - } - - const client = new JwksClient({ - jwksUri, - requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined - }); - - const { kid } = decodedToken.header as { kid: string }; - const oidcSigningKey = await client.getSigningKey(kid); - - let tokenData: Record; + const org = await orgDAL.findById(identity.orgId); try { - tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId + }); + + let caCert = ""; + if (identityOidcAuth.encryptedCaCertificate) { + caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString(); + } + + const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); + const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( + `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, + { + httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined + } + ); + const jwksUri = discoveryDoc.jwks_uri; + + const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true }); + if (!decodedToken) { throw new UnauthorizedError({ - message: `Access denied: ${error.message}` + message: "Invalid JWT" + }); + } + + const client = new JwksClient({ + jwksUri, + requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined + }); + + const { kid } = decodedToken.header as { kid: string }; + const oidcSigningKey = await client.getSigningKey(kid); + + let tokenData: Record; + try { + tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + throw error; + } + + if (identityOidcAuth.boundSubject) { + if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: OIDC subject not allowed." + }); + } + } + + if (identityOidcAuth.boundAudiences) { + if ( + !identityOidcAuth.boundAudiences + .split(", ") + .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: OIDC audience not allowed." + }); + } + } + + if (identityOidcAuth.boundClaims) { + Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; + const value = getValueByDot(tokenData, claimKey); + + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) { + throw new UnauthorizedError({ + message: "Access denied: OIDC claim not allowed." + }); + } + }); + } + + const filteredClaims: Record = {}; + if (identityOidcAuth.claimMetadataMapping) { + Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { + const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; + const value = getValueByDot(tokenData, claimKey); + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + filteredClaims[permissionKey] = value.toString(); + }); + } + + const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOidcAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOidcAuth.accessTokenTTL, + accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.OIDC_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( + { + identityId: identityOidcAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + oidc: { + claims: filteredClaims + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOidcAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOidcAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") }); } throw error; } - - if (identityOidcAuth.boundSubject) { - if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { - throw new ForbiddenRequestError({ - message: "Access denied: OIDC subject not allowed." - }); - } - } - - if (identityOidcAuth.boundAudiences) { - if ( - !identityOidcAuth.boundAudiences - .split(", ") - .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) - ) { - throw new UnauthorizedError({ - message: "Access denied: OIDC audience not allowed." - }); - } - } - - if (identityOidcAuth.boundClaims) { - Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { - const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); - - if (!value) { - throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` - }); - } - - // handle both single and multi-valued claims - if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) { - throw new UnauthorizedError({ - message: "Access denied: OIDC claim not allowed." - }); - } - }); - } - - const filteredClaims: Record = {}; - if (identityOidcAuth.claimMetadataMapping) { - Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { - const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getValueByDot(tokenData, claimKey); - if (!value) { - throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` - }); - } - filteredClaims[permissionKey] = value.toString(); - }); - } - - const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( - { - identityId: identityOidcAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityOidcAuth.accessTokenTTL, - accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OIDC_AUTH - }, - tx - ); - return newToken; - }); - - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityOidcAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - oidc: { - claims: filteredClaims - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; }; const attachOidcAuth = async ({ diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 24c82ccac0..60670d035f 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -19,6 +20,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -27,6 +29,7 @@ import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identit import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; @@ -42,6 +45,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { licenseService: Pick; permissionService: Pick; kmsService: Pick; + orgDAL: Pick; }; const parseSubjectDetails = (data: string) => { @@ -60,9 +64,11 @@ export const identityTlsCertAuthServiceFactory = ({ membershipIdentityDAL, licenseService, permissionService, - kmsService + kmsService, + orgDAL }: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { + const appCfg = getConfig(); const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); if (!identityTlsCertAuth) { throw new NotFoundError({ @@ -73,94 +79,124 @@ export const identityTlsCertAuthServiceFactory = ({ const identity = await identityDAL.findById(identityTlsCertAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); + const org = await orgDAL.findById(identity.orgId); - const caCertificate = decryptor({ - cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate - }).toString(); - - const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; - if (!leafCertificate) { - throw new BadRequestError({ message: "Missing client certificate" }); - } - - const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate); - const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate); - - const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); - if (!isValidCertificate) - throw new UnauthorizedError({ - message: "Access denied: Certificate not issued by the provided CA." + try { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId }); - if (new Date(clientCertificateX509.validTo) < new Date()) { - throw new UnauthorizedError({ - message: "Access denied: Certificate has expired." - }); - } + const caCertificate = decryptor({ + cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate + }).toString(); - if (new Date(clientCertificateX509.validFrom) > new Date()) { - throw new UnauthorizedError({ - message: "Access denied: Certificate not yet valid." - }); - } + const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; + if (!leafCertificate) { + throw new BadRequestError({ message: "Missing client certificate" }); + } - const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); - if (identityTlsCertAuth.allowedCommonNames) { - const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN); - if (!isValidCommonName) { + const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate); + const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate); + + const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); + if (!isValidCertificate) throw new UnauthorizedError({ - message: "Access denied: TLS Certificate Auth common name not allowed." + message: "Access denied: Certificate not issued by the provided CA." + }); + + if (new Date(clientCertificateX509.validTo) < new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate has expired." }); } - } - // Generate the token - const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + if (new Date(clientCertificateX509.validFrom) > new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate not yet valid." + }); + } + + const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); + if (identityTlsCertAuth.allowedCommonNames) { + const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN); + if (!isValidCommonName) { + throw new UnauthorizedError({ + message: "Access denied: TLS Certificate Auth common name not allowed." + }); + } + } + + // Generate the token + const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityTlsCertAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.TLS_CERT_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityTlsCertAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityTlsCertAuth.accessTokenTTL, - accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.TLS_CERT_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityTlsCertAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityTlsCertAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityTlsCertAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityTlsCertAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityTlsCertAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({ diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 00ab1610d3..26bd016275 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -21,6 +22,7 @@ import { } from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -77,6 +79,7 @@ export const identityUaServiceFactory = ({ identityDAL }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { + const appCfg = getConfig(); const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { throw new UnauthorizedError({ @@ -84,196 +87,226 @@ export const identityUaServiceFactory = ({ }); } - checkIPAgainstBlocklist({ - ipAddress: ip, - trustedIps: identityUa.clientSecretTrustedIps as TIp[] - }); + const identity = await identityDAL.findById(identityUa.identityId); + const org = await orgDAL.findById(identity.orgId); - const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - - const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - - let lockout: LockoutObject | undefined; - if (lockoutRaw) { - lockout = JSON.parse(lockoutRaw) as LockoutObject; - } - - if (lockout && lockout.lockedOut) { - throw new UnauthorizedError({ - message: "This identity auth method is temporarily locked, please try again later" + try { + checkIPAgainstBlocklist({ + ipAddress: ip, + trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); - } - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecretInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); + const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; - for await (const info of clientSecretInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - if (isMatch) { - validClientSecretInfo = info; - break; + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; } - } - if (!validClientSecretInfo) { - if (identityUa.lockoutEnabled) { - let lock: Awaited> | undefined; - try { - lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { - retryCount: 3, - retryDelay: 300, - retryJitter: 100 - }); + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } - // Re-fetch the latest lockout data while holding the lock - const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); - if (lockoutRawNew) { - lockout = JSON.parse(lockoutRawNew) as LockoutObject; - } else { - lockout = { - lockedOut: false, - failedAttempts: 0 - }; - } + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); - if (lockout.lockedOut) { - throw new UnauthorizedError({ - message: "This identity auth method is temporarily locked, please try again later" - }); - } + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); - lockout.failedAttempts += 1; - if (lockout.failedAttempts >= identityUa.lockoutThreshold) { - lockout.lockedOut = true; - } - - await keyStore.setItemWithExpiry( - LOCKOUT_KEY, - lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, - JSON.stringify(lockout) - ); - } catch (e) { - if (lock === undefined) { - logger.info( - `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` - ); - throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); - } - throw e; - } finally { - if (lock) { - await lock.release(); - } + if (isMatch) { + validClientSecretInfo = info; + break; } } - throw new UnauthorizedError({ message: "Invalid credentials" }); - } else if (lockout) { - // If credentials are valid, clear any existing lockout record - await keyStore.deleteItem(LOCKOUT_KEY); - } + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + let lock: Awaited> | undefined; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + // Re-fetch the latest lockout data while holding the lock + const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRawNew) { + lockout = JSON.parse(lockoutRawNew) as LockoutObject; + } else { + lockout = { + lockedOut: false, + failedAttempts: 0 + }; + } - if (currentDate > expirationTime) { + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityUa.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, + JSON.stringify(lockout) + ); + } catch (e) { + if (lock === undefined) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } + throw e; + } finally { + if (lock) { + await lock.release(); + } + } + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + // If credentials are valid, clear any existing lockout record + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Access denied due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" + message: "Access denied due to client secret usage limit reached" }); } - } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; + + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityUa.identityId, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx + ); + + return newToken; }); - throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" - }); - } - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identity = await identityDAL.findById(identityUa.identityId); - const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityUa.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { - accessToken, - identityUa, - validClientSecretInfo, - identityAccessToken, - identity, - ...accessTokenTTLParams - }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identity, + ...accessTokenTTLParams + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityUa.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachUniversalAuth = async ({ diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index b665df4ed0..8f868978da 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -401,13 +401,6 @@ export const kmsServiceFactory = ({ const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); - const expectedByteLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyAlgorithm); - if (key.byteLength !== expectedByteLength) { - throw new BadRequestError({ - message: `Invalid key length for ${algorithm}. Expected ${expectedByteLength} bytes but got ${key.byteLength} bytes` - }); - } - const encryptedKeyMaterial = cipher.encrypt(key, ROOT_ENCRYPTION_KEY); const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { diff --git a/backend/src/services/membership-group/membership-group-service.ts b/backend/src/services/membership-group/membership-group-service.ts index 18f6b3ad11..0aedccd152 100644 --- a/backend/src/services/membership-group/membership-group-service.ts +++ b/backend/src/services/membership-group/membership-group-service.ts @@ -1,5 +1,15 @@ -import { AccessScope, ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { + AccessScope, + ProjectMembershipRole, + TableName, + TemporaryPermissionMode, + TMembershipRolesInsert +} from "@app/db/schemas"; +import { TAccessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TSecretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; +import { TSecretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; @@ -23,6 +33,10 @@ import { newProjectMembershipGroupFactory } from "./project/project-membership-g type TMembershipGroupServiceFactoryDep = { membershipGroupDAL: TMembershipGroupDALFactory; membershipRoleDAL: Pick; + accessApprovalPolicyDAL: Pick; + accessApprovalPolicyApproverDAL: Pick; + secretApprovalPolicyDAL: Pick; + secretApprovalPolicyApproverDAL: Pick; roleDAL: Pick; permissionService: TPermissionServiceFactory; orgDAL: TOrgDALFactory; @@ -33,6 +47,10 @@ export type TMembershipGroupServiceFactory = ReturnType policyId); + if (accessApprovalPolicyApprovers.length > 0) { + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ + $in: { + [`${TableName.AccessApprovalPolicy}.id` as "id"]: [...new Set(accessApprovalPolicyApproverGroupIds)] + }, + projectId: existingMembership.scopeProjectId ?? undefined, + deletedAt: null + }); + + if (accessApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "This group is assigned to an approval policy and cannot be deleted" + }); + } + } + + // check if group is assigned to any secret approval policy + const secretApprovalPolicyApprovers = await secretApprovalPolicyApproverDAL.find({ + approverGroupId: dto.selector.groupId + }); + const secretApprovalPolicyApproverGroupIds = secretApprovalPolicyApprovers.map(({ policyId }) => policyId); + if (secretApprovalPolicyApprovers.length > 0) { + const secretApprovalPolicies = await secretApprovalPolicyDAL.find({ + $in: { + [`${TableName.SecretApprovalPolicy}.id` as "id"]: [...new Set(secretApprovalPolicyApproverGroupIds)] + }, + projectId: existingMembership.scopeProjectId ?? undefined, + deletedAt: null + }); + if (secretApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "This group is assigned to a secret approval policy and cannot be deleted" + }); + } + } + const membershipDoc = await membershipGroupDAL.transaction(async (tx) => { await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); const doc = await membershipGroupDAL.deleteById(existingMembership.id, tx); diff --git a/backend/src/services/secret-sync/northflank/index.ts b/backend/src/services/secret-sync/northflank/index.ts new file mode 100644 index 0000000000..7fab276cfe --- /dev/null +++ b/backend/src/services/secret-sync/northflank/index.ts @@ -0,0 +1,4 @@ +export * from "./northflank-sync-constants"; +export * from "./northflank-sync-fns"; +export * from "./northflank-sync-schemas"; +export * from "./northflank-sync-types"; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts new file mode 100644 index 0000000000..d4b8512170 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const NORTHFLANK_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Northflank", + destination: SecretSync.Northflank, + connection: AppConnection.Northflank, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts new file mode 100644 index 0000000000..396aa9ac8f --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts @@ -0,0 +1,165 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import { TNorthflankSyncWithCredentials } from "./northflank-sync-types"; + +const NORTHFLANK_API_URL = "https://api.northflank.com"; + +const buildNorthflankAPIErrorMessage = (error: unknown): string => { + let errorMessage = "Northflank API returned an error."; + + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as AxiosError; + + if (axiosError.response?.data) { + // This is the shape of the error response from the Northflank API + const responseData = axiosError.response.data as { + error?: { message?: string; details?: Record }; + message?: string; + }; + const errorParts = []; + + if (responseData.error?.message) { + errorParts.push(responseData.error.message); + } else if (responseData.message) { + errorParts.push(responseData.message); + } + + if (responseData.error?.details) { + const { details } = responseData.error; + + // Flatten the details object into a string + Object.entries(details).forEach(([field, fieldErrors]) => { + if (Array.isArray(fieldErrors)) { + fieldErrors.forEach((fieldError) => errorParts.push(`${field}: ${fieldError}`)); + } else { + errorParts.push(`${field}: ${String(fieldErrors)}`); + } + }); + } + + errorMessage += ` ${errorParts.join(". ")}`; + } + } + + return errorMessage; +}; + +const getNorthflankSecrets = async (secretSync: TNorthflankSyncWithCredentials): Promise> => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + const { + data: { + data: { + secrets: { variables } + } + } + } = await request.get<{ + data: { + secrets: { + variables: Record; + }; + }; + }>(`${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}/details`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + return variables; + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to fetch Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +const updateNorthflankSecrets = async ( + secretSync: TNorthflankSyncWithCredentials, + variables: Record +): Promise => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.patch( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}`, + { + secrets: { + variables + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to update Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +export const NorthflankSyncFns = { + syncSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + const shouldKeep = + !secretMap[key] && // this prevents duplicates from infisical secrets, because we add all of them to the updateVariables in the next loop + (secretSync.syncOptions.disableSecretDeletion || + !matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)); + + if (shouldKeep) { + updatedVariables[key] = value; + } + } + + for (const [key, { value }] of Object.entries(secretMap)) { + updatedVariables[key] = value; + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + }, + + getSecrets: async (secretSync: TNorthflankSyncWithCredentials): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + return Object.fromEntries(Object.entries(northflankSecrets).map(([key, value]) => [key, { value }])); + }, + + removeSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + if (!(key in secretMap)) { + updatedVariables[key] = value; + } + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + } +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts new file mode 100644 index 0000000000..55cdeae232 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const NorthflankSyncDestinationConfigSchema = z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectId), + projectName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectName), + secretGroupId: z + .string() + .trim() + .min(1, "Secret Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupId), + secretGroupName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupName) +}); + +const NorthflankSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NorthflankSyncSchema = BaseSecretSyncSchema(SecretSync.Northflank, NorthflankSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const CreateNorthflankSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const UpdateNorthflankSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + destinationConfig: NorthflankSyncDestinationConfigSchema.optional() +}); + +export const NorthflankSyncListItemSchema = z.object({ + name: z.literal("Northflank"), + connection: z.literal(AppConnection.Northflank), + destination: z.literal(SecretSync.Northflank), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-types.ts b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts new file mode 100644 index 0000000000..019ae28438 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TNorthflankConnection } from "@app/services/app-connection/northflank"; + +import { + CreateNorthflankSyncSchema, + NorthflankSyncListItemSchema, + NorthflankSyncSchema +} from "./northflank-sync-schemas"; + +export type TNorthflankSyncListItem = z.infer; + +export type TNorthflankSync = z.infer; + +export type TNorthflankSyncInput = z.infer; + +export type TNorthflankSyncWithCredentials = TNorthflankSync & { + connection: TNorthflankConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index a9625b53f4..835a314ca1 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge", Chef = "chef" diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 46f53fda66..77845535f8 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -52,6 +52,7 @@ import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { LARAVEL_FORGE_SYNC_LIST_OPTION, LaravelForgeSyncFns } from "./laravel-forge"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; +import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -93,6 +94,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, + [SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION, [SecretSync.Chef]: CHEF_SYNC_LIST_OPTION @@ -279,6 +281,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: @@ -398,6 +402,9 @@ export const SecretSyncFns = { case SecretSync.Netlify: secretMap = await NetlifySyncFns.getSecrets(secretSync); break; + case SecretSync.Northflank: + secretMap = await NorthflankSyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -498,6 +505,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index e82d4f0779..86cb189c39 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,6 +32,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", + [SecretSync.Northflank]: "Northflank", [SecretSync.Bitbucket]: "Bitbucket", [SecretSync.LaravelForge]: "Laravel Forge", [SecretSync.Chef]: "Chef" @@ -67,6 +68,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge, [SecretSync.Chef]: AppConnection.Chef @@ -102,6 +104,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, + [SecretSync.Northflank]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, [SecretSync.LaravelForge]: SecretSyncPlanType.Regular, [SecretSync.Chef]: SecretSyncPlanType.Regular @@ -146,6 +149,7 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Northflank]: [], [SecretSync.Bitbucket]: [], [SecretSync.LaravelForge]: [], [SecretSync.Chef]: [] @@ -207,6 +211,7 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record el.slug) }); + recordSecretReadMetric({ + environment, + secretPath: path, + name: secretName + }); + // this will throw if the user doesn't have read value permission no matter what // because if its an expansion, it will fully depend on the value. const { expandSecretReferences } = expandSecretReferencesFactory({ diff --git a/docs/api-reference/endpoints/app-connections/northflank/available.mdx b/docs/api-reference/endpoints/app-connections/northflank/available.mdx new file mode 100644 index 0000000000..99626a9470 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/northflank/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/create.mdx b/docs/api-reference/endpoints/app-connections/northflank/create.mdx new file mode 100644 index 0000000000..0ec6527764 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/northflank" +--- + + + Check out the configuration docs for [Northflank Connections](/integrations/app-connections/northflank) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/northflank/delete.mdx b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx new file mode 100644 index 0000000000..1c3518ea5f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx new file mode 100644 index 0000000000..e6e24e39ea --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx new file mode 100644 index 0000000000..e3ca69b314 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/northflank/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/list.mdx b/docs/api-reference/endpoints/app-connections/northflank/list.mdx new file mode 100644 index 0000000000..fbaf08ea6d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/northflank" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/update.mdx b/docs/api-reference/endpoints/app-connections/northflank/update.mdx new file mode 100644 index 0000000000..4554bd7146 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx new file mode 100644 index 0000000000..47ae2f4b4d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx new file mode 100644 index 0000000000..12e5c6e449 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx new file mode 100644 index 0000000000..7cad153e83 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx new file mode 100644 index 0000000000..487462dde9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/northflank/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx new file mode 100644 index 0000000000..0294f4dade --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx new file mode 100644 index 0000000000..a1926710ff --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx new file mode 100644 index 0000000000..161ddac54e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx new file mode 100644 index 0000000000..82ce1a96a7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx new file mode 100644 index 0000000000..2f743e82f2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 04d6d2aecf..6d8eb04ccb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -131,6 +131,7 @@ "integrations/app-connections/mssql", "integrations/app-connections/mysql", "integrations/app-connections/netlify", + "integrations/app-connections/northflank", "integrations/app-connections/oci", "integrations/app-connections/okta", "integrations/app-connections/oracledb", @@ -554,6 +555,7 @@ "integrations/secret-syncs/humanitec", "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", + "integrations/secret-syncs/northflank", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", "integrations/secret-syncs/render", @@ -1862,6 +1864,18 @@ "api-reference/endpoints/app-connections/netlify/delete" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/app-connections/northflank/list", + "api-reference/endpoints/app-connections/northflank/available", + "api-reference/endpoints/app-connections/northflank/get-by-id", + "api-reference/endpoints/app-connections/northflank/get-by-name", + "api-reference/endpoints/app-connections/northflank/create", + "api-reference/endpoints/app-connections/northflank/update", + "api-reference/endpoints/app-connections/northflank/delete" + ] + }, { "group": "OCI", "pages": [ @@ -2335,6 +2349,20 @@ "api-reference/endpoints/secret-syncs/netlify/remove-secrets" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/secret-syncs/northflank/list", + "api-reference/endpoints/secret-syncs/northflank/get-by-id", + "api-reference/endpoints/secret-syncs/northflank/get-by-name", + "api-reference/endpoints/secret-syncs/northflank/create", + "api-reference/endpoints/secret-syncs/northflank/update", + "api-reference/endpoints/secret-syncs/northflank/delete", + "api-reference/endpoints/secret-syncs/northflank/sync-secrets", + "api-reference/endpoints/secret-syncs/northflank/import-secrets", + "api-reference/endpoints/secret-syncs/northflank/remove-secrets" + ] + }, { "group": "OCI", "pages": [ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-form.png b/docs/images/app-connections/northflank/northflank-app-connection-form.png new file mode 100644 index 0000000000..2263465174 Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-form.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-generated.png b/docs/images/app-connections/northflank/northflank-app-connection-generated.png new file mode 100644 index 0000000000..89038637aa Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-generated.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-option.png b/docs/images/app-connections/northflank/northflank-app-connection-option.png new file mode 100644 index 0000000000..17106494aa Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-option.png differ diff --git a/docs/images/app-connections/northflank/step-1.png b/docs/images/app-connections/northflank/step-1.png new file mode 100644 index 0000000000..fe97a4a307 Binary files /dev/null and b/docs/images/app-connections/northflank/step-1.png differ diff --git a/docs/images/app-connections/northflank/step-2.png b/docs/images/app-connections/northflank/step-2.png new file mode 100644 index 0000000000..e77d1e7409 Binary files /dev/null and b/docs/images/app-connections/northflank/step-2.png differ diff --git a/docs/images/app-connections/northflank/step-3.png b/docs/images/app-connections/northflank/step-3.png new file mode 100644 index 0000000000..739c4e48b7 Binary files /dev/null and b/docs/images/app-connections/northflank/step-3.png differ diff --git a/docs/images/app-connections/northflank/step-4-1.png b/docs/images/app-connections/northflank/step-4-1.png new file mode 100644 index 0000000000..cd4a5dfaf5 Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-1.png differ diff --git a/docs/images/app-connections/northflank/step-4-2.png b/docs/images/app-connections/northflank/step-4-2.png new file mode 100644 index 0000000000..52789f9d13 Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-2.png differ diff --git a/docs/images/app-connections/northflank/step-5.png b/docs/images/app-connections/northflank/step-5.png new file mode 100644 index 0000000000..d8e9c817be Binary files /dev/null and b/docs/images/app-connections/northflank/step-5.png differ diff --git a/docs/images/app-connections/northflank/step-6.png b/docs/images/app-connections/northflank/step-6.png new file mode 100644 index 0000000000..457a60cdf3 Binary files /dev/null and b/docs/images/app-connections/northflank/step-6.png differ diff --git a/docs/images/app-connections/northflank/step-7.png b/docs/images/app-connections/northflank/step-7.png new file mode 100644 index 0000000000..76480665f4 Binary files /dev/null and b/docs/images/app-connections/northflank/step-7.png differ diff --git a/docs/images/secret-syncs/northflank/configure-destination.png b/docs/images/secret-syncs/northflank/configure-destination.png new file mode 100644 index 0000000000..08dcb96bf9 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-destination.png differ diff --git a/docs/images/secret-syncs/northflank/configure-details.png b/docs/images/secret-syncs/northflank/configure-details.png new file mode 100644 index 0000000000..edbfa0dac9 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-details.png differ diff --git a/docs/images/secret-syncs/northflank/configure-source.png b/docs/images/secret-syncs/northflank/configure-source.png new file mode 100644 index 0000000000..530613f032 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-source.png differ diff --git a/docs/images/secret-syncs/northflank/configure-sync-options.png b/docs/images/secret-syncs/northflank/configure-sync-options.png new file mode 100644 index 0000000000..6e03b1f5f0 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/northflank/review-configuration.png b/docs/images/secret-syncs/northflank/review-configuration.png new file mode 100644 index 0000000000..59df97a80e Binary files /dev/null and b/docs/images/secret-syncs/northflank/review-configuration.png differ diff --git a/docs/images/secret-syncs/northflank/select-option.png b/docs/images/secret-syncs/northflank/select-option.png new file mode 100644 index 0000000000..0ee9ea2fa3 Binary files /dev/null and b/docs/images/secret-syncs/northflank/select-option.png differ diff --git a/docs/images/secret-syncs/northflank/sync-created.png b/docs/images/secret-syncs/northflank/sync-created.png new file mode 100644 index 0000000000..388d78f4de Binary files /dev/null and b/docs/images/secret-syncs/northflank/sync-created.png differ diff --git a/docs/integrations/app-connections/northflank.mdx b/docs/integrations/app-connections/northflank.mdx new file mode 100644 index 0000000000..0435c3e950 --- /dev/null +++ b/docs/integrations/app-connections/northflank.mdx @@ -0,0 +1,125 @@ +--- +title: "Northflank Connection" +description: "Learn how to configure a Northflank Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://northflank.com/docs/v1/api/use-the-api) to connect with Northflank. + + + Infisical recommends creating a specific API role for the app connection and only giving access to projects that will use the integration. + + +## Create a Northflank API Token + + + + Navigate to your team page and click **Create token**. + + ![Create API Role](/images/app-connections/northflank/step-1.png) + + Click on **Create API role**. + + ![Create API Role](/images/app-connections/northflank/step-2.png) + + Select all the projects you want this role to have access to, or leave this unchecked if you want to give access to all projects. + + ![Create API Role](/images/app-connections/northflank/step-3.png) + + Add the **Projects** -> **Manage** -> **Read** permission. + + ![Create API Role](/images/app-connections/northflank/step-4-1.png) + + Add the **Config & Secrets** -> **Secret Groups** -> **List**, **Update** and **Read Values** permissions. + + ![Create API Role](/images/app-connections/northflank/step-4-2.png) + + Scroll to the bottom and save the API role. + + + Click on the **API** -> **Tokens** menu on the left and then click the **Create API token** button. + + ![Create API Token](/images/app-connections/northflank/step-5.png) + + Give a name to the API token and click the **Use role** button for the new API role you just created. + + ![Create API Token](/images/app-connections/northflank/step-6.png) + + Click the **View API token** icon to view and copy your token. + + ![Create API Token](/images/app-connections/northflank/step-7.png) + + + +## Create a Northflank Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Northflank Connection** from the list of integrations. + + ![Select Northflank Connection](/images/app-connections/northflank/northflank-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Northflank Connection Modal](/images/app-connections/northflank/northflank-app-connection-form.png) + + + After submitting the form, your **Northflank Connection** will be successfully created and ready to use with your Infisical project. + + ![Northflank Connection Created](/images/app-connections/northflank/northflank-app-connection-generated.png) + + + + + + To create a Northflank Connection via API, send a request to the [Create Northflank Connection](/api-reference/endpoints/app-connections/northflank/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-connection", + "method": "api-token", + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-northflank-connection", + "description": null, + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-01-23T10:15:00.000Z", + "updatedAt": "2025-01-23T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "northflank", + "method": "api-token", + "credentials": {} + } + } + ``` + + \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index d5d48598be..b7ecf5815a 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -63,6 +63,7 @@ The Infisical Agent Injector supports the following annotations: - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. + - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. diff --git a/docs/integrations/platforms/kubernetes/overview.mdx b/docs/integrations/platforms/kubernetes/overview.mdx index ce9afcced4..71ae05c46f 100644 --- a/docs/integrations/platforms/kubernetes/overview.mdx +++ b/docs/integrations/platforms/kubernetes/overview.mdx @@ -41,6 +41,29 @@ If you require stronger isolation and stricter access controls, a namespace-scop ```bash helm install --generate-name infisical-helm-charts/secrets-operator ``` + + + By default a service account is created for the operator based on the operator release name. + You can bring your own service account by setting `controllerManager.serviceAccount.create` to `false` and setting `controllerManager.serviceAccount.name` to the name of the service account you want to use in your values.yaml file. + + Example values.yaml file: + + ```yaml values.yaml + controllerManager: + serviceAccount: + create: false + name: my-service-account + # other values... + ``` + + + Please note that if you set `controllerManager.serviceAccount.create` to `false`, the service account needs to already exist in the namespace you are installing the operator in. + + + + Custom service accounts are supported in chart version `0.10.11` and above. Please upgrade your helm chart to `0.10.11` or above before attempting to use custom service accounts. + + The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. This is useful for: @@ -67,6 +90,29 @@ If you require stronger isolation and stricter access controls, a namespace-scop --set installCRDs=false ``` + + By default a service account is created for the operator based on the operator release name. + You can bring your own service account by setting `controllerManager.serviceAccount.create` to `false` and setting `controllerManager.serviceAccount.name` to the name of the service account you want to use in your values.yaml file. + + Example values.yaml file: + + ```yaml values.yaml + controllerManager: + serviceAccount: + create: false + name: my-service-account + # other values... + ``` + + + Please note that if you set `controllerManager.serviceAccount.create` to `false`, the service account needs to already exist in the namespace you are installing the operator in. + + + + Custom service accounts are supported in chart version `0.10.11` and above. Please upgrade your helm chart to `0.10.11` or above before attempting to use custom service accounts. + + + When scoped to a namespace, the operator will: - Only watch InfisicalSecrets in the specified namespace @@ -158,14 +204,17 @@ The Infisical Secrets Operator integrates with the [Sprig library](https://githu ## Global configuration -To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. -For example, you can configure all `InfisicalSecret` instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. +To configure global settings that will apply to all CRD instances (`InfisicalSecret`, `InfisicalPushSecret`, and `InfisicalDynamicSecret`), you can define these configurations in a Kubernetes ConfigMap. +For example, you can configure all CRD instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. ### Available global properties | Property | Description | Default value | | -------- | --------------------------------------------------------------------------------- | ----------------------------- | -| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api | +| hostAPI | If `hostAPI` in a CRD instance is left empty, this value will be used | https://app.infisical.com/api | +| tls.caRef.secretName | If `tls.caRef.secretName` in a CRD instance is left empty, this value will be used | - | +| tls.caRef.secretNamespace | If `tls.caRef.secretNamespace` in a CRD instance is left empty, this value will be used | - | +| tls.caRef.key | If `tls.caRef.key` in a CRD instance is left empty, this value will be used | - | ### Applying global configurations @@ -185,6 +234,9 @@ metadata: namespace: infisical-operator-system data: hostAPI: https://example.com/api # <-- global hostAPI + tls.caRef.secretName: custom-ca-certificate # <-- global TLS CA secret name + tls.caRef.secretNamespace: default # <-- global TLS CA secret namespace + tls.caRef.key: ca.crt # <-- global TLS CA secret key ``` Then apply this change via kubectl by running the following diff --git a/docs/integrations/secret-syncs/northflank.mdx b/docs/integrations/secret-syncs/northflank.mdx new file mode 100644 index 0000000000..66075c9670 --- /dev/null +++ b/docs/integrations/secret-syncs/northflank.mdx @@ -0,0 +1,160 @@ +--- +title: "Northflank Sync" +description: "Learn how to configure a Northflank Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Northflank Connection](/integrations/app-connections/northflank) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Northflank](/images/secret-syncs/northflank/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/northflank/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/northflank/configure-destination.png) + + - **Northflank Connection**: The Northflank Connection to authenticate with. + - **Project**: The Northflank project to sync secrets to. + - **Secret Group**: The Northflank secret group to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/northflank/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Destination Secrets - Prioritize Infisical Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Infisical over Northflank when keys conflict. + - **Import Destination Secrets - Prioritize Northflank Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Northflank over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Northflank Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/northflank/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Northflank Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/northflank/review-configuration.png) + + + If enabled, your Northflank Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/northflank/sync-created.png) + + + + + To create a **Northflank Sync**, make an API request to the [Create Northflank Sync](/api-reference/endpoints/secret-syncs/northflank/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}" + }, + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + }' + ``` + + ### Sample response + + ```json Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-northflank-sync", + "description": "an example sync", + "isAutoSyncEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "northflank", + "name": "my-northflank-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "northflank", + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + } + } + ``` + + \ No newline at end of file diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 066ecc060f..670e0975ab 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -72,6 +72,7 @@ The SDK methods are organized into the following high-level categories: 1. `auth`: Handles authentication methods. 2. `secrets`: Manages CRUD operations for secrets. 3. `kms`: Perform cryptographic operations with Infisical KMS. +4. `folders`: Manages folder-related operations. ### `auth` @@ -415,4 +416,66 @@ decrypted_data = client.kms.decrypt_data( - `ciphertext` (str): The ciphertext returned from the encrypt operation. **Returns:** -- `str`: The base64 encoded plaintext. \ No newline at end of file +- `str`: The base64 encoded plaintext. + +### `folders` + +This sub-class handles operations related to folders: + +#### List Folders + +```python +folders = client.folders.list_folders( + project_id="", + environment_slug="dev", + path="/", + recursive=False, # Optional + last_secret_modified=None # Optional +) +``` + +**Parameters:** +- `project_id` (str): The ID of your project. +- `environment_slug` (str): The environment in which to list folders. +- `path` (str): The path to list folders from. +- `recursive` (bool, optional): Whether to list folders recursively from the specified path and downwards. Defaults to `False`. +- `last_secret_modified` (datetime, optional): The timestamp used to filter folders with secrets modified after the specified date. Defaults to `None`. + +**Returns:** +- `ListFoldersResponse`: The response containing the list of folders. + +#### Create Folder + +```python +new_folder = client.folders.create_folder( + name="my-folder", + environment_slug="dev", + project_id="", + path="/", # Optional + description=None # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the folder to create. +- `environment_slug` (str): The slug of the environment to create the folder in. +- `project_id` (str): The ID of your project to create the folder in. +- `path` (str, optional): The path to create the folder in. Defaults to `/`. +- `description` (str, optional): An optional description label for the folder. Defaults to `None`. + +**Returns:** +- `CreateFolderResponseItem`: The response containing the created folder. + +#### Get Folder by ID + +```python +folder = client.folders.get_folder_by_id( + id="" +) +``` + +**Parameters:** +- `id` (str): The ID of the folder to retrieve. + +**Returns:** +- `SingleFolderResponseItem`: The response containing the folder details. \ No newline at end of file diff --git a/docs/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx index 1c2d057026..b23c51b270 100644 --- a/docs/self-hosting/guides/monitoring-telemetry.mdx +++ b/docs/self-hosting/guides/monitoring-telemetry.mdx @@ -319,80 +319,137 @@ helm install otel-collector open-telemetry/opentelemetry-collector \ --set config.exporters.prometheus.endpoint=0.0.0.0:8889 ``` -## Alternative Backends - -Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus: - -### Cloud-Native Examples - -```yaml -# Add to your otel-collector-config.yaml exporters section -exporters: - # AWS CloudWatch - awsemf: - region: us-west-2 - log_group_name: /aws/emf/infisical - log_stream_name: metrics - - # Google Cloud Monitoring - googlecloud: - project_id: your-project-id - - # Azure Monitor - azuremonitor: - connection_string: "your-connection-string" - - # Datadog - datadog: - api: - key: "your-api-key" - site: "datadoghq.com" - - # New Relic - newrelic: - apikey: "your-api-key" - host_override: "otlp.nr-data.net" -``` - -### Multi-Backend Configuration - -```yaml -service: - pipelines: - metrics: - receivers: [otlp] - processors: [batch] - exporters: [prometheus, awsemf, datadog] # Send to multiple backends -``` - -## Setting Up Grafana - -1. **Access Grafana**: Navigate to your Grafana instance -2. **Login**: Use your configured credentials -3. **Add Prometheus Data Source**: - - Go to Configuration → Data Sources - - Click "Add data source" - - Select "Prometheus" - - Set URL to your Prometheus endpoint - - Click "Save & Test" - ## Available Metrics Infisical exposes the following key metrics in OpenTelemetry format: -### API Performance Metrics +### Core API Metrics -- `API_latency` - API request latency histogram in milliseconds +These metrics track all HTTP API requests to Infisical, including request counts, latency, and errors. Use these to monitor overall API health, identify performance bottlenecks, and track usage patterns across users and machine identities. - - **Labels**: `route`, `method`, `statusCode` - - **Example**: Monitor response times for specific endpoints +#### Total API Requests -- `API_errors` - API error count histogram - - **Labels**: `route`, `method`, `type`, `name` - - **Example**: Track error rates by endpoint and error type +- **Metric Name**: `infisical.http.server.request.count` +- **Type**: Counter +- **Unit**: `{request}` +- **Description**: Total number of API requests to Infisical (covers both human users and machine identities) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name (e.g., "Platform Engineering Team") + - `infisical.user.id` (string, optional): User ID if human user + - `infisical.user.email` (string, optional): User email (e.g., "jane.doe@cisco.com") + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name (e.g., "prod-k8s-operator") + - `infisical.auth.method` (string, optional): Auth method used + - `http.request.method` (string): HTTP method (GET, POST, PUT, DELETE) + - `http.route` (string): API endpoint route pattern + - `http.response.status_code` (int): HTTP status code + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + - `user_agent.original` (string, optional): User agent string + - `client.address` (string, optional): IP address + +#### Request Duration + +- **Metric Name**: `infisical.http.server.request.duration` +- **Type**: Histogram +- **Unit**: `s` (seconds) +- **Description**: API request latency +- **Buckets**: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human user + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name + - `http.request.method` (string): HTTP method + - `http.route` (string): API endpoint route pattern + - `http.response.status_code` (int): HTTP status code + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + +#### API Errors by Actor + +- **Metric Name**: `infisical.http.server.error.count` +- **Type**: Counter +- **Unit**: `{error}` +- **Description**: API errors grouped by actor (for identifying misconfigured services) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Identity ID if machine + - `infisical.identity.name` (string, optional): Identity name + - `http.route` (string): API endpoint where error occurred + - `http.request.method` (string): HTTP method + - `error.type` (string): Error category/type (client_error, server_error, auth_error, rate_limit_error, etc.) + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + - `client.address` (string, optional): IP address + - `user_agent.original` (string, optional): User agent information + +### Secret Operations Metrics + +These metrics provide visibility into secret access patterns, helping you understand which secrets are being accessed, by whom, and from where. Essential for security auditing and access pattern analysis. + +#### Secret Read Operations + +- **Metric Name**: `infisical.secret.read.count` +- **Type**: Counter +- **Unit**: `{operation}` +- **Description**: Number of secret read operations +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.project.id` (string): Project ID + - `infisical.project.name` (string): Project name (e.g., "payment-service-secrets") + - `infisical.environment` (string): Environment (dev, staging, prod) + - `infisical.secret.path` (string): Path to secrets (e.g., "/microservice-a/database") + - `infisical.secret.name` (string, optional): Name of secret + - `infisical.user.id` (string, optional): User ID if human + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name + - `user_agent.original` (string, optional): User agent/SDK information + - `client.address` (string, optional): IP address + +### Authentication Metrics + +These metrics track authentication attempts and outcomes, enabling you to monitor login success rates, detect potential security threats, and identify authentication issues. + +#### Login Attempts + +- **Metric Name**: `infisical.auth.attempt.count` +- **Type**: Counter +- **Unit**: `{attempt}` +- **Description**: Authentication attempts (both successful and failed) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human (if identifiable) + - `infisical.user.email` (string, optional): User email (if identifiable) + - `infisical.identity.id` (string, optional): Identity ID if machine (if identifiable) + - `infisical.identity.name` (string, optional): Identity name (if identifiable) + - `infisical.auth.method` (string): Authentication method attempted + - `infisical.auth.result` (string): success or failure + - `error.type` (string, optional): Reason for failure if failed (invalid_credentials, expired_token, invalid_token, etc.) + - `client.address` (string): IP address + - `user_agent.original` (string, optional): User agent/client information + - `infisical.auth.attempt.username` (string, optional): Attempted username/email (if available) + +### Legacy Metrics + +These metrics are from the previous instrumentation and may be deprecated in future versions. Consider migrating to the new Core API Metrics for more comprehensive observability. + +- `API_latency` - API request latency histogram in milliseconds (Labels: `route`, `method`, `statusCode`) +- `API_errors` - API error count histogram (Labels: `route`, `method`, `type`, `name`) ### Integration & Secret Sync Metrics +These metrics monitor secret synchronization operations between Infisical and external systems, helping you track sync health, identify integration failures, and troubleshoot connectivity issues. + - `integration_secret_sync_errors` - Integration secret sync error count - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` @@ -414,16 +471,11 @@ Infisical exposes the following key metrics in OpenTelemetry format: ### System Metrics -These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: +These low-level HTTP metrics are automatically collected by OpenTelemetry's instrumentation layer, providing baseline performance data for all HTTP traffic. - `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) - `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) -### Custom Business Metrics - -- `infisical_secret_operations_total` - Total secret operations -- `infisical_secrets_processed_total` - Total secrets processed - ## Troubleshooting ### Common Issues diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index c74d9149ac..dfe5745477 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -48,6 +48,7 @@ export const AppConnectionsBrowser = () => { {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, {"name": "Chef", "slug": "chef", "path": "/integrations/app-connections/chef", "description": "Learn how to connect your Chef to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index c7b5806ea1..17ff9e97b9 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -38,7 +38,8 @@ export const SecretSyncsBrowser = () => { {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"}, - {"name": "Chef", "slug": "chef", "path": "/integrations/secret-syncs/chef", "description": "Learn how to sync secrets from Infisical to Chef.", "category": "DevOps Tools"} + {"name": "Chef", "slug": "chef", "path": "/integrations/secret-syncs/chef", "description": "Learn how to sync secrets from Infisical to Chef.", "category": "DevOps Tools"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/secret-syncs/northflank", "description": "Learn how to sync secrets from Infisical to Northflank projects.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx new file mode 100644 index 0000000000..2392e43945 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx @@ -0,0 +1,123 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TNorthflankProject, + TNorthflankSecretGroup, + useNorthflankConnectionListProjects, + useNorthflankConnectionListSecretGroups +} from "@app/hooks/api/appConnections/northflank"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const NorthflankSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Northflank } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useNorthflankConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: secretGroups = [], isPending: isSecretGroupsLoading } = + useNorthflankConnectionListSecretGroups(connectionId, projectId, { + enabled: Boolean(connectionId) && Boolean(projectId) + }); + + return ( + <> + { + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + /> + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ( + +
+ Don't see the secret group you're looking for?{" "} + +
+ + } + > + sg.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.secretGroupName", v?.name ?? ""); + }} + options={secretGroups} + placeholder="Select a secret group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 5137b7d390..a7a4bcbe58 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -25,6 +25,7 @@ import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; +import { NorthflankSyncFields } from "./NorthflankSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -106,6 +107,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Chef: return ; + case SecretSync.Northflank: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index dbd5f743a7..506c81a212 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -68,6 +68,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: + case SecretSync.Northflank: case SecretSync.Bitbucket: case SecretSync.LaravelForge: case SecretSync.Chef: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx new file mode 100644 index 0000000000..a37597722a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectName = watch("destinationConfig.projectName"); + const projectId = watch("destinationConfig.projectId"); + const secretGroupName = watch("destinationConfig.secretGroupName"); + const secretGroupId = watch("destinationConfig.secretGroupId"); + + return ( + <> + {projectName || projectId} + {secretGroupName || secretGroupId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 78f365a657..af0748db79 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -37,6 +37,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; +import { NorthflankSyncReviewFields } from "./NorthflankSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -168,6 +169,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Netlify: DestinationFieldsComponent = ; break; + case SecretSync.Northflank: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts new file mode 100644 index 0000000000..1554da63b0 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: z.object({ + projectId: z.string().trim().min(1, "Project ID is required"), + projectName: z.string().trim().optional(), + secretGroupId: z.string().trim().min(1, "Secret Group ID is required"), + secretGroupName: z.string().trim().optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 85c8289a93..ea38231636 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -22,6 +22,7 @@ import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { LaravelForgeSyncDestinationSchema } from "./laravel-forge-sync-destination-schema"; import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; +import { NorthflankSyncDestinationSchema } from "./northflank-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -63,6 +64,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, + NorthflankSyncDestinationSchema, BitbucketSyncDestinationSchema, LaravelForgeSyncDestinationSchema, ChefSyncDestinationSchema diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index e7efda4b8d..5e8f657883 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -14,6 +14,7 @@ export type ModalContentProps = Omit void; overlayClassName?: string; + showCloseButton?: boolean; }; export const ModalContent = forwardRef( @@ -27,6 +28,7 @@ export const ModalContent = forwardRef( footerContent, bodyClassName, onClose, + showCloseButton = true, ...props }, forwardedRef @@ -57,15 +59,17 @@ export const ModalContent = forwardRef( {children} {footerContent && {footerContent}} - - - - - + {showCloseButton && ( + + + + + + )} @@ -74,7 +78,9 @@ export const ModalContent = forwardRef( ModalContent.displayName = "ModalContent"; -export type ModalProps = Omit & { isOpen?: boolean }; +export type ModalProps = Omit & { + isOpen?: boolean; +}; export const Modal = ({ isOpen, ...props }: ModalProps) => ( ); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index aa25e2f96a..1b0d4fd2e1 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -50,6 +50,7 @@ import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/type import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection"; import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; +import { NorthflankConnectionMethod } from "@app/hooks/api/appConnections/types/northflank-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -122,6 +123,7 @@ export const APP_CONNECTION_MAP: Record< name: "Netlify", image: "Netlify.png" }, + [AppConnection.Northflank]: { name: "Northflank", image: "Northflank.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, [AppConnection.LaravelForge]: { @@ -166,6 +168,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index f2cae0be52..393aff6449 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -114,6 +114,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge, [SecretSync.Chef]: AppConnection.Chef diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c58fb91944..fba0cbb4bc 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -36,6 +36,7 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", + Northflank = "northflank", Okta = "okta", Redis = "redis", LaravelForge = "laravel-forge", diff --git a/frontend/src/hooks/api/appConnections/northflank/index.ts b/frontend/src/hooks/api/appConnections/northflank/index.ts new file mode 100644 index 0000000000..2c1906d369 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/northflank/queries.tsx b/frontend/src/hooks/api/appConnections/northflank/queries.tsx new file mode 100644 index 0000000000..bf63cd9765 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNorthflankProject, TNorthflankSecretGroup } from "./types"; + +const northflankConnectionKeys = { + all: [...appConnectionKeys.all, "northflank"] as const, + listProjects: (connectionId: string) => + [...northflankConnectionKeys.all, "projects", connectionId] as const, + listSecretGroups: (connectionId: string, projectId: string) => + [...northflankConnectionKeys.all, "secret-groups", connectionId, projectId] as const +}; + +export const useNorthflankConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNorthflankProject[], + unknown, + TNorthflankProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TNorthflankProject[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; + +export const useNorthflankConnectionListSecretGroups = ( + connectionId: string, + projectId: string, + options?: Omit< + UseQueryOptions< + TNorthflankSecretGroup[], + unknown, + TNorthflankSecretGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listSecretGroups(connectionId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ secretGroups: TNorthflankSecretGroup[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects/${projectId}/secret-groups` + ); + + return data.secretGroups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/northflank/types.ts b/frontend/src/hooks/api/appConnections/northflank/types.ts new file mode 100644 index 0000000000..061d2179f6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/types.ts @@ -0,0 +1,9 @@ +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index edf26d2e1c..1f553f6059 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -172,6 +172,10 @@ export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { app: AppConnection.LaravelForge; }; +export type TNorthflankConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Northflank; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -217,6 +221,7 @@ export type TAppConnectionOption = | TSupabaseConnectionOption | TDigitalOceanConnectionOption | TNetlifyConnectionOption + | TNorthflankConnectionOption | TOktaConnectionOption | TAzureAdCsConnectionOption | TLaravelForgeConnectionOption @@ -259,6 +264,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; [AppConnection.Netlify]: TNetlifyConnectionOption; + [AppConnection.Northflank]: TNorthflankConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2664a462c1..3358c1129b 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -27,6 +27,7 @@ import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; import { TNetlifyConnection } from "./netlify-connection"; +import { TNorthflankConnection } from "./northflank-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -67,6 +68,8 @@ export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; +export * from "./netlify-connection"; +export * from "./northflank-connection"; export * from "./oci-connection"; export * from "./okta-connection"; export * from "./oracledb-connection"; @@ -121,6 +124,7 @@ export type TAppConnection = | TSupabaseConnection | TDigitalOceanConnection | TNetlifyConnection + | TNorthflankConnection | TOktaConnection | TRedisConnection | TChefConnection; diff --git a/frontend/src/hooks/api/appConnections/types/northflank-connection.ts b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts new file mode 100644 index 0000000000..9e29693e4c --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum NorthflankConnectionMethod { + ApiToken = "api-token" +} + +export type TNorthflankConnection = TRootAppConnection & { app: AppConnection.Northflank } & { + method: NorthflankConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 20cb7aa606..286c9389a3 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -16,6 +16,8 @@ export interface TBasePamAccount { rotationEnabled: boolean; rotationIntervalSeconds?: number | null; lastRotatedAt?: string | null; + lastRotationMessage?: string | null; + rotationStatus?: string | null; createdAt: string; updatedAt: string; } diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index d34876930a..be51805c85 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge", Chef = "chef" diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 577ec7fc46..3611ed2f57 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -23,6 +23,7 @@ import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; +import { TNorthflankSync } from "./northflank-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -71,6 +72,7 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync + | TNorthflankSync | TBitbucketSync | TLaravelForgeSync | TChefSync; diff --git a/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts new file mode 100644 index 0000000000..e1fff68fcc --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts @@ -0,0 +1,19 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TNorthflankSync = TRootSecretSync & { + destination: SecretSync.Northflank; + destinationConfig: { + projectId: string; + projectName?: string; + secretGroupId: string; + secretGroupName?: string; + }; + + connection: { + app: AppConnection.Northflank; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 98daf3ec13..80bea3db77 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -59,6 +59,7 @@ export type SubscriptionPlan = { enterpriseAppConnections: boolean; cardDeclined?: boolean; cardDeclinedReason?: string; + cardDeclinedDays?: number; machineIdentityAuthTemplates: boolean; pam: boolean; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 014c77baa0..3fb909c9b2 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -141,12 +141,10 @@ export const Navbar = () => { enabled: Boolean(subscription.subOrganization) }); - useEffect(() => { - if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { - sessionStorage.setItem("paymentFailed", "true"); - setShowCardDeclinedModal(true); - } - }, [subscription]); + const isCardDeclined = Boolean(subscription?.cardDeclined); + const isCardDeclinedMoreThan30Days = Boolean( + isCardDeclined && subscription?.cardDeclinedDays && subscription?.cardDeclinedDays >= 30 + ); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -158,6 +156,23 @@ export const Navbar = () => { const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); + const isBillingPage = location.pathname === "/organization/billing"; + + const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days); + + useEffect(() => { + if (isModalIntrusive) { + setShowCardDeclinedModal(true); + sessionStorage.setItem("paymentFailed", "true"); + return; + } + + if (isCardDeclined && !sessionStorage.getItem("paymentFailed")) { + sessionStorage.setItem("paymentFailed", "true"); + setShowCardDeclinedModal(true); + } + }, [subscription, isBillingPage, isModalIntrusive]); + const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; @@ -689,7 +704,10 @@ export const Navbar = () => { - + !isModalIntrusive && setShowCardDeclinedModal(false)} + > @@ -697,6 +715,7 @@ export const Navbar = () => { Your payment could not be processed. } + showCloseButton={!isModalIntrusive} >
@@ -717,15 +736,16 @@ export const Navbar = () => { > Update Payment Method + + {!isModalIntrusive && ( - + )}
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index eee0cd84f3..04292d94f7 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -37,6 +37,7 @@ import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { NetlifyConnectionForm } from "./NetlifyConnectionForm"; +import { NorthflankConnectionForm } from "./NorthflankConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OktaConnectionForm } from "./OktaConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; @@ -172,6 +173,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Netlify: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: @@ -335,6 +338,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.DigitalOcean: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx new file mode 100644 index 0000000000..2b837ce193 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + NorthflankConnectionMethod, + TNorthflankConnection +} from "@app/hooks/api/appConnections/types/northflank-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TNorthflankConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Northflank) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const NorthflankConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Northflank, + method: NorthflankConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx index ad0ed433d4..9b8fdddf1d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx @@ -72,6 +72,7 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => { return (