diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 273e6d982..a2bc332f8 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/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 76bff60e8..00add4adc 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 e80ec7cf5..cbe1bed7e 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/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 48b78d980..b71e63c10 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -337,6 +337,12 @@ export const permissionServiceFactory = ({ throw new NotFoundError({ message: `Project with ${projectId} not found` }); } + requestContext.set("projectDetails", { + id: projectDetails.id, + name: projectDetails.name, + slug: projectDetails.slug + }); + if (projectDetails.orgId !== actorOrgId) { throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); } diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index c99ae8b28..7206bd293 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -769,7 +769,7 @@ export const samlConfigServiceFactory = ({ }); } - return { isUserCompleted, providerAuthToken }; + return { isUserCompleted, providerAuthToken, user, organization }; }; return { diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 983ec4db7..5ca3e09fb 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -1,4 +1,4 @@ -import { TSamlConfigs } from "@app/db/schemas"; +import { TOrganizations, TSamlConfigs, TUsers } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -78,5 +78,7 @@ export type TSamlConfigServiceFactory = { samlLogin: (arg: TSamlLoginDTO) => Promise<{ isUserCompleted: boolean; providerAuthToken: string; + user: TUsers; + organization: TOrganizations; }>; }; diff --git a/backend/src/lib/telemetry/metrics.ts b/backend/src/lib/telemetry/metrics.ts new file mode 100644 index 000000000..5f650ffc6 --- /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 f1176b932..60b678f63 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 2e3a20a23..4233bc86d 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 9de15cd34..31f3139ec 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 62df05eec..8d10a8630 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 31d860201..b7725da3f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1705,7 +1705,8 @@ export const registerRoutes = async ( licenseService, permissionService, kmsService, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }); const identityAwsAuthService = identityAwsAuthServiceFactory({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 366fa331d..32b09b337 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/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 28a986fe8..fb7213109 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 31a9cc5a8..b9c759703 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 02660a0ae..3479d929e 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 c6f6f1376..525da1e10 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 e61e8296b..81fab3fde 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 f75aeba4f..17f274e7c 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 67adb6c1e..3e0035e82 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 debd90933..3cf93fd16 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 8a8fd5091..b633dc433 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 272e45c4e..d327dabee 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 6d7f0c4d3..c75abc76b 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 628b69f14..a03beeb3b 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 24c82ccac..60670d035 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 00ab1610d..26bd01627 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/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 465babebe..559c86843 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -34,6 +34,7 @@ import { diff, groupBy } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { recordSecretReadMetric } from "@app/lib/telemetry/metrics"; import { ActorType } from "../auth/auth-type"; import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; @@ -1052,6 +1053,11 @@ export const secretV2BridgeServiceFactory = ({ }); throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); + recordSecretReadMetric({ + environment, + secretPath: path + }); + const cachedSecretDalVersion = await keyStore.pgGetIntItem(SecretServiceCacheKeys.getSecretDalVersion(projectId)); const secretDalVersion = Number(cachedSecretDalVersion || 0); const cacheKey = SecretServiceCacheKeys.getSecretsOfServiceLayer(projectId, secretDalVersion, { @@ -1482,6 +1488,12 @@ export const secretV2BridgeServiceFactory = ({ secretTags: (secret?.tags || []).map((el) => 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/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx index 1c2d05702..b23c51b27 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