From 0f41590d6adccc4b13bb6d126f386aeee564780c Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:32:31 +0100 Subject: [PATCH 001/302] feat(cli): --plain, --expand, --include-imports in 'secrets get' subcommand --- cli/packages/cmd/secrets.go | 49 ++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 778ac574e..e4e0db126 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -68,6 +68,11 @@ var secretsCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + plainOutput, err := cmd.Flags().GetBool("plain") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}, "") if err != nil { util.HandleError(err) @@ -83,7 +88,14 @@ var secretsCmd = &cobra.Command{ secrets = util.ExpandSecrets(secrets, infisicalToken, "") } - visualize.PrintAllSecretDetails(secrets) + if plainOutput { + for _, secret := range secrets { + fmt.Println(secret.Value) + } + } else { + visualize.PrintAllSecretDetails(secrets) + } + Telemetry.CaptureEvent("cli-command:secrets", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) }, } @@ -406,11 +418,30 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}, "") + plainOutput, err := cmd.Flags().GetBool("plain") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + includeImports, err := cmd.Flags().GetBool("include-imports") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + shouldExpandSecrets, err := cmd.Flags().GetBool("expand") + if err != nil { + util.HandleError(err) + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}, "") if err != nil { util.HandleError(err, "To fetch all secrets") } + if shouldExpandSecrets { + secrets = util.ExpandSecrets(secrets, infisicalToken, "") + } + requestedSecrets := []models.SingleEnvironmentVariable{} secretsMap := getSecretsByKeys(secrets) @@ -427,7 +458,13 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - visualize.PrintAllSecretDetails(requestedSecrets) + if plainOutput { + for _, secret := range requestedSecrets { + fmt.Println(secret.Value) + } + } else { + visualize.PrintAllSecretDetails(requestedSecrets) + } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) } @@ -661,6 +698,9 @@ func init() { secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.AddCommand(secretsGetCmd) secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") + secretsGetCmd.Flags().Bool("plain", false, "print values without formatting, one per line") + secretsGetCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") + secretsGetCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) @@ -703,9 +743,10 @@ func init() { secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") - secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") secretsCmd.PersistentFlags().StringP("tags", "t", "", "filter secrets by tag slugs") secretsCmd.Flags().String("path", "/", "get secrets within a folder path") + secretsCmd.Flags().Bool("plain", false, "print values without formatting, one per line") rootCmd.AddCommand(secretsCmd) } From f83c2215a579da9519fc1051774bb7c9de8a5a01 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:44:55 +0100 Subject: [PATCH 002/302] doc(cli): --plain --- docs/cli/commands/secrets.mdx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 8e2183ad9..08e858e9d 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -59,6 +59,14 @@ This command enables you to perform CRUD (create, read, update, delete) operatio infisical secrets --path="/" --env=dev ``` + + The `--plain` flag will output all your secret values without formatting, one per line. + + ```bash + # Example + infisical secrets --plain + ``` + @@ -70,6 +78,7 @@ This command enables you to perform CRUD (create, read, update, delete) operatio # Example $ infisical secrets get DOMAIN +$ infisical secrets get DOMAIN PORT ``` @@ -79,6 +88,17 @@ This command enables you to perform CRUD (create, read, update, delete) operatio Default value: `dev` + + The `--plain` flag will output all your requested secret values without formatting, one per line. + + ```bash + # Example + infisical secrets get FOO --plain + + # Fetch a single value and assign it to a variable + API_KEY=$(infisical secrets get FOO --plain) + ``` + From 637b0b955fe7b6702fcedee001e5e857f96e7d60 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Fri, 24 May 2024 17:28:52 +0200 Subject: [PATCH 003/302] fix(cli): add backward compatibility for --raw-value --- cli/packages/cmd/secrets.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 90239deeb..c3cec597a 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -470,10 +470,10 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse recursive flag") } - //deprecated (showOnlyValue) in favor of --plain + // deprecated, in favor of --plain showOnlyValue, err := cmd.Flags().GetBool("raw-value") if err != nil { - util.HandleError(err, "Unable to parse path flag") + util.HandleError(err, "Unable to parse flag") } plainOutput, err := cmd.Flags().GetBool("plain") @@ -533,7 +533,8 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - if plainOutput { + // showOnlyValue deprecated in favor of --plain, below only for backward compatibility + if plainOutput || showOnlyValue { for _, secret := range requestedSecrets { fmt.Println(secret.Value) } @@ -541,16 +542,6 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { visualize.PrintAllSecretDetails(requestedSecrets) } - // deprecated (showOnlyValue) - if showOnlyValue && len(requestedSecrets) > 1 { - util.PrintErrorMessageAndExit("--raw-value only works with one secret.") - } - - if showOnlyValue { - fmt.Printf(requestedSecrets[0].Value) - } else { - visualize.PrintAllSecretDetails(requestedSecrets) - } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) } From f9a959965916909471261a97c5cbc1dc656dc95c Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Fri, 24 May 2024 17:29:44 +0200 Subject: [PATCH 004/302] doc(cli): improve --plain example --- docs/cli/commands/secrets.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 81fa4de4c..941820227 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -106,8 +106,8 @@ $ infisical secrets ```bash $ infisical secrets get ... - # Example - $ infisical secrets get DOMAIN +# Example +$ infisical secrets get DOMAIN $ infisical secrets get DOMAIN PORT ``` @@ -124,9 +124,12 @@ $ infisical secrets get DOMAIN PORT The `--plain` flag will output all your requested secret values without formatting, one per line. + Default value: `false` + ```bash # Example infisical secrets get FOO --plain + infisical secrets get FOO BAR --plain # Fetch a single value and assign it to a variable API_KEY=$(infisical secrets get FOO --plain) From 66e5edcfc0c116d3d8923addcac1dcf43402facc Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 20:32:47 +0800 Subject: [PATCH 005/302] feat: oidc poc --- backend/package-lock.json | 39 ++++ backend/package.json | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 2 + .../20240617041053_add-oidc-auth.ts | 30 +++ backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 31 +++ backend/src/server/routes/index.ts | 12 ++ backend/src/server/routes/v1/index.ts | 2 + backend/src/server/routes/v1/oidc-router.ts | 125 +++++++++++ .../src/services/auth/auth-login-service.ts | 5 +- backend/src/services/auth/auth-type.ts | 3 +- backend/src/services/oidc/oidc-config-dal.ts | 11 + .../src/services/oidc/oidc-config-service.ts | 200 ++++++++++++++++++ .../src/services/oidc/oidc-config-types.ts | 27 +++ .../services/user-alias/user-alias-types.ts | 3 +- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/oidcConfig/index.tsx | 1 + frontend/src/hooks/api/oidcConfig/queries.tsx | 19 ++ 19 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/20240617041053_add-oidc-auth.ts create mode 100644 backend/src/db/schemas/oidc-configs.ts create mode 100644 backend/src/server/routes/v1/oidc-router.ts create mode 100644 backend/src/services/oidc/oidc-config-dal.ts create mode 100644 backend/src/services/oidc/oidc-config-service.ts create mode 100644 backend/src/services/oidc/oidc-config-types.ts create mode 100644 frontend/src/hooks/api/oidcConfig/index.tsx create mode 100644 frontend/src/hooks/api/oidcConfig/queries.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index a2a4d0a6e..b0ff466c2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -55,6 +55,7 @@ "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", + "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", "passport-github": "^1.1.0", @@ -9445,6 +9446,14 @@ "node": ">= 0.6.0" } }, + "node_modules/jose": { + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -10570,6 +10579,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -10693,6 +10710,14 @@ "@octokit/core": ">=5" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -10739,6 +10764,20 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/openid-client": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", + "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "dependencies": { + "jose": "^4.15.5", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", diff --git a/backend/package.json b/backend/package.json index 66e720dcf..7aecc01bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -116,6 +116,7 @@ "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", + "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", "passport-github": "^1.1.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 81fc0c541..06cca28b3 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -40,6 +40,7 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { TOidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; @@ -99,6 +100,7 @@ declare module "fastify" { permission: TPermissionServiceFactory; org: TOrgServiceFactory; orgRole: TOrgRoleServiceFactory; + oidc: TOidcConfigServiceFactory; superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; group: TGroupServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index fbcaa3528..330b447b5 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -255,6 +255,7 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; +import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; declare module "knex/types/tables" { interface Tables { @@ -497,6 +498,7 @@ declare module "knex/types/tables" { TDynamicSecretLeasesUpdate >; [TableName.SamlConfig]: Knex.CompositeTableType; + [TableName.OidcConfig]: Knex.CompositeTableType; [TableName.LdapConfig]: Knex.CompositeTableType; [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts new file mode 100644 index 000000000..0b54064bc --- /dev/null +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.OidcConfig))) { + await knex.schema.createTable(TableName.OidcConfig, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("issuer"); + tb.string("authorizationEndpoint"); + tb.string("jwksUri"); + tb.string("tokenEndpoint"); + tb.string("userinfoEndpoint"); + tb.text("encryptedClientId"); + tb.string("clientIdIV"); + tb.string("clientIdTag"); + tb.text("encryptedClientSecret"); + tb.string("clientSecretIV"); + tb.string("clientSecretTag"); + tb.boolean("isActive").notNullable(); + tb.timestamps(true, true, true); + tb.uuid("orgId").notNullable().unique(); + tb.foreign("orgId").references("id").inTable(TableName.Organization); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.OidcConfig); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 5d2213003..df15456fb 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -71,6 +71,7 @@ export enum TableName { SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", LdapConfig = "ldap_configs", + OidcConfig = "oidc_configs", LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", AuditLogStream = "audit_log_streams", diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts new file mode 100644 index 000000000..05dae70a3 --- /dev/null +++ b/backend/src/db/schemas/oidc-configs.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OidcConfigsSchema = z.object({ + id: z.string().uuid(), + issuer: z.string().nullable().optional(), + authorizationEndpoint: z.string().nullable().optional(), + jwksUri: z.string().nullable().optional(), + tokenEndpoint: z.string().nullable().optional(), + userinfoEndpoint: z.string().nullable().optional(), + encryptedClientId: z.string().nullable().optional(), + clientIdIV: z.string().nullable().optional(), + clientIdTag: z.string().nullable().optional(), + encryptedClientSecret: z.string().nullable().optional(), + clientSecretIV: z.string().nullable().optional(), + clientSecretTag: z.string().nullable().optional(), + isActive: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid() +}); + +export type TOidcConfigs = z.infer; +export type TOidcConfigsInsert = Omit, TImmutableDBKeys>; +export type TOidcConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2f2de829f..7d3155bd6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -838,6 +839,16 @@ export const registerRoutes = async ( secretSharingDAL }); + const oidcService = oidcConfigServiceFactory({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + tokenService, + smtpService + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -858,6 +869,7 @@ export const registerRoutes = async ( permission: permissionService, org: orgService, orgRole: orgRoleService, + oidc: oidcService, apiKey: apiKeyService, authToken: tokenService, superAdmin: superAdminService, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fd8255e63..4b8480cd5 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerOidcRouter } from "./oidc-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -29,6 +30,7 @@ import { registerWebhookRouter } from "./webhook-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); + await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register( async (authRouter) => { await authRouter.register(registerAuthRoutes); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/server/routes/v1/oidc-router.ts new file mode 100644 index 000000000..bccead8e9 --- /dev/null +++ b/backend/src/server/routes/v1/oidc-router.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// All the any rules are disabled because passport typesense with fastify is really poor + +import { Authenticator, Strategy } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { z } from "zod"; + +import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; +import { getConfig } from "@app/lib/config/env"; + +export const registerOidcRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + cookie: { + secure: false // has to be set to false if testing locally + } + }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + // redirect to IDP for login + server.route({ + url: "/login", + method: "GET", + schema: { + params: z.object({ + orgSlug: z.string().trim() + }) + }, + handler: async (req, res) => { + // get params, save to session + const { orgSlug } = req.params; + req.session.set("oidcOrgSlug", orgSlug); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); + ( + passport.authenticate(oidcStrategy as Strategy, { + scope: "profile email openid" + }) as any + )(req, res); + } + }); + + // callback route after login from IDP + server.route({ + url: "/callback", + method: "GET", + handler: async (req, res) => { + const oidcOrgSlug = req.session.get("oidcOrgSlug"); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); + await ( + passport.authenticate(oidcStrategy as Strategy, { + failureRedirect: "/api/v1/oidc/login/error", + session: false, + failureMessage: true + }) as any + )(req, res); + + if (req.passportUser.isUserCompleted) { + return res.redirect( + `http://localhost:8080/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + + // signup + return res.redirect( + `http://localhost:8080/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + }); + + server.route({ + url: "/login/error", + method: "GET", + handler: (req, res) => { + return res.status(500).send({ + error: "Authentication error", + details: req.query + }); + } + }); + + server.route({ + url: "/config", + method: "GET", + schema: { + querystring: z.object({ + orgSlug: z.string().trim() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true + }).extend({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + handler: async (req) => { + const { orgSlug } = req.query; + const oidc = await server.services.oidc.getOidc({ + orgSlug, + type: "external", + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return oidc; + } + }); +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index a136508e7..d6d99f682 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -196,7 +196,10 @@ export const authLoginServiceFactory = ({ const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); authMethod = decodedProviderToken.authMethod; - if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId) { + if ( + (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod)) && + decodedProviderToken.orgId + ) { organizationId = decodedProviderToken.orgId; } } diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 8e7b92253..9210093ab 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -8,7 +8,8 @@ export enum AuthMethod { JUMPCLOUD_SAML = "jumpcloud-saml", GOOGLE_SAML = "google-saml", KEYCLOAK_SAML = "keycloak-saml", - LDAP = "ldap" + LDAP = "ldap", + OIDC = "oidc" } export enum AuthTokenType { diff --git a/backend/src/services/oidc/oidc-config-dal.ts b/backend/src/services/oidc/oidc-config-dal.ts new file mode 100644 index 000000000..470916c61 --- /dev/null +++ b/backend/src/services/oidc/oidc-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOidcConfigDALFactory = ReturnType; + +export const oidcConfigDALFactory = (db: TDbClient) => { + const oidcCfgOrm = ormify(db, TableName.OidcConfig); + + return { ...oidcCfgOrm }; +}; diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/services/oidc/oidc-config-service.ts new file mode 100644 index 000000000..9634a24cc --- /dev/null +++ b/backend/src/services/oidc/oidc-config-service.ts @@ -0,0 +1,200 @@ +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; +import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; +import { normalizeUsername } from "../user/user-fns"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; +import { UserAliasType } from "../user-alias/user-alias-types"; +import { TOidcLoginDTO } from "./oidc-config-types"; + +type TOidcConfigServiceFactoryDep = { + userDAL: Pick; + userAliasDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgMembershipDAL: Pick; + licenseService: Pick; + tokenService: Pick; + smtpService: Pick; +}; + +export type TOidcConfigServiceFactory = ReturnType; + +export const oidcConfigServiceFactory = ({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + tokenService, + smtpService +}: TOidcConfigServiceFactoryDep) => { + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { + const appCfg = getConfig(); + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.OIDC + }); + + const organization = await orgDAL.findOrgById(orgId); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + let user: TUsers; + if (userAlias) { + user = await userDAL.transaction(async (tx) => { + const foundUser = await userDAL.findById(userAlias.userId, tx); + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + + return foundUser; + }); + } else { + user = await userDAL.transaction(async (tx) => { + let newUser: TUsers | undefined; + if (!newUser) { + const uniqueUsername = await normalizeUsername(externalId, userDAL); + newUser = await userDAL.create( + { + email, + firstName, + isEmailVerified: false, + username: uniqueUsername, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( + { + userId: newUser.id, + aliasType: UserAliasType.OIDC, + externalId, + emails: email ? [email] : [], + orgId + }, + tx + ); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + + return newUser; + }); + } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); + + const isUserCompleted = Boolean(user.isAccepted); + const providerAuthToken = jwt.sign( + { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user.id, + username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + firstName, + lastName, + organizationName: organization.name, + organizationId: organization.id, + organizationSlug: organization.slug, + authMethod: AuthMethod.OIDC, + authType: UserAliasType.OIDC, + isUserCompleted + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME + } + ); + + // TODO: Sheen update oidc config + // await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); + + if (user.email && !user.isEmailVerified) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + } + + return { isUserCompleted, providerAuthToken }; + }; + + return { oidcLogin }; +}; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts new file mode 100644 index 000000000..d7145f453 --- /dev/null +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -0,0 +1,27 @@ +import { TGenericPermission } from "@app/lib/types"; + +export type TOidcLoginDTO = { + externalId: string; + email: string; + firstName: string; + lastName?: string; + orgId: string; +}; + +export type TGetOidcCfgDTO = { + type: "internal" | "external"; + orgSlug: string; +} & TGenericPermission; + +export type TUpdateOidcCfgDTO = Partial<{ + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +}> & + TGenericPermission; diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index 09204644f..7207e8acf 100644 --- a/backend/src/services/user-alias/user-alias-types.ts +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -1,4 +1,5 @@ export enum UserAliasType { LDAP = "ldap", - SAML = "saml" + SAML = "saml", + OIDC = "oidc" } diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 19fd5f594..b76aac166 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -15,6 +15,7 @@ export * from "./integrationAuth"; export * from "./integrations"; export * from "./keys"; export * from "./ldapConfig"; +export * from "./oidcConfig"; export * from "./organization"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; diff --git a/frontend/src/hooks/api/oidcConfig/index.tsx b/frontend/src/hooks/api/oidcConfig/index.tsx new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/index.tsx @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx new file mode 100644 index 000000000..4a9773fd5 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +export const oidcConfigKeys = { + getOIDCConfig: (orgSlug: string) => [{ orgSlug }, "organization-oidc"] as const +}; + +export const useGetOIDCConfig = (orgSlug: string) => { + return useQuery({ + queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/oidc/config?orgSlug=${orgSlug}`); + + return data; + }, + enabled: true + }); +}; From 61fcb2b605b80068069fc24ad1775771d7ca4114 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 22:37:23 +0800 Subject: [PATCH 006/302] feat: finished oidc form functions --- backend/src/@types/knex.d.ts | 4 +- .../20240617041053_add-oidc-auth.ts | 22 +- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 22 +- backend/src/server/routes/index.ts | 7 +- backend/src/server/routes/v1/oidc-router.ts | 95 +++++++ .../src/services/oidc/oidc-config-types.ts | 12 + .../src/hooks/api/oidcConfig/mutations.tsx | 93 +++++++ .../components/OrgAuthTab/OIDCModal.tsx | 245 ++++++++++++++++++ .../components/OrgAuthTab/OrgAuthTab.tsx | 2 + .../components/OrgAuthTab/OrgOIDCSection.tsx | 101 ++++++++ 11 files changed, 580 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/api/oidcConfig/mutations.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 330b447b5..bdd1de711 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -113,6 +113,9 @@ import { TLdapGroupMaps, TLdapGroupMapsInsert, TLdapGroupMapsUpdate, + TOidcConfigs, + TOidcConfigsInsert, + TOidcConfigsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -255,7 +258,6 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; declare module "knex/types/tables" { interface Tables { diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index 0b54064bc..b11e6bbab 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -6,17 +6,17 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.OidcConfig))) { await knex.schema.createTable(TableName.OidcConfig, (tb) => { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - tb.string("issuer"); - tb.string("authorizationEndpoint"); - tb.string("jwksUri"); - tb.string("tokenEndpoint"); - tb.string("userinfoEndpoint"); - tb.text("encryptedClientId"); - tb.string("clientIdIV"); - tb.string("clientIdTag"); - tb.text("encryptedClientSecret"); - tb.string("clientSecretIV"); - tb.string("clientSecretTag"); + tb.string("issuer").notNullable(); + tb.string("authorizationEndpoint").notNullable(); + tb.string("jwksUri").notNullable(); + tb.string("tokenEndpoint").notNullable(); + tb.string("userinfoEndpoint").notNullable(); + tb.text("encryptedClientId").notNullable(); + tb.string("clientIdIV").notNullable(); + tb.string("clientIdTag").notNullable(); + tb.text("encryptedClientSecret").notNullable(); + tb.string("clientSecretIV").notNullable(); + tb.string("clientSecretTag").notNullable(); tb.boolean("isActive").notNullable(); tb.timestamps(true, true, true); tb.uuid("orgId").notNullable().unique(); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5771cb669..cc7cea33c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -36,6 +36,7 @@ export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; +export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-memberships"; export * from "./org-roles"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 05dae70a3..b06114df6 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -9,17 +9,17 @@ import { TImmutableDBKeys } from "./models"; export const OidcConfigsSchema = z.object({ id: z.string().uuid(), - issuer: z.string().nullable().optional(), - authorizationEndpoint: z.string().nullable().optional(), - jwksUri: z.string().nullable().optional(), - tokenEndpoint: z.string().nullable().optional(), - userinfoEndpoint: z.string().nullable().optional(), - encryptedClientId: z.string().nullable().optional(), - clientIdIV: z.string().nullable().optional(), - clientIdTag: z.string().nullable().optional(), - encryptedClientSecret: z.string().nullable().optional(), - clientSecretIV: z.string().nullable().optional(), - clientSecretTag: z.string().nullable().optional(), + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + encryptedClientId: z.string(), + clientIdIV: z.string(), + clientIdTag: z.string(), + encryptedClientSecret: z.string(), + clientSecretIV: z.string(), + clientSecretTag: z.string(), isActive: z.boolean(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7d3155bd6..0bf4d0ae5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { oidcConfigDALFactory } from "@app/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; @@ -241,6 +242,7 @@ export const registerRoutes = async ( const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + const oidcConfigDAL = oidcConfigDALFactory(db); const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); @@ -846,7 +848,10 @@ export const registerRoutes = async ( userAliasDAL, licenseService, tokenService, - smtpService + smtpService, + orgBotDAL, + permissionService, + oidcConfigDAL }); await superAdminService.initServerCfg(); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/server/routes/v1/oidc-router.ts index bccead8e9..05c2bd90e 100644 --- a/backend/src/server/routes/v1/oidc-router.ts +++ b/backend/src/server/routes/v1/oidc-router.ts @@ -12,6 +12,9 @@ import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; import { getConfig } from "@app/lib/config/env"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -101,6 +104,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, + isActive: true, orgId: true }).extend({ clientId: z.string(), @@ -122,4 +126,95 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { return oidc; } }); + + server.route({ + method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z + .object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean() + }) + .partial() + .merge(z.object({ orgSlug: z.string() })), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + handler: async (req) => { + const oidc = await server.services.oidc.updateOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); + + server.route({ + method: "POST", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean(), + orgSlug: z.string() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + + handler: async (req) => { + const oidc = await server.services.oidc.createOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); }; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts index d7145f453..24d3d0e4c 100644 --- a/backend/src/services/oidc/oidc-config-types.ts +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -13,6 +13,18 @@ export type TGetOidcCfgDTO = { orgSlug: string; } & TGenericPermission; +export type TCreateOidcCfgDTO = { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +} & TGenericPermission; + export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx new file mode 100644 index 000000000..44d976170 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { oidcConfigKeys } from "./queries"; + +export const useUpdateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer?: string; + authorizationEndpoint?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId?: string; + clientSecret?: string; + isActive?: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.patch("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + orgSlug, + clientSecret, + isActive + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; + +export const useCreateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.post("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx new file mode 100644 index 000000000..55b7924a0 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -0,0 +1,245 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addOIDC"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void; +}; + +const schema = z.object({ + issuer: z.string().min(1), + authorizationEndpoint: z.string().min(1), + jwksUri: z.string().min(1), + tokenEndpoint: z.string().min(1), + userinfoEndpoint: z.string().min(1), + clientId: z.string().min(1), + clientSecret: z.string().min(1) +}); + +export type OIDCFormData = z.infer; + +export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateOIDCConfig(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateOIDCConfig(); + const { data } = useGetOIDCConfig(currentOrg?.slug ?? ""); + + const { control, handleSubmit, reset, setValue } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (data) { + setValue("issuer", data.issuer); + setValue("authorizationEndpoint", data.authorizationEndpoint); + setValue("jwksUri", data.jwksUri); + setValue("tokenEndpoint", data.tokenEndpoint); + setValue("userinfoEndpoint", data.userinfoEndpoint); + setValue("clientId", data.clientId); + setValue("clientSecret", data.clientSecret); + } + }, [data]); + + const onOIDCModalSubmit = async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: OIDCFormData) => { + try { + if (!currentOrg) return; + + if (!data) { + await createMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } else { + await updateMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } + + handlePopUpClose("addOIDC"); + + createNotification({ + text: `Successfully ${!data ? "added" : "updated"} OIDC SSO configuration`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${!data ? "add" : "update"} OIDC SSO configuration`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addOIDC", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 322798d16..31d6987bd 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -3,6 +3,7 @@ import { withPermission } from "@app/hoc"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; import { OrgLDAPSection } from "./OrgLDAPSection"; +import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; @@ -12,6 +13,7 @@ export const OrgAuthTab = withPermission(
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx new file mode 100644 index 000000000..41d7b7416 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -0,0 +1,101 @@ +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { OIDCModal } from "./OIDCModal"; + +export const OrgOIDCSection = (): JSX.Element => { + const { currentOrg } = useOrganization(); + + const { data, isLoading } = useGetOIDCConfig(currentOrg?.slug ?? ""); + const { mutateAsync } = useUpdateOIDCConfig(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addOIDC" + ] as const); + + const handleOIDCToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + + await mutateAsync({ + orgSlug: currentOrg?.slug, + isActive: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} OIDC SSO`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${value ? "enable" : "disable"} OIDC SSO`, + type: "error" + }); + } + }; + + const addOidcButtonClick = async () => { + try { + handlePopUpOpen("addOIDC"); + } catch (err) { + console.error(err); + } + }; + + return ( + <> +
+
+
+

OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + + )} + + )} +
+

Manage OIDC authentication configuration

+
+ {data && ( +
+
+

Enable OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + handleOIDCToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed} + /> + )} + + )} +
+

+ Allow members to authenticate into Infisical with OIDC +

+
+ )} + + + ); +}; From 56cc248425317de570e845006247411a17550144 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 17 Jun 2024 23:28:27 +0800 Subject: [PATCH 007/302] feature: finalized oidc core service methods --- .../src/services/oidc/oidc-config-service.ts | 356 +++++++++++++++++- .../src/services/oidc/oidc-config-types.ts | 13 +- frontend/src/hooks/api/oidcConfig/queries.tsx | 6 +- frontend/src/hooks/api/oidcConfig/types.ts | 12 + 4 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/api/oidcConfig/types.ts diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/services/oidc/oidc-config-service.ts index 9634a24cc..d80b506d2 100644 --- a/backend/src/services/oidc/oidc-config-service.ts +++ b/backend/src/services/oidc/oidc-config-service.ts @@ -1,13 +1,28 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; +import { Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; +import { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgBotDALFactory } from "../org/org-bot-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; @@ -15,7 +30,8 @@ import { TUserDALFactory } from "../user/user-dal"; import { normalizeUsername } from "../user/user-fns"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; -import { TOidcLoginDTO } from "./oidc-config-types"; +import { TOidcConfigDALFactory } from "./oidc-config-dal"; +import { TCreateOidcCfgDTO, TGetOidcCfgDTO, TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; type TOidcConfigServiceFactoryDep = { userDAL: Pick; @@ -25,9 +41,12 @@ type TOidcConfigServiceFactoryDep = { "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; orgMembershipDAL: Pick; + orgBotDAL: Pick; licenseService: Pick; tokenService: Pick; smtpService: Pick; + permissionService: Pick; + oidcConfigDAL: Pick; }; export type TOidcConfigServiceFactory = ReturnType; @@ -38,8 +57,11 @@ export const oidcConfigServiceFactory = ({ userDAL, userAliasDAL, licenseService, + permissionService, tokenService, - smtpService + orgBotDAL, + smtpService, + oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { const appCfg = getConfig(); @@ -196,5 +218,331 @@ export const oidcConfigServiceFactory = ({ return { isUserCompleted, providerAuthToken }; }; - return { oidcLogin }; + const getOidc = async (dto: TGetOidcCfgDTO) => { + const org = await orgDAL.findOne({ slug: dto.orgSlug }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found", + name: "OrgNotFound" + }); + } + if (dto.type === "external") { + const { permission } = await permissionService.getOrgPermission( + dto.actor, + dto.actorId, + org.id, + dto.actorAuthMethod, + dto.actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + } + + const oidcCfg = await oidcConfigDAL.findOne({ + orgId: org.id + }); + + if (!oidcCfg) { + throw new BadRequestError({ + message: "Failed to find organization OIDC configuration" + }); + } + + // decrypt and return cfg + const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = + oidcCfg; + + let clientId = ""; + if (encryptedClientId && clientIdIV && clientIdTag) { + clientId = decryptSymmetric({ + ciphertext: encryptedClientId, + key, + tag: clientIdTag, + iv: clientIdIV + }); + } + + let clientSecret = ""; + if (encryptedClientSecret && clientSecretIV && clientSecretTag) { + clientSecret = decryptSymmetric({ + key, + tag: clientSecretTag, + iv: clientSecretIV, + ciphertext: encryptedClientSecret + }); + } + + return { + id: oidcCfg.id, + issuer: oidcCfg.issuer, + authorizationEndpoint: oidcCfg.authorizationEndpoint, + jwksUri: oidcCfg.jwksUri, + tokenEndpoint: oidcCfg.tokenEndpoint, + userinfoEndpoint: oidcCfg.userinfoEndpoint, + orgId: oidcCfg.orgId, + isActive: oidcCfg.isActive, + clientId, + clientSecret + }; + }; + + const updateOidcCfg = async ({ + orgSlug, + actor, + actorOrgId, + actorAuthMethod, + actorId, + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: TUpdateOidcCfgDTO) => { + const org = await orgDAL.findOne({ + slug: orgSlug + }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + org.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + + const orgBot = await orgBotDAL.findOne({ orgId: org.id }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const updateQuery: TOidcConfigsUpdate = { + issuer, + authorizationEndpoint, + tokenEndpoint, + userinfoEndpoint, + jwksUri, + isActive + }; + + if (clientId !== undefined) { + const { ciphertext: encryptedClientId, iv: clientIdIV, tag: clientIdTag } = encryptSymmetric(clientId, key); + updateQuery.encryptedClientId = encryptedClientId; + updateQuery.clientIdIV = clientIdIV; + updateQuery.clientIdTag = clientIdTag; + } + + if (clientSecret !== undefined) { + const { + ciphertext: encryptedClientSecret, + iv: clientSecretIV, + tag: clientSecretTag + } = encryptSymmetric(clientSecret, key); + + updateQuery.encryptedClientSecret = encryptedClientSecret; + updateQuery.clientSecretIV = clientSecretIV; + updateQuery.clientSecretTag = clientSecretTag; + } + + const [ssoConfig] = await oidcConfigDAL.update({ orgId: org.id }, updateQuery); + + return ssoConfig; + }; + + const createOidcCfg = async ({ + orgSlug, + actor, + actorOrgId, + actorAuthMethod, + actorId, + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: TCreateOidcCfgDTO) => { + const org = await orgDAL.findOne({ + slug: orgSlug + }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + org.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: org.id }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: org.id, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedClientId, iv: clientIdIV, tag: clientIdTag } = encryptSymmetric(clientId, key); + const { + ciphertext: encryptedClientSecret, + iv: clientSecretIV, + tag: clientSecretTag + } = encryptSymmetric(clientSecret, key); + + const oidcCfg = await oidcConfigDAL.create({ + issuer, + isActive, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + orgId: org.id, + encryptedClientId, + clientIdIV, + clientIdTag, + encryptedClientSecret, + clientSecretIV, + clientSecretTag + }); + + return oidcCfg; + }; + + const getOrgAuthStrategy = async (orgSlug: string) => { + const appCfg = getConfig(); + + const org = await orgDAL.findOne({ + slug: orgSlug + }); + + if (!org) { + throw new BadRequestError({ + message: "Organization not found." + }); + } + + const oidcCfg = await getOidc({ + type: "internal", + orgSlug + }); + + const openIdIssuer = new OpenIdIssuer({ + issuer: oidcCfg.issuer, + authorization_endpoint: oidcCfg.authorizationEndpoint, + jwks_uri: oidcCfg.jwksUri, + token_endpoint: oidcCfg.tokenEndpoint, + userinfo_endpoint: oidcCfg.userinfoEndpoint + }); + + const client = new openIdIssuer.Client({ + client_id: oidcCfg.clientId, + client_secret: oidcCfg.clientSecret, + redirect_uris: [`${appCfg.SITE_URL}/api/v1/oidc/callback`] + }); + + const strategy = new OpenIdStrategy( + { + client, + passReqToCallback: true + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_req: any, tokenSet: TokenSet, cb: any) => { + const claims = tokenSet.claims(); + if (!claims.email || !claims.given_name) { + throw new BadRequestError({ + message: "Invalid request. Missing email or first name" + }); + } + + oidcLogin({ + email: claims.email, + externalId: claims.sub, + firstName: claims.given_name ?? "", + lastName: claims.family_name ?? "", + orgId: org.id + }) + .then(({ isUserCompleted, providerAuthToken }) => { + cb(null, { isUserCompleted, providerAuthToken }); + }) + .catch((error) => { + cb(error); + }); + } + ); + + return strategy; + }; + + return { oidcLogin, getOrgAuthStrategy, getOidc, updateOidcCfg, createOidcCfg }; }; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts index 24d3d0e4c..a160b272a 100644 --- a/backend/src/services/oidc/oidc-config-types.ts +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -8,10 +8,15 @@ export type TOidcLoginDTO = { orgId: string; }; -export type TGetOidcCfgDTO = { - type: "internal" | "external"; - orgSlug: string; -} & TGenericPermission; +export type TGetOidcCfgDTO = + | ({ + type: "external"; + orgSlug: string; + } & TGenericPermission) + | { + type: "internal"; + orgSlug: string; + }; export type TCreateOidcCfgDTO = { issuer: string; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx index 4a9773fd5..590c39848 100644 --- a/frontend/src/hooks/api/oidcConfig/queries.tsx +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { OIDCConfigData } from "./types"; + export const oidcConfigKeys = { getOIDCConfig: (orgSlug: string) => [{ orgSlug }, "organization-oidc"] as const }; @@ -10,7 +12,9 @@ export const useGetOIDCConfig = (orgSlug: string) => { return useQuery({ queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), queryFn: async () => { - const { data } = await apiRequest.get(`/api/v1/oidc/config?orgSlug=${orgSlug}`); + const { data } = await apiRequest.get( + `/api/v1/oidc/config?orgSlug=${orgSlug}` + ); return data; }, diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts new file mode 100644 index 000000000..579590e09 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -0,0 +1,12 @@ +export type OIDCConfigData = { + id: string; + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + isActive: boolean; + orgId: string; + clientId: string; + clientSecret: string; +}; From 2c237ee2775796c634397336b836d4eca194c591 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 00:53:56 +0800 Subject: [PATCH 008/302] feat: moved oidc to ee directory --- backend/src/@types/fastify.d.ts | 2 +- backend/src/ee/routes/v1/index.ts | 2 ++ .../{server => ee}/routes/v1/oidc-router.ts | 4 ++-- .../{ => ee}/services/oidc/oidc-config-dal.ts | 0 .../services/oidc/oidc-config-service.ts | 22 +++++++++---------- .../services/oidc/oidc-config-types.ts | 0 backend/src/server/routes/index.ts | 4 ++-- backend/src/server/routes/v1/index.ts | 2 -- 8 files changed, 18 insertions(+), 18 deletions(-) rename backend/src/{server => ee}/routes/v1/oidc-router.ts (98%) rename backend/src/{ => ee}/services/oidc/oidc-config-dal.ts (100%) rename backend/src/{ => ee}/services/oidc/oidc-config-service.ts (95%) rename backend/src/{ => ee}/services/oidc/oidc-config-types.ts (100%) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 06cca28b3..80080f6c5 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -12,6 +12,7 @@ import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; @@ -40,7 +41,6 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; -import { TOidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 16e23eb88..de59c22aa 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -7,6 +7,7 @@ import { registerGroupRouter } from "./group-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; +import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; @@ -55,6 +56,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts similarity index 98% rename from backend/src/server/routes/v1/oidc-router.ts rename to backend/src/ee/routes/v1/oidc-router.ts index 05c2bd90e..49d1d5223 100644 --- a/backend/src/server/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -33,13 +33,13 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { url: "/login", method: "GET", schema: { - params: z.object({ + querystring: z.object({ orgSlug: z.string().trim() }) }, handler: async (req, res) => { // get params, save to session - const { orgSlug } = req.params; + const { orgSlug } = req.query; req.session.set("oidcOrgSlug", orgSlug); const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); ( diff --git a/backend/src/services/oidc/oidc-config-dal.ts b/backend/src/ee/services/oidc/oidc-config-dal.ts similarity index 100% rename from backend/src/services/oidc/oidc-config-dal.ts rename to backend/src/ee/services/oidc/oidc-config-dal.ts diff --git a/backend/src/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts similarity index 95% rename from backend/src/services/oidc/oidc-config-service.ts rename to backend/src/ee/services/oidc/oidc-config-service.ts index d80b506d2..049dee52b 100644 --- a/backend/src/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -18,18 +18,18 @@ import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; -import { AuthMethod, AuthTokenType } from "../auth/auth-type"; -import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenType } from "../auth-token/auth-token-types"; -import { TOrgBotDALFactory } from "../org/org-bot-dal"; -import { TOrgDALFactory } from "../org/org-dal"; -import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; -import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; -import { TUserDALFactory } from "../user/user-dal"; -import { normalizeUsername } from "../user/user-fns"; -import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; -import { UserAliasType } from "../user-alias/user-alias-types"; import { TOidcConfigDALFactory } from "./oidc-config-dal"; import { TCreateOidcCfgDTO, TGetOidcCfgDTO, TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts similarity index 100% rename from backend/src/services/oidc/oidc-config-types.ts rename to backend/src/ee/services/oidc/oidc-config-types.ts diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0bf4d0ae5..925ae5f39 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -30,6 +30,8 @@ import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-conf import { ldapGroupMapDALFactory } from "@app/ee/services/ldap-config/ldap-group-map-dal"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; +import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; +import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; @@ -101,8 +103,6 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; -import { oidcConfigDALFactory } from "@app/services/oidc/oidc-config-dal"; -import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 4b8480cd5..fd8255e63 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,7 +11,6 @@ import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; -import { registerOidcRouter } from "./oidc-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -30,7 +29,6 @@ import { registerWebhookRouter } from "./webhook-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); - await server.register(registerOidcRouter, { prefix: "/oidc" }); await server.register( async (authRouter) => { await authRouter.register(registerAuthRoutes); From d79ffbe37e53358374b729c2ecbe0d61ad5f2bda Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 01:11:57 +0800 Subject: [PATCH 009/302] misc: added license checks for oidc sso --- .../src/ee/services/license/licence-fns.ts | 1 + .../src/ee/services/license/license-types.ts | 1 + .../ee/services/oidc/oidc-config-service.ts | 18 ++++++++++-- frontend/src/hooks/api/subscriptions/types.ts | 17 ++++++----- .../components/OrgAuthTab/OrgOIDCSection.tsx | 29 +++++++++++++++---- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..c9a002dd5 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -27,6 +27,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + oidcSSO: false, scim: false, ldap: false, groups: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 0c8fdc197..6d8ac6a64 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -44,6 +44,7 @@ export type TFeatureSet = { auditLogStreams: false; auditLogStreamLimit: 3; samlSSO: false; + oidcSSO: false; scim: false; ldap: false; groups: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 049dee52b..323ccb20a 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -196,9 +196,6 @@ export const oidcConfigServiceFactory = ({ } ); - // TODO: Sheen update oidc config - // await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, @@ -315,12 +312,20 @@ export const oidcConfigServiceFactory = ({ const org = await orgDAL.findOne({ slug: orgSlug }); + if (!org) { throw new BadRequestError({ message: "Organization not found" }); } + const plan = await licenseService.getPlan(org.id); + if (!plan.oidcSSO) + throw new BadRequestError({ + message: + "Failed to update OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -396,6 +401,13 @@ export const oidcConfigServiceFactory = ({ }); } + const plan = await licenseService.getPlan(org.id); + if (!plan.oidcSSO) + throw new BadRequestError({ + message: + "Failed to create OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + const { permission } = await permissionService.getOrgPermission( actor, actorId, diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 45414292d..7ce074344 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -21,18 +21,19 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + oidcSSO: boolean; scim: boolean; ldap: boolean; groups: boolean; status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | null; + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | null; trial_end: number | null; has_used_trial: boolean; }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx index 41d7b7416..ee09e3fe9 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -1,7 +1,12 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, Switch } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { Button, Switch, UpgradePlanModal } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; import { useGetOIDCConfig } from "@app/hooks/api"; import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -10,17 +15,24 @@ import { OIDCModal } from "./OIDCModal"; export const OrgOIDCSection = (): JSX.Element => { const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); const { data, isLoading } = useGetOIDCConfig(currentOrg?.slug ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "addOIDC" + "addOIDC", + "upgradePlan" ] as const); const handleOIDCToggle = async (value: boolean) => { try { if (!currentOrg?.id) return; + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + await mutateAsync({ orgSlug: currentOrg?.slug, isActive: value @@ -40,10 +52,10 @@ export const OrgOIDCSection = (): JSX.Element => { }; const addOidcButtonClick = async () => { - try { + if (subscription?.oidcSSO && currentOrg) { handlePopUpOpen("addOIDC"); - } catch (err) { - console.error(err); + } else { + handlePopUpOpen("upgradePlan"); } }; @@ -96,6 +108,11 @@ export const OrgOIDCSection = (): JSX.Element => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use OIDC SSO if you switch to Infisical's Pro plan." + /> ); }; From 4f2f7b2f70a37735369f0e4a7b379b97f5c37fbe Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 01:21:42 +0800 Subject: [PATCH 010/302] misc: moved oidc endpoints to /sso --- backend/src/ee/routes/v1/index.ts | 9 +++++++-- frontend/src/hooks/api/oidcConfig/mutations.tsx | 4 ++-- frontend/src/hooks/api/oidcConfig/queries.tsx | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index de59c22aa..7431ec609 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -55,8 +55,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/dynamic-secrets" } ); - await server.register(registerSamlRouter, { prefix: "/sso" }); - await server.register(registerOidcRouter, { prefix: "/oidc" }); + await server.register( + async (ssoRouter) => { + await ssoRouter.register(registerSamlRouter); + await ssoRouter.register(registerOidcRouter, { prefix: "/oidc" }); + }, + { prefix: "/sso" } + ); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index 44d976170..bfcf92d1f 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -28,7 +28,7 @@ export const useUpdateOIDCConfig = () => { isActive?: boolean; orgSlug: string; }) => { - const { data } = await apiRequest.patch("/api/v1/oidc/config", { + const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, jwksUri, @@ -72,7 +72,7 @@ export const useCreateOIDCConfig = () => { isActive: boolean; orgSlug: string; }) => { - const { data } = await apiRequest.post("/api/v1/oidc/config", { + const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, jwksUri, diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx index 590c39848..08b38cbb8 100644 --- a/frontend/src/hooks/api/oidcConfig/queries.tsx +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -13,7 +13,7 @@ export const useGetOIDCConfig = (orgSlug: string) => { queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), queryFn: async () => { const { data } = await apiRequest.get( - `/api/v1/oidc/config?orgSlug=${orgSlug}` + `/api/v1/sso/oidc/config?orgSlug=${orgSlug}` ); return data; From df51d05c46b40047297ef84bb403930f65305ec9 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 02:10:08 +0800 Subject: [PATCH 011/302] feat: integrated oidc with sso login --- backend/src/ee/routes/v1/oidc-router.ts | 32 +++++++++---------- .../ee/services/oidc/oidc-config-service.ts | 2 +- frontend/src/views/Login/Login.tsx | 6 ++-- .../components/InitialStep/InitialStep.tsx | 25 ++++++++++++--- .../Login/components/SAMLSSOStep/index.tsx | 1 - .../SAMLSSOStep.tsx => SSOStep/SSOStep.tsx} | 27 ++++++++++------ .../views/Login/components/SSOStep/index.tsx | 1 + frontend/src/views/Login/components/index.tsx | 2 +- 8 files changed, 60 insertions(+), 36 deletions(-) delete mode 100644 frontend/src/views/Login/components/SAMLSSOStep/index.tsx rename frontend/src/views/Login/components/{SAMLSSOStep/SAMLSSOStep.tsx => SSOStep/SSOStep.tsx} (65%) create mode 100644 frontend/src/views/Login/components/SSOStep/index.tsx diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 49d1d5223..00844309d 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -59,7 +59,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); await ( passport.authenticate(oidcStrategy as Strategy, { - failureRedirect: "/api/v1/oidc/login/error", + failureRedirect: "/api/v1/sso/oidc/login/error", session: false, failureMessage: true }) as any @@ -137,13 +137,13 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z .object({ - issuer: z.string(), - authorizationEndpoint: z.string(), - jwksUri: z.string(), - tokenEndpoint: z.string(), - userinfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), + issuer: z.string().trim(), + authorizationEndpoint: z.string().trim(), + jwksUri: z.string().trim(), + tokenEndpoint: z.string().trim(), + userinfoEndpoint: z.string().trim(), + clientId: z.string().trim(), + clientSecret: z.string().trim(), isActive: z.boolean() }) .partial() @@ -182,15 +182,15 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ - issuer: z.string(), - authorizationEndpoint: z.string(), - jwksUri: z.string(), - tokenEndpoint: z.string(), - userinfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), + issuer: z.string().trim(), + authorizationEndpoint: z.string().trim(), + jwksUri: z.string().trim(), + tokenEndpoint: z.string().trim(), + userinfoEndpoint: z.string().trim(), + clientId: z.string().trim(), + clientSecret: z.string().trim(), isActive: z.boolean(), - orgSlug: z.string() + orgSlug: z.string().trim() }), response: { 200: OidcConfigsSchema.pick({ diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 323ccb20a..03d74ea16 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -520,7 +520,7 @@ export const oidcConfigServiceFactory = ({ const client = new openIdIssuer.Client({ client_id: oidcCfg.clientId, client_secret: oidcCfg.clientSecret, - redirect_uris: [`${appCfg.SITE_URL}/api/v1/oidc/callback`] + redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`] }); const strategy = new OpenIdStrategy( diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index 04a24d233..cad207aa8 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { isLoggedIn } from "@app/reactQuery"; -import { InitialStep, MFAStep, SAMLSSOStep } from "./components"; +import { InitialStep, MFAStep, SSOStep } from "./components"; import { navigateUserToSelectOrg } from "./Login.utils"; export const Login = () => { @@ -57,7 +57,9 @@ export const Login = () => { /> ); case 2: - return ; + return ; + case 3: + return ; default: return
; } diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 6e2c788ae..82bee7eb4 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -40,11 +40,13 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const { data: serverDetails } = useFetchServerStatus(); useEffect(() => { - if (serverDetails?.samlDefaultOrgSlug){ - const callbackPort = queryParams.get("callback_port"); - const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${serverDetails?.samlDefaultOrgSlug}${callbackPort ? `?callback_port=${callbackPort}` : ""}` - router.push(redirectUrl); - } + if (serverDetails?.samlDefaultOrgSlug) { + const callbackPort = queryParams.get("callback_port"); + const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${ + serverDetails?.samlDefaultOrgSlug + }${callbackPort ? `?callback_port=${callbackPort}` : ""}`; + router.push(redirectUrl); + } }, [serverDetails?.samlDefaultOrgSlug]); const handleLogin = async (e: FormEvent) => { @@ -217,6 +219,19 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Continue with SAML
+
+ +
diff --git a/frontend/src/views/Login/components/SSOStep/index.tsx b/frontend/src/views/Login/components/SSOStep/index.tsx new file mode 100644 index 000000000..e7d80b2c0 --- /dev/null +++ b/frontend/src/views/Login/components/SSOStep/index.tsx @@ -0,0 +1 @@ +export { SSOStep } from "./SSOStep"; diff --git a/frontend/src/views/Login/components/index.tsx b/frontend/src/views/Login/components/index.tsx index 7c55c4acf..296b0503e 100644 --- a/frontend/src/views/Login/components/index.tsx +++ b/frontend/src/views/Login/components/index.tsx @@ -1,6 +1,6 @@ export { InitialStep } from "./InitialStep"; export { MFAStep } from "./MFAStep"; -export { SAMLSSOStep } from "./SAMLSSOStep"; +export { SSOStep } from "./SSOStep"; // SSO-specific step export { PasswordStep } from "./PasswordStep"; From 96ad3b02643ece9313038c346d649c639ccf206c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 14:20:04 +0800 Subject: [PATCH 012/302] misc: used redis for oic session managemen --- backend/package-lock.json | 91 +++++++++++++++++++++++++ backend/package.json | 1 + backend/src/ee/routes/v1/oidc-router.ts | 13 +++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index b0ff466c2..4e22ed5a1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -36,6 +36,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "connect-redis": "^7.1.1", "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", @@ -6633,6 +6634,17 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/connect-redis": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.1.tgz", + "integrity": "sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "express-session": ">=1" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -7739,6 +7751,55 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "peer": true, + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "peer": true + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "peer": true + }, "node_modules/express/node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -10737,6 +10798,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11813,6 +11883,15 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13871,6 +13950,18 @@ "node": ">=0.8.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "peer": true, + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", diff --git a/backend/package.json b/backend/package.json index 7aecc01bb..dbd55705e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -97,6 +97,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "connect-redis": "^7.1.1", "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 00844309d..c5433c008 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -8,6 +8,8 @@ import { Authenticator, Strategy } from "@fastify/passport"; import fastifySession from "@fastify/session"; +import RedisStore from "connect-redis"; +import { Redis } from "ioredis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; @@ -18,13 +20,22 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + const redisStore = new RedisStore({ + client: redis, + prefix: "oidc-session:", + ttl: 600 // 10 minutes + }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, cookie: { - secure: false // has to be set to false if testing locally + secure: false // set to true in production } }); + await server.register(passport.initialize()); await server.register(passport.secureSession()); From 8de4443be172111eaadeee70a7f6a978c49fc4b6 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:26:08 +0800 Subject: [PATCH 013/302] feat: added support for login via cli --- backend/src/ee/routes/v1/oidc-router.ts | 22 ++++++++++++++----- .../ee/services/oidc/oidc-config-service.ts | 10 +++++---- .../src/ee/services/oidc/oidc-config-types.ts | 1 + .../Login/components/SSOStep/SSOStep.tsx | 7 ++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index c5433c008..55d3be19d 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -45,14 +45,20 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - orgSlug: z.string().trim() + orgSlug: z.string().trim(), + callbackPort: z.string().trim().optional() }) }, handler: async (req, res) => { // get params, save to session - const { orgSlug } = req.query; + const { orgSlug, callbackPort } = req.query; req.session.set("oidcOrgSlug", orgSlug); - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug); + + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug, callbackPort); ( passport.authenticate(oidcStrategy as Strategy, { scope: "profile email openid" @@ -67,7 +73,9 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { method: "GET", handler: async (req, res) => { const oidcOrgSlug = req.session.get("oidcOrgSlug"); - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug); + const callbackPort = req.session.get("callbackPort"); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug, callbackPort); + await ( passport.authenticate(oidcStrategy as Strategy, { failureRedirect: "/api/v1/sso/oidc/login/error", @@ -76,6 +84,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { }) as any )(req, res); + await req.session.destroy(); + if (req.passportUser.isUserCompleted) { return res.redirect( `http://localhost:8080/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -92,7 +102,9 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { server.route({ url: "/login/error", method: "GET", - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); + return res.status(500).send({ error: "Authentication error", details: req.query diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 03d74ea16..b880c8664 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -63,7 +63,7 @@ export const oidcConfigServiceFactory = ({ smtpService, oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { - const oidcLogin = async ({ externalId, email, firstName, lastName, orgId }: TOidcLoginDTO) => { + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ externalId, @@ -188,7 +188,8 @@ export const oidcConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, - isUserCompleted + isUserCompleted, + ...(callbackPort && { callbackPort }) }, appCfg.AUTH_SECRET, { @@ -491,7 +492,7 @@ export const oidcConfigServiceFactory = ({ return oidcCfg; }; - const getOrgAuthStrategy = async (orgSlug: string) => { + const getOrgAuthStrategy = async (orgSlug: string, callbackPort?: string) => { const appCfg = getConfig(); const org = await orgDAL.findOne({ @@ -542,7 +543,8 @@ export const oidcConfigServiceFactory = ({ externalId: claims.sub, firstName: claims.given_name ?? "", lastName: claims.family_name ?? "", - orgId: org.id + orgId: org.id, + callbackPort }) .then(({ isUserCompleted, providerAuthToken }) => { cb(null, { isUserCompleted, providerAuthToken }); diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index a160b272a..4c729ceea 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -6,6 +6,7 @@ export type TOidcLoginDTO = { firstName: string; lastName?: string; orgId: string; + callbackPort?: string; }; export type TGetOidcCfgDTO = diff --git a/frontend/src/views/Login/components/SSOStep/SSOStep.tsx b/frontend/src/views/Login/components/SSOStep/SSOStep.tsx index 5e70cf1a7..9663c04a9 100644 --- a/frontend/src/views/Login/components/SSOStep/SSOStep.tsx +++ b/frontend/src/views/Login/components/SSOStep/SSOStep.tsx @@ -24,8 +24,11 @@ export const SSOStep = ({ setStep, type }: Props) => { }` ); } else { - // TODO: Sheen - Add callback support for CLI login - window.open(`/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}`); + window.open( + `/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}${ + callbackPort ? `&callbackPort=${callbackPort}` : "" + }` + ); } window.close(); From c5c00b520c7378887fa0111b5cbb8da57d6fe226 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:35:18 +0800 Subject: [PATCH 014/302] misc: added session regenerate for fresh state --- backend/src/ee/routes/v1/oidc-router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 55d3be19d..36ad8a085 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -50,8 +50,11 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { }) }, handler: async (req, res) => { - // get params, save to session const { orgSlug, callbackPort } = req.query; + + // ensure fresh session state per login attempt + await req.session.regenerate(); + req.session.set("oidcOrgSlug", orgSlug); if (callbackPort) { From 371b96a13af8683bb22994876d494e22c87a3ced Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 15:36:49 +0800 Subject: [PATCH 015/302] misc: removed cookie path proxy for dev envs --- nginx/default.dev.conf | 2 -- 1 file changed, 2 deletions(-) diff --git a/nginx/default.dev.conf b/nginx/default.dev.conf index 94c5d7b67..0bdb335ac 100644 --- a/nginx/default.dev.conf +++ b/nginx/default.dev.conf @@ -10,8 +10,6 @@ server { proxy_pass http://backend:4000; proxy_redirect off; - - proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; } location / { From bcd65333c0a032a05f7200767b8f2cd02f322caa Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 19:17:54 +0800 Subject: [PATCH 016/302] misc: added handling of inactive and undefined oidc config --- backend/src/ee/services/oidc/oidc-config-service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b880c8664..7c4b7e87e 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -510,6 +510,12 @@ export const oidcConfigServiceFactory = ({ orgSlug }); + if (!oidcCfg || !oidcCfg.isActive) { + throw new BadRequestError({ + message: "Failed to authenticate with OIDC SSO" + }); + } + const openIdIssuer = new OpenIdIssuer({ issuer: oidcCfg.issuer, authorization_endpoint: oidcCfg.authorizationEndpoint, From bdc7c018eb5e38b4085689943cd46ccdc7e916df Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 18 Jun 2024 20:36:09 +0800 Subject: [PATCH 017/302] misc: added comment regarding session and redis usage --- backend/src/ee/routes/v1/oidc-router.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 36ad8a085..8b68a8aef 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -22,6 +22,12 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); + + /* + - OIDC protocol cannot work without sessions: https://github.com/panva/node-openid-client/issues/190 + - Current redis usage is not ideal and will eventually have to be refactored to use a better structure + - Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js + */ const redisStore = new RedisStore({ client: redis, prefix: "oidc-session:", From 18e69578f03240fd83043d850d9d862270b2caa3 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Jun 2024 01:29:26 +0800 Subject: [PATCH 018/302] feat: added support for limiting email domains --- .../20240617041053_add-oidc-auth.ts | 1 + backend/src/db/schemas/oidc-configs.ts | 1 + backend/src/ee/routes/v1/oidc-router.ts | 33 +++- .../ee/services/oidc/oidc-config-service.ts | 173 ++++++++++-------- .../src/ee/services/oidc/oidc-config-types.ts | 2 + .../src/hooks/api/oidcConfig/mutations.tsx | 6 + frontend/src/hooks/api/oidcConfig/types.ts | 1 + .../components/OrgAuthTab/OIDCModal.tsx | 20 +- 8 files changed, 154 insertions(+), 83 deletions(-) diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index b11e6bbab..8ef34854b 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -17,6 +17,7 @@ export async function up(knex: Knex): Promise { tb.text("encryptedClientSecret").notNullable(); tb.string("clientSecretIV").notNullable(); tb.string("clientSecretTag").notNullable(); + tb.string("allowedEmailDomains").nullable(); tb.boolean("isActive").notNullable(); tb.timestamps(true, true, true); tb.uuid("orgId").notNullable().unique(); diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index b06114df6..3de22d1d0 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -20,6 +20,7 @@ export const OidcConfigsSchema = z.object({ encryptedClientSecret: z.string(), clientSecretIV: z.string(), clientSecretTag: z.string(), + allowedEmailDomains: z.string().nullable().optional(), isActive: z.boolean(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 8b68a8aef..d64704dce 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -137,7 +137,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, isActive: true, - orgId: true + orgId: true, + allowedEmailDomains: true }).extend({ clientId: z.string(), clientSecret: z.string() @@ -169,6 +170,19 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z .object({ + allowedEmailDomains: z + .string() + .trim() + .optional() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }), issuer: z.string().trim(), authorizationEndpoint: z.string().trim(), jwksUri: z.string().trim(), @@ -189,6 +203,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, orgId: true, + allowedEmailDomains: true, isActive: true }) } @@ -215,6 +230,19 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ issuer: z.string().trim(), + allowedEmailDomains: z + .string() + .trim() + .optional() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }), authorizationEndpoint: z.string().trim(), jwksUri: z.string().trim(), tokenEndpoint: z.string().trim(), @@ -233,7 +261,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { tokenEndpoint: true, userinfoEndpoint: true, orgId: true, - isActive: true + isActive: true, + allowedEmailDomains: true }) } }, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 7c4b7e87e..3f58036b6 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -63,6 +63,86 @@ export const oidcConfigServiceFactory = ({ smtpService, oidcConfigDAL }: TOidcConfigServiceFactoryDep) => { + const getOidc = async (dto: TGetOidcCfgDTO) => { + const org = await orgDAL.findOne({ slug: dto.orgSlug }); + if (!org) { + throw new BadRequestError({ + message: "Organization not found", + name: "OrgNotFound" + }); + } + if (dto.type === "external") { + const { permission } = await permissionService.getOrgPermission( + dto.actor, + dto.actorId, + org.id, + dto.actorAuthMethod, + dto.actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + } + + const oidcCfg = await oidcConfigDAL.findOne({ + orgId: org.id + }); + + if (!oidcCfg) { + throw new BadRequestError({ + message: "Failed to find organization OIDC configuration" + }); + } + + // decrypt and return cfg + const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = + oidcCfg; + + let clientId = ""; + if (encryptedClientId && clientIdIV && clientIdTag) { + clientId = decryptSymmetric({ + ciphertext: encryptedClientId, + key, + tag: clientIdTag, + iv: clientIdIV + }); + } + + let clientSecret = ""; + if (encryptedClientSecret && clientSecretIV && clientSecretTag) { + clientSecret = decryptSymmetric({ + key, + tag: clientSecretTag, + iv: clientSecretIV, + ciphertext: encryptedClientSecret + }); + } + + return { + id: oidcCfg.id, + issuer: oidcCfg.issuer, + authorizationEndpoint: oidcCfg.authorizationEndpoint, + jwksUri: oidcCfg.jwksUri, + tokenEndpoint: oidcCfg.tokenEndpoint, + userinfoEndpoint: oidcCfg.userinfoEndpoint, + orgId: oidcCfg.orgId, + isActive: oidcCfg.isActive, + allowedEmailDomains: oidcCfg.allowedEmailDomains, + clientId, + clientSecret + }; + }; + const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ @@ -216,87 +296,9 @@ export const oidcConfigServiceFactory = ({ return { isUserCompleted, providerAuthToken }; }; - const getOidc = async (dto: TGetOidcCfgDTO) => { - const org = await orgDAL.findOne({ slug: dto.orgSlug }); - if (!org) { - throw new BadRequestError({ - message: "Organization not found", - name: "OrgNotFound" - }); - } - if (dto.type === "external") { - const { permission } = await permissionService.getOrgPermission( - dto.actor, - dto.actorId, - org.id, - dto.actorAuthMethod, - dto.actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); - } - - const oidcCfg = await oidcConfigDAL.findOne({ - orgId: org.id - }); - - if (!oidcCfg) { - throw new BadRequestError({ - message: "Failed to find organization OIDC configuration" - }); - } - - // decrypt and return cfg - const orgBot = await orgBotDAL.findOne({ orgId: oidcCfg.orgId }); - if (!orgBot) { - throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); - } - - const key = infisicalSymmetricDecrypt({ - ciphertext: orgBot.encryptedSymmetricKey, - iv: orgBot.symmetricKeyIV, - tag: orgBot.symmetricKeyTag, - keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding - }); - - const { encryptedClientId, clientIdIV, clientIdTag, encryptedClientSecret, clientSecretIV, clientSecretTag } = - oidcCfg; - - let clientId = ""; - if (encryptedClientId && clientIdIV && clientIdTag) { - clientId = decryptSymmetric({ - ciphertext: encryptedClientId, - key, - tag: clientIdTag, - iv: clientIdIV - }); - } - - let clientSecret = ""; - if (encryptedClientSecret && clientSecretIV && clientSecretTag) { - clientSecret = decryptSymmetric({ - key, - tag: clientSecretTag, - iv: clientSecretIV, - ciphertext: encryptedClientSecret - }); - } - - return { - id: oidcCfg.id, - issuer: oidcCfg.issuer, - authorizationEndpoint: oidcCfg.authorizationEndpoint, - jwksUri: oidcCfg.jwksUri, - tokenEndpoint: oidcCfg.tokenEndpoint, - userinfoEndpoint: oidcCfg.userinfoEndpoint, - orgId: oidcCfg.orgId, - isActive: oidcCfg.isActive, - clientId, - clientSecret - }; - }; - const updateOidcCfg = async ({ orgSlug, + allowedEmailDomains, actor, actorOrgId, actorAuthMethod, @@ -346,6 +348,7 @@ export const oidcConfigServiceFactory = ({ }); const updateQuery: TOidcConfigsUpdate = { + allowedEmailDomains, issuer, authorizationEndpoint, tokenEndpoint, @@ -374,12 +377,12 @@ export const oidcConfigServiceFactory = ({ } const [ssoConfig] = await oidcConfigDAL.update({ orgId: org.id }, updateQuery); - return ssoConfig; }; const createOidcCfg = async ({ orgSlug, + allowedEmailDomains, actor, actorOrgId, actorAuthMethod, @@ -477,6 +480,7 @@ export const oidcConfigServiceFactory = ({ issuer, isActive, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -544,6 +548,15 @@ export const oidcConfigServiceFactory = ({ }); } + if (oidcCfg.allowedEmailDomains) { + const allowedDomains = oidcCfg.allowedEmailDomains.split(", "); + if (!allowedDomains.includes(claims.email.split("@")[1])) { + throw new BadRequestError({ + message: "Email not allowed." + }); + } + } + oidcLogin({ email: claims.email, externalId: claims.sub, diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index 4c729ceea..f773bbe92 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -22,6 +22,7 @@ export type TGetOidcCfgDTO = export type TCreateOidcCfgDTO = { issuer: string; authorizationEndpoint: string; + allowedEmailDomains: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; @@ -34,6 +35,7 @@ export type TCreateOidcCfgDTO = { export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; + allowedEmailDomains: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index bfcf92d1f..39467e84c 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -13,11 +13,13 @@ export const useUpdateOIDCConfig = () => { jwksUri, tokenEndpoint, userinfoEndpoint, + allowedEmailDomains, clientId, clientSecret, isActive, orgSlug }: { + allowedEmailDomains?: string; issuer?: string; authorizationEndpoint?: string; jwksUri?: string; @@ -30,6 +32,7 @@ export const useUpdateOIDCConfig = () => { }) => { const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, + allowedEmailDomains, authorizationEndpoint, jwksUri, tokenEndpoint, @@ -54,6 +57,7 @@ export const useCreateOIDCConfig = () => { mutationFn: async ({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -71,10 +75,12 @@ export const useCreateOIDCConfig = () => { clientSecret: string; isActive: boolean; orgSlug: string; + allowedEmailDomains?: string; }) => { const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 579590e09..457a86cb7 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -9,4 +9,5 @@ export type OIDCConfigData = { orgId: string; clientId: string; clientSecret: string; + allowedEmailDomains?: string; }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx index 55b7924a0..229f437f9 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -23,7 +23,8 @@ const schema = z.object({ tokenEndpoint: z.string().min(1), userinfoEndpoint: z.string().min(1), clientId: z.string().min(1), - clientSecret: z.string().min(1) + clientSecret: z.string().min(1), + allowedEmailDomains: z.string().optional() }); export type OIDCFormData = z.infer; @@ -48,12 +49,14 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) setValue("userinfoEndpoint", data.userinfoEndpoint); setValue("clientId", data.clientId); setValue("clientSecret", data.clientSecret); + setValue("allowedEmailDomains", data.allowedEmailDomains); } }, [data]); const onOIDCModalSubmit = async ({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -67,6 +70,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) await createMutateAsync({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -79,6 +83,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) await updateMutateAsync({ issuer, authorizationEndpoint, + allowedEmailDomains, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -187,6 +192,19 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) )} /> + ( + + + + )} + /> Date: Wed, 19 Jun 2024 13:46:17 +0800 Subject: [PATCH 019/302] misc: added option for trusting OIDC emails by default --- .../20240617041053_add-oidc-auth.ts | 16 +++++++++ backend/src/db/schemas/super-admin.ts | 3 +- .../ee/services/oidc/oidc-config-service.ts | 18 ++++++++-- backend/src/server/routes/v1/admin-router.ts | 3 +- frontend/src/hooks/api/admin/types.ts | 1 + .../admin/DashboardPage/DashboardPage.tsx | 35 +++++++++++++++---- 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index 8ef34854b..b74f0040a 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -24,8 +24,24 @@ export async function up(knex: Knex): Promise { tb.foreign("orgId").references("id").inTable(TableName.Organization); }); } + + if (await knex.schema.hasTable(TableName.SuperAdmin)) { + if (!(await knex.schema.hasColumn(TableName.SuperAdmin, "trustOidcEmails"))) { + await knex.schema.alterTable(TableName.SuperAdmin, (tb) => { + tb.boolean("trustOidcEmails").defaultTo(false); + }); + } + } } export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.OidcConfig); + + if (await knex.schema.hasTable(TableName.SuperAdmin)) { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustOidcEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustOidcEmails"); + }); + } + } } diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 417d4e05e..87ba35c83 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -16,7 +16,8 @@ export const SuperAdminSchema = z.object({ allowedSignUpDomain: z.string().nullable().optional(), instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), trustSamlEmails: z.boolean().default(false).nullable().optional(), - trustLdapEmails: z.boolean().default(false).nullable().optional() + trustLdapEmails: z.boolean().default(false).nullable().optional(), + trustOidcEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 3f58036b6..546de7d9d 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -25,6 +25,7 @@ import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -144,6 +145,7 @@ export const oidcConfigServiceFactory = ({ }; const oidcLogin = async ({ externalId, email, firstName, lastName, orgId, callbackPort }: TOidcLoginDTO) => { + const serverCfg = await getServerCfg(); const appCfg = getConfig(); const userAlias = await userAliasDAL.findOne({ externalId, @@ -192,14 +194,25 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + + if (serverCfg.trustOidcEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + if (!newUser) { const uniqueUsername = await normalizeUsername(externalId, userDAL); newUser = await userDAL.create( { email, firstName, - isEmailVerified: false, - username: uniqueUsername, + isEmailVerified: serverCfg.trustOidcEmails, + username: serverCfg.trustOidcEmails ? email : uniqueUsername, lastName, authMethods: [], isGhost: false @@ -252,6 +265,7 @@ export const oidcConfigServiceFactory = ({ return newUser; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const isUserCompleted = Boolean(user.isAccepted); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 572409d9b..d8f64b86f 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -51,7 +51,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { allowSignUp: z.boolean().optional(), allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean().optional(), - trustLdapEmails: z.boolean().optional() + trustLdapEmails: z.boolean().optional(), + trustOidcEmails: z.boolean().optional() }), response: { 200: z.object({ diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 6a42e6ed0..80025ab8f 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -5,6 +5,7 @@ export type TServerConfig = { isMigrationModeOn?: boolean; trustSamlEmails: boolean; trustLdapEmails: boolean; + trustOidcEmails: boolean; isSecretScanningDisabled: boolean; }; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 52fddb22c..c5f7db906 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -39,7 +39,8 @@ const formSchema = z.object({ signUpMode: z.nativeEnum(SignUpModes), allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean(), - trustLdapEmails: z.boolean() + trustLdapEmails: z.boolean(), + trustOidcEmails: z.boolean() }); type TDashboardForm = z.infer; @@ -60,7 +61,8 @@ export const AdminDashboardPage = () => { signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, allowedSignUpDomain: config.allowedSignUpDomain, trustSamlEmails: config.trustSamlEmails, - trustLdapEmails: config.trustLdapEmails + trustLdapEmails: config.trustLdapEmails, + trustOidcEmails: config.trustOidcEmails } }); @@ -84,13 +86,15 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = + formData; await updateServerConfig({ allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, - trustLdapEmails + trustLdapEmails, + trustOidcEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -190,9 +194,9 @@ export const AdminDashboardPage = () => {
Trust emails
- Select if you want Infisical to trust external emails from SAML/LDAP identity - providers. If set to false, then Infisical will prompt SAML/LDAP provisioned - users to verify their email upon their first login. + Select if you want Infisical to trust external emails from SAML/LDAP/OIDC + identity providers. If set to false, then Infisical will prompt SAML/LDAP + provisioned users to verify their email upon their first login.
{ ); }} /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust OIDC emails

+
+
+ ); + }} + />
diff --git a/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx index 1efec0c36..2a5162922 100644 --- a/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx +++ b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx @@ -15,9 +15,9 @@ export const ViewAndCopySharedSecret = ({ copyUrlToClipboard: () => void; }) => { return ( -
-
-
+
+
+

{newSharedSecret}

+
Secret Shared | Infisical -
-
- - Infisical logo - -
-

- {id ? "Someone shared a secret on Infisical with you." : "Share Secrets with Infisical"} -

-
- {id && ( - - )} -
- - {isNewSession && ( - - )} - -
-
-
- -
- {!isNewSession && ( -
- -
- )} -
-

- Safe, Secure, & Open Source -

-

- Infisical is the #1 {" "} - - open source - {" "} - secrets management platform for developers.
-

- Infisical Secret Sharing uses end-to-end encrypted architecture to ensure that your secrets are truly private, even from our servers. -

+
+
+
- - Learn More - + Infisical logo
+
+

+ {id ? "Someone shared a secret on Infisical with you" : "Share a secret with Infisical"} +

+
+
+ {id && ( + + )} +
+ + {isNewSession && ( +
+ +
+ )} + {!isNewSession && ( +
+ )} +
+
+
+
+
+

+ Open source secret management for developers +

+
+

+ + Infisical + {" "} is the all-in-one secret management platform to securely manage secrets, configs, and certificates across your team and infrastructure. +

+ + + Try Infisical + + +
+
+
+
-
-

- © 2024{" "} - - Infisical - - . All rights reserved. -
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 -

-
- +
+
+

+ © 2024{" "} + + Infisical + + . All rights reserved. +
+ 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 +

); diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx index d2a17e566..399bdc40d 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -16,7 +16,7 @@ export const SecretTable = ({ isUrlCopied, copyUrlToClipboard }: Props) => ( -
+
{isLoading &&
Loading...
} {!isLoading && !decryptedSecret && ( @@ -37,7 +37,7 @@ export const SecretTable = ({ colorSchema="primary" ariaLabel="copy to clipboard" onClick={copyUrlToClipboard} - className="mx-1 flex max-h-8 items-center rounded" + className="mx-1 flex max-h-8 items-center rounded absolute top-1 sm:top-2 right-0 sm:right-5" size="xs" > Copy From eca6871cbcd362781d5d5413e4e7cb03a9821106 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:31:22 +0200 Subject: [PATCH 058/302] Feat: Show imported values in overview --- .../SecretOverviewTableRow.tsx | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 5e6342d2c..57cc01807 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -14,6 +14,7 @@ import { twMerge } from "tailwind-merge"; import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; import { SecretEditRow } from "./SecretEditRow"; import SecretRenameRow from "./SecretRenameRow"; @@ -30,6 +31,10 @@ type Props = { onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; + getImportedSecretByKey: ( + env: string, + secretName: string + ) => { secret?: DecryptedSecret; environmentInfo?: WorkspaceEnv } | undefined; }; export const SecretOverviewTableRow = ({ @@ -41,6 +46,7 @@ export const SecretOverviewTableRow = ({ onSecretCreate, onSecretDelete, isImportedSecretPresentInEnv, + getImportedSecretByKey, expandableColWidth, onToggleSecretSelect, isSelected @@ -53,8 +59,9 @@ export const SecretOverviewTableRow = ({ <> setIsFormExpanded.toggle()} className="group">
@@ -107,8 +114,8 @@ export const SecretOverviewTableRow = ({ isSecretPresent ? "Present secret" : isSecretImported - ? "Imported secret" - : "Missing secret" + ? "Imported secret" + : "Missing secret" } >
-
+
{name} + {isImportedSecret && ( + + + + )}
@@ -198,7 +214,7 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} isVisible={isSecretVisible} secretName={secretKey} - defaultValue={secret?.value} + defaultValue={secret?.value || importedSecret?.secret?.value} secretId={secret?.id} isImportedSecret={isImportedSecret} isCreatable={isCreatable} From 00d83f913655321d6563282295a608a80dc53574 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:31:29 +0200 Subject: [PATCH 059/302] Update SecretInput.tsx --- frontend/src/components/v2/SecretInput/SecretInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 5e7bbef78..4ba089c46 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -15,7 +15,7 @@ const replaceContentWithDot = (str: string) => { }; const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { - if (isImport) return "IMPORTED"; + if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; if (!isVisible) return replaceContentWithDot(content); From f676b443350b2ad1514ae8406232a0f67d068028 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:31:36 +0200 Subject: [PATCH 060/302] Feat: Show imported values in overview --- .../src/hooks/api/secretImports/queries.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 701a20543..a7c510662 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -279,7 +279,28 @@ export const useGetImportedSecretsAllEnvs = ({ [(secretImports || []).map((response) => response.data)] ); - return { secretImports, isImportedSecretPresentInEnv }; + const getImportedSecretByKey = useCallback( + (envSlug: string, secretName: string) => { + const selectedEnvIndex = environments.indexOf(envSlug); + + if (selectedEnvIndex !== -1) { + const secret = secretImports?.[selectedEnvIndex]?.data?.find(({ secrets }) => + secrets.find((s) => s.key === secretName) + ); + + if (!secret) return undefined; + + return { + secret: secret?.secrets.find((s) => s.key === secretName), + environmentInfo: secret?.environmentInfo + }; + } + return undefined; + }, + [(secretImports || []).map((response) => response.data)] + ); + + return { secretImports, isImportedSecretPresentInEnv, getImportedSecretByKey }; }; export const useGetImportedFoldersByEnv = ({ From 0d7a07dea3c54dc83f67c5ae43c9d497232da71f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:31:41 +0200 Subject: [PATCH 061/302] Update SecretEditRow.tsx --- .../components/SecretOverviewTableRow/SecretEditRow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index 71529c95c..f67fc6de5 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -93,12 +93,13 @@ export const SecretEditRow = ({
( Date: Fri, 21 Jun 2024 14:31:45 +0200 Subject: [PATCH 062/302] Update SecretOverviewPage.tsx --- frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 11ad5fcba..6ab1344ff 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -188,7 +188,7 @@ export const SecretOverviewPage = () => { environments: userAvailableEnvs.map(({ slug }) => slug) }); - const { isImportedSecretPresentInEnv } = useGetImportedSecretsAllEnvs({ + const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({ projectId: workspaceId, decryptFileKey: latestFileKey!, path: secretPath, @@ -807,6 +807,7 @@ export const SecretOverviewPage = () => { isSelected={selectedEntries.secret[key]} onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} onSecretDelete={handleSecretDelete} From 7cf7eb5acbdbb0d98c23c47d4bff3588f930e320 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:20:37 +0200 Subject: [PATCH 063/302] Fix: Added explicit SecretType type --- frontend/src/hooks/api/secrets/types.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index f36872e43..378405c96 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -1,11 +1,16 @@ import type { UserWsKeyPair } from "../keys/types"; import type { WsTag } from "../tags/types"; +export enum SecretType { + Shared = "shared", + Personal = "personal" +} + export type EncryptedSecret = { id: string; version: number; workspace: string; - type: "shared" | "personal"; + type: SecretType; environment: string; secretKeyCiphertext: string; secretKeyIV: string; @@ -49,7 +54,7 @@ export type EncryptedSecretVersion = { secretId: string; version: number; workspace: string; - type: string; + type: SecretType; isDeleted: boolean; envId: string; secretKeyCiphertext: string; @@ -101,14 +106,14 @@ export type TCreateSecretsV3DTO = { secretPath: string; workspaceId: string; environment: string; - type: string; + type: SecretType; }; export type TUpdateSecretsV3DTO = { latestFileKey: UserWsKeyPair; workspaceId: string; environment: string; - type: string; + type: SecretType; secretPath: string; skipMultilineEncoding?: boolean; newSecretName?: string; @@ -124,7 +129,7 @@ export type TUpdateSecretsV3DTO = { export type TDeleteSecretsV3DTO = { workspaceId: string; environment: string; - type: "shared" | "personal"; + type: SecretType; secretPath: string; secretName: string; secretId?: string; @@ -140,7 +145,7 @@ export type TCreateSecretBatchDTO = { secretValue: string; secretComment: string; skipMultilineEncoding?: boolean; - type: "shared" | "personal"; + type: SecretType; metadata?: { source?: string; }; @@ -153,7 +158,7 @@ export type TUpdateSecretBatchDTO = { secretPath: string; latestFileKey: UserWsKeyPair; secrets: Array<{ - type: "shared" | "personal"; + type: SecretType; secretName: string; skipMultilineEncoding?: boolean; secretValue: string; @@ -168,14 +173,14 @@ export type TDeleteSecretBatchDTO = { secretPath: string; secrets: Array<{ secretName: string; - type: "shared" | "personal"; + type: SecretType; }>; }; export type CreateSecretDTO = { workspaceId: string; environment: string; - type: "shared" | "personal"; + type: SecretType; secretKey: string; secretKeyCiphertext: string; secretKeyIV: string; From 988c61204839d34782e1996739095219f47897f8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:20:48 +0200 Subject: [PATCH 064/302] Update DropZone.tsx --- frontend/src/components/dashboard/DropZone.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/dashboard/DropZone.tsx b/frontend/src/components/dashboard/DropZone.tsx index ba0b9efdb..b196e8cd4 100644 --- a/frontend/src/components/dashboard/DropZone.tsx +++ b/frontend/src/components/dashboard/DropZone.tsx @@ -6,6 +6,8 @@ import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { parseDocument, Scalar, YAMLMap } from "yaml"; +import { SecretType } from "@app/hooks/api/types"; + import Button from "../basic/buttons/Button"; import Error from "../basic/Error"; import { createNotification } from "../notifications"; @@ -33,7 +35,6 @@ const DropZone = ({ numCurrentRows }: DropZoneProps) => { const { t } = useTranslation(); - const handleDragEnter = (e: DragEvent) => { e.preventDefault(); @@ -66,7 +67,7 @@ const DropZone = ({ key, value: keyPairs[key as keyof typeof keyPairs].value, comment: keyPairs[key as keyof typeof keyPairs].comments.join("\n"), - type: "shared", + type: SecretType.Shared, tags: [] })); break; @@ -79,7 +80,7 @@ const DropZone = ({ key, value: keyPairs[key as keyof typeof keyPairs], comment: "", - type: "shared", + type: SecretType.Shared, tags: [] })); break; @@ -102,7 +103,7 @@ const DropZone = ({ key, value: keyPairs[key as keyof typeof keyPairs]?.toString() ?? "", comment, - type: "shared", + type: SecretType.Shared, tags: [] }; }); @@ -132,7 +133,7 @@ const DropZone = ({ if (file === undefined) { createNotification({ text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", - type: "error", + type: "error" }); setLoading(false); return; From f3da676b88b63d048300dcc5c823a6668544dc36 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:21:44 +0200 Subject: [PATCH 065/302] Update checkOverrides.ts --- frontend/src/components/utilities/secrets/checkOverrides.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/utilities/secrets/checkOverrides.ts b/frontend/src/components/utilities/secrets/checkOverrides.ts index 84ba0bbb7..7f311a252 100644 --- a/frontend/src/components/utilities/secrets/checkOverrides.ts +++ b/frontend/src/components/utilities/secrets/checkOverrides.ts @@ -1,5 +1,7 @@ import { SecretDataProps } from "public/data/frequentInterfaces"; +import { SecretType } from "@app/hooks/api/types"; + /** * This function downloads the secrets as a .env file * @param {object} obj @@ -10,8 +12,8 @@ const checkOverrides = async ({ data }: { data: SecretDataProps[] }) => { let secrets: SecretDataProps[] = data!.map((secret) => Object.create(secret)); const overridenSecrets = data!.filter((secret) => secret.valueOverride === undefined || secret?.value !== secret?.valueOverride - ? "shared" - : "personal" + ? SecretType.Shared + : SecretType.Personal ); if (overridenSecrets.length) { overridenSecrets.forEach((secret) => { From 077cbc97d541c4a750b3c750b9bc5ac50074ac49 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:21:52 +0200 Subject: [PATCH 066/302] Update encryptSecrets.ts --- .../src/components/utilities/secrets/encryptSecrets.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/utilities/secrets/encryptSecrets.ts b/frontend/src/components/utilities/secrets/encryptSecrets.ts index 943502cdf..4eb59ae9f 100644 --- a/frontend/src/components/utilities/secrets/encryptSecrets.ts +++ b/frontend/src/components/utilities/secrets/encryptSecrets.ts @@ -3,6 +3,7 @@ import crypto from "crypto"; import { SecretDataProps, Tag } from "public/data/frequentInterfaces"; import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; +import { SecretType } from "@app/hooks/api/types"; import { decryptAssymmetric, encryptSymmetric } from "../cryptography/crypto"; @@ -20,7 +21,7 @@ interface EncryptedSecretProps { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; - type: "personal" | "shared"; + type: SecretType; tags: Tag[]; } @@ -108,8 +109,8 @@ const encryptSecrets = async ({ secretCommentTag, type: secret.valueOverride === undefined || secret?.value !== secret?.valueOverride - ? "shared" - : "personal", + ? SecretType.Shared + : SecretType.Personal, tags: secret.tags }; From dc0fe6920c0b799a4084b9c6cb5e177a7fbf4c6b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:22:48 +0200 Subject: [PATCH 067/302] Feat: Show overwritten value in secret overview and allow for edits --- .../SecretOverviewTableRow.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 57cc01807..ede481821 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -13,7 +13,7 @@ import { twMerge } from "tailwind-merge"; import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; -import { DecryptedSecret } from "@app/hooks/api/secrets/types"; +import { DecryptedSecret, SecretType } from "@app/hooks/api/secrets/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { SecretEditRow } from "./SecretEditRow"; @@ -28,7 +28,13 @@ type Props = { onToggleSecretSelect: (key: string) => void; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; onSecretCreate: (env: string, key: string, value: string) => Promise; - onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; getImportedSecretByKey: ( @@ -214,8 +220,13 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} isVisible={isSecretVisible} secretName={secretKey} - defaultValue={secret?.value || importedSecret?.secret?.value} + defaultValue={ + secret?.valueOverride || + secret?.value || + importedSecret?.secret?.value + } secretId={secret?.id} + isOverride={Boolean(secret?.valueOverride)} isImportedSecret={isImportedSecret} isCreatable={isCreatable} onSecretDelete={onSecretDelete} From c43a18904d0e1d7c2e51f954555b0e193444231b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:23:09 +0200 Subject: [PATCH 068/302] Feat: Show overwritten value in secret overview and allow for edits --- .../SecretOverviewTableRow/SecretEditRow.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index f67fc6de5..fc335baec 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -10,24 +10,33 @@ import { IconButton, Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; +import { SecretType } from "@app/hooks/api/types"; type Props = { defaultValue?: string | null; secretName: string; secretId?: string; + isOverride?: boolean; isCreatable?: boolean; isVisible?: boolean; isImportedSecret: boolean; environment: string; secretPath: string; onSecretCreate: (env: string, key: string, value: string) => Promise; - onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; }; export const SecretEditRow = ({ defaultValue, isCreatable, + isOverride, isImportedSecret, onSecretUpdate, secretName, @@ -73,7 +82,13 @@ export const SecretEditRow = ({ if (isCreatable) { await onSecretCreate(environment, secretName, value); } else { - await onSecretUpdate(environment, secretName, value, secretId); + await onSecretUpdate( + environment, + secretName, + value, + isOverride ? SecretType.Personal : SecretType.Shared, + secretId + ); } } reset({ value }); From 9c06cab99d008e340cc843540d120fc92d539029 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:23:31 +0200 Subject: [PATCH 069/302] Fix: Added explicit SecretType type --- frontend/src/hooks/api/secrets/queries.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 28999389e..382d01785 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -14,6 +14,7 @@ import { EncryptedSecret, EncryptedSecretVersion, GetSecretVersionsDTO, + SecretType, TGetProjectSecretsAllEnvDTO, TGetProjectSecretsDTO, TGetProjectSecretsKey @@ -77,7 +78,7 @@ export const decryptSecrets = ( skipMultilineEncoding: encSecret.skipMultilineEncoding }; - if (encSecret.type === "personal") { + if (encSecret.type === SecretType.Personal) { personalSecrets[decryptedSecret.key] = { id: encSecret.id, value: secretValue From d0f8394f5007bf6be83eefd55c21e3194980be67 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:23:50 +0200 Subject: [PATCH 070/302] Fix: Added explicit SecretType type --- .../src/hooks/api/secretSnapshots/queries.tsx | 4 ++-- .../components/ActionBar/ActionBar.tsx | 4 ++-- .../CreateSecretForm/CreateSecretForm.tsx | 4 ++-- .../components/SecretDropzone/SecretDropzone.tsx | 6 +++--- .../components/SecretListView/SecretListView.tsx | 16 +++++++++------- .../SecretOverviewPage/SecretOverviewPage.tsx | 16 +++++++++++----- .../CreateSecretForm/CreateSecretForm.tsx | 6 +++--- .../SecretOverviewTableRow/SecretRenameRow.tsx | 5 ++--- .../components/SelectionPanel/SelectionPanel.tsx | 9 +++++++-- 9 files changed, 41 insertions(+), 29 deletions(-) diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx index ca1ec76fd..cd81152a1 100644 --- a/frontend/src/hooks/api/secretSnapshots/queries.tsx +++ b/frontend/src/hooks/api/secretSnapshots/queries.tsx @@ -7,7 +7,7 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; -import { DecryptedSecret } from "../secrets/types"; +import { DecryptedSecret, SecretType } from "../secrets/types"; import { TGetSecretSnapshotsDTO, TSecretRollbackDTO, @@ -112,7 +112,7 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, snapshotId }: TSnapshotD version: encSecret.version }; - if (encSecret.type === "personal") { + if (encSecret.type === SecretType.Personal) { personalSecrets[decryptedSecret.key] = { id: encSecret.secretId, value: secretValue }; } else { sharedSecrets.push(decryptedSecret); diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index bed5d6a84..0056c4a3b 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -47,7 +47,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; -import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; +import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; import { @@ -211,7 +211,7 @@ export const ActionBar = ({ secretPath, workspaceId, environment, - secrets: bulkDeletedSecrets.map(({ key }) => ({ secretName: key, type: "shared" })) + secrets: bulkDeletedSecrets.map(({ key }) => ({ secretName: key, type: SecretType.Shared })) }); resetSelectedSecret(); handlePopUpClose("bulkDeleteSecrets"); diff --git a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx index 4efd6bdc3..7d7126c56 100644 --- a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -6,7 +6,7 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useCreateSecretV3 } from "@app/hooks/api"; -import { UserWsKeyPair } from "@app/hooks/api/types"; +import { SecretType, UserWsKeyPair } from "@app/hooks/api/types"; import { PopUpNames, usePopUpAction, usePopUpState } from "../../SecretMainPage.store"; @@ -56,7 +56,7 @@ export const CreateSecretForm = ({ secretName: key, secretValue: value || "", secretComment: "", - type: "shared", + type: SecretType.Shared, latestFileKey: decryptFileKey }); closePopUp(PopUpNames.CreateSecretForm); diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx index 98ffb0862..ca0923894 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx @@ -16,7 +16,7 @@ import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; -import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types"; +import { DecryptedSecret, SecretType, UserWsKeyPair } from "@app/hooks/api/types"; import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; @@ -170,7 +170,7 @@ export const SecretDropzone = ({ workspaceId, environment, secrets: Object.entries(create).map(([secretName, secData]) => ({ - type: "shared", + type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretName @@ -184,7 +184,7 @@ export const SecretDropzone = ({ workspaceId, environment, secrets: Object.entries(update).map(([secretName, secData]) => ({ - type: "shared", + type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretName diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index d17de4bb4..d6d6fc487 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -10,7 +10,7 @@ import { usePopUp } from "@app/hooks"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; -import { DecryptedSecret } from "@app/hooks/api/secrets/types"; +import { DecryptedSecret, SecretType } from "@app/hooks/api/secrets/types"; import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries"; import { UserWsKeyPair, WsTag } from "@app/hooks/api/types"; @@ -119,7 +119,7 @@ export const SecretListView = ({ const handleSecretOperation = async ( operation: "create" | "update" | "delete", - type: "shared" | "personal", + type: SecretType, key: string, { value, @@ -227,23 +227,25 @@ export const SecretListView = ({ try { // personal secret change if (overrideAction === "deleted") { - await handleSecretOperation("delete", "personal", oldKey, { + await handleSecretOperation("delete", SecretType.Personal, oldKey, { secretId: orgSecret.idOverride }); } else if (overrideAction && idOverride) { - await handleSecretOperation("update", "personal", oldKey, { + await handleSecretOperation("update", SecretType.Personal, oldKey, { value: valueOverride, newKey: hasKeyChanged ? key : undefined, secretId: orgSecret.idOverride, skipMultilineEncoding: modSecret.skipMultilineEncoding }); } else if (overrideAction) { - await handleSecretOperation("create", "personal", oldKey, { value: valueOverride }); + await handleSecretOperation("create", SecretType.Personal, oldKey, { + value: valueOverride + }); } // shared secret change if (!isSharedSecUnchanged) { - await handleSecretOperation("update", "shared", oldKey, { + await handleSecretOperation("update", SecretType.Shared, oldKey, { value, tags: tagIds, comment, @@ -286,7 +288,7 @@ export const SecretListView = ({ const handleSecretDelete = useCallback(async () => { const { key, id: secretId } = popUp.deleteSecret?.data as DecryptedSecret; try { - await handleSecretOperation("delete", "shared", key, { secretId }); + await handleSecretOperation("delete", SecretType.Shared, key, { secretId }); // wrap this in another function and then reuse queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 6ab1344ff..09a16e407 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -64,7 +64,7 @@ import { } from "@app/hooks/api"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; -import { TSecretFolder } from "@app/hooks/api/types"; +import { SecretType, TSecretFolder } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; @@ -320,7 +320,7 @@ export const SecretOverviewPage = () => { secretName: key, secretValue: value, secretComment: "", - type: "shared", + type: SecretType.Shared, latestFileKey: latestFileKey! }); createNotification({ @@ -344,7 +344,13 @@ export const SecretOverviewPage = () => { } }; - const handleSecretUpdate = async (env: string, key: string, value: string, secretId?: string) => { + const handleSecretUpdate = async ( + env: string, + key: string, + value: string, + type = SecretType.Shared, + secretId?: string + ) => { try { await updateSecretV3({ environment: env, @@ -353,7 +359,7 @@ export const SecretOverviewPage = () => { secretId, secretName: key, secretValue: value, - type: "shared", + type, latestFileKey: latestFileKey! }); createNotification({ @@ -377,7 +383,7 @@ export const SecretOverviewPage = () => { secretPath, secretName: key, secretId, - type: "shared" + type: SecretType.Shared }); createNotification({ type: "success", diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 85d3125d5..0ef342a2e 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -18,7 +18,7 @@ import { import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useWorkspace } from "@app/context"; import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; -import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types"; +import { DecryptedSecret, SecretType, UserWsKeyPair } from "@app/hooks/api/types"; const typeSchema = z .object({ @@ -103,7 +103,7 @@ export const CreateSecretForm = ({ secretPath, secretName: key, secretValue: value || "", - type: "shared", + type: SecretType.Shared, latestFileKey: decryptFileKey }); } @@ -115,7 +115,7 @@ export const CreateSecretForm = ({ secretName: key, secretValue: value || "", secretComment: "", - type: "shared", + type: SecretType.Shared, latestFileKey: decryptFileKey }); }); diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx index 8059f5e7b..e5488ab9f 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx @@ -18,7 +18,7 @@ import { } from "@app/context"; import { useToggle } from "@app/hooks"; import { useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api"; -import { DecryptedSecret } from "@app/hooks/api/types"; +import { DecryptedSecret, SecretType } from "@app/hooks/api/types"; import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils"; type Props = { @@ -37,7 +37,6 @@ type TFormSchema = z.infer; function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) { const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); - const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey)); @@ -113,7 +112,7 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath } secretName: secret.key, secretId: secret.id, secretValue: secret.value || "", - type: "shared", + type: SecretType.Shared, latestFileKey: decryptFileKey!, tags: secret.tags.map((tag) => tag.id), secretComment: secret.comment, diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 6a2fe9bf6..3d97228c3 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -13,7 +13,12 @@ import { } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; -import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; +import { + DecryptedSecret, + SecretType, + TDeleteSecretBatchDTO, + TSecretFolder +} from "@app/hooks/api/types"; export enum EntryType { FOLDER = "folder", @@ -100,7 +105,7 @@ export const SelectionPanel = ({ ...accum, { secretName: entry.key, - type: "shared" as "shared" + type: SecretType.Shared } ]; } From 52fcf53d0e3178d143b2b869adb9445b2e69199f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 21 Jun 2024 22:22:34 +0800 Subject: [PATCH 071/302] misc: moved authenticate to preValidation --- backend/src/ee/routes/v1/oidc-router.ts | 60 ++++++++++++++----------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 2f01c79ca..e675121e9 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -60,44 +60,50 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { callbackPort: z.string().trim().optional() }) }, - handler: async (req, res) => { - const { orgSlug, callbackPort } = req.query; + preValidation: [ + async (req, res) => { + const { orgSlug, callbackPort } = req.query; - // ensure fresh session state per login attempt - await req.session.regenerate(); + // ensure fresh session state per login attempt + await req.session.regenerate(); - req.session.set("oidcOrgSlug", orgSlug); + req.session.set("oidcOrgSlug", orgSlug); - if (callbackPort) { - req.session.set("callbackPort", callbackPort); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug, callbackPort); + return ( + passport.authenticate(oidcStrategy as Strategy, { + scope: "profile email openid" + }) as any + )(req, res); } - - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(orgSlug, callbackPort); - ( - passport.authenticate(oidcStrategy as Strategy, { - scope: "profile email openid" - }) as any - )(req, res); - } + ], + handler: () => {} }); // callback route after login from IDP server.route({ url: "/callback", method: "GET", + preValidation: [ + async (req, res) => { + const oidcOrgSlug = req.session.get("oidcOrgSlug"); + const callbackPort = req.session.get("callbackPort"); + const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug, callbackPort); + + return ( + passport.authenticate(oidcStrategy as Strategy, { + failureRedirect: "/api/v1/sso/oidc/login/error", + session: false, + failureMessage: true + }) as any + )(req, res); + } + ], handler: async (req, res) => { - const oidcOrgSlug = req.session.get("oidcOrgSlug"); - const callbackPort = req.session.get("callbackPort"); - const oidcStrategy = await server.services.oidc.getOrgAuthStrategy(oidcOrgSlug, callbackPort); - - await ( - passport.authenticate(oidcStrategy as Strategy, { - failureRedirect: "/api/v1/sso/oidc/login/error", - session: false, - failureMessage: true - }) as any - )(req, res); - await req.session.destroy(); if (req.passportUser.isUserCompleted) { From c426ba517ac741337a232877c30cb18522f11289 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 23:12:38 +0200 Subject: [PATCH 072/302] Feat: Expand single secret references --- backend/src/lib/api-docs/constants.ts | 1 + backend/src/server/routes/v3/secret-router.ts | 6 +++ backend/src/services/secret/secret-service.ts | 44 ++++++++++++++++++- backend/src/services/secret/secret-types.ts | 1 + 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d768066fa..5bbe49cf2 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -347,6 +347,7 @@ export const RAW_SECRETS = { tagIds: "The ID of the tags to be attached to the created secret." }, GET: { + expand: "Whether or not to expand secret references", secretName: "The name of the secret to get.", workspaceId: "The ID of the project to get the secret from.", workspaceSlug: "The slug of the project to get the secret from.", diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index e3f1528ad..b92138ec8 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -300,6 +300,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.GET.type), + expandSecretReferences: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + .describe(RAW_SECRETS.GET.expand), include_imports: z .enum(["true", "false"]) .default("false") @@ -344,6 +349,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, + expandSecretReferences: req.query.expandSecretReferences, environment, projectId: workspaceId, projectSlug: workspaceSlug, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a5a469a8f..cbfccba91 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1078,6 +1078,7 @@ export const secretServiceFactory = ({ actor, environment, projectId: workspaceId, + expandSecretReferences, projectSlug, actorId, actorOrgId, @@ -1091,7 +1092,7 @@ export const secretServiceFactory = ({ const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); - const secret = await getSecretByName({ + const encryptedSecret = await getSecretByName({ actorId, projectId, actorAuthMethod, @@ -1105,7 +1106,46 @@ export const secretServiceFactory = ({ version }); - return decryptSecretRaw(secret, botKey); + const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey); + + if (expandSecretReferences) { + const expandSecrets = interpolateSecrets({ + folderDAL, + projectId, + secretDAL, + secretEncKey: botKey + }); + + const expandSingleSecret = async (secret: { + secretKey: string; + secretValue: string; + secretComment?: string; + secretPath: string; + skipMultilineEncoding: boolean | null | undefined; + }) => { + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding: boolean | null | undefined } + > = { + [secret.secretKey]: { + value: secret.secretValue, + comment: secret.secretComment, + skipMultilineEncoding: secret.skipMultilineEncoding + } + }; + + await expandSecrets(secretRecord); + + // Update the secret with the expanded value + // eslint-disable-next-line no-param-reassign + secret.secretValue = secretRecord[secret.secretKey].value; + }; + + // Expand the secret + await expandSingleSecret(decryptedSecret); + } + + return decryptedSecret; }; const createSecretRaw = async ({ diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 1aac324c5..10df2f258 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -151,6 +151,7 @@ export type TGetASecretRawDTO = { secretName: string; path: string; environment: string; + expandSecretReferences?: boolean; type: "shared" | "personal"; includeImports?: boolean; version?: number; From bd0d0bd333cf156d958de3d67c6bcb9d37099a7c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 22 Jun 2024 22:42:36 +0800 Subject: [PATCH 073/302] feat: added project setting for rebuilding secret indices --- .../secret-blind-index-dal.ts | 2 - .../ProjectGeneralTab/ProjectGeneralTab.tsx | 2 + .../RebuildSecretIndicesSection.tsx | 88 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx diff --git a/backend/src/services/secret-blind-index/secret-blind-index-dal.ts b/backend/src/services/secret-blind-index/secret-blind-index-dal.ts index 8fa60cde7..825dea3a7 100644 --- a/backend/src/services/secret-blind-index/secret-blind-index-dal.ts +++ b/backend/src/services/secret-blind-index/secret-blind-index-dal.ts @@ -30,7 +30,6 @@ export const secretBlindIndexDALFactory = (db: TDbClient) => { .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.Secret}.folderId`) .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) .where({ projectId }) - .whereNull("secretBlindIndex") .select(selectAllTableCols(TableName.Secret)) .select( db.ref("slug").withSchema(TableName.Environment).as("environment"), @@ -49,7 +48,6 @@ export const secretBlindIndexDALFactory = (db: TDbClient) => { .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) .where({ projectId }) .whereIn(`${TableName.Secret}.id`, secretIds) - .whereNull("secretBlindIndex") .select(selectAllTableCols(TableName.Secret)) .select( db.ref("slug").withSchema(TableName.Environment).as("environment"), diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index 511dff93e..bdbd41c1c 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -5,6 +5,7 @@ import { E2EESection } from "../E2EESection"; import { EnvironmentSection } from "../EnvironmentSection"; import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection"; import { ProjectNameChangeSection } from "../ProjectNameChangeSection"; +import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection"; import { SecretTagsSection } from "../SecretTagsSection"; export const ProjectGeneralTab = () => { @@ -17,6 +18,7 @@ export const ProjectGeneralTab = () => { +
); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx new file mode 100644 index 000000000..ef298a962 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx @@ -0,0 +1,88 @@ +import { createNotification } from "@app/components/notifications"; +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { Button } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetUserWsKey, useNameWorkspaceSecrets } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { fetchWorkspaceSecrets } from "@app/hooks/api/workspace/queries"; + +export const RebuildSecretIndicesSection = () => { + const { currentWorkspace } = useWorkspace(); + const { membership } = useProjectPermission(); + const nameWorkspaceSecrets = useNameWorkspaceSecrets(); + + const [isIndexing, setIsIndexing] = useToggle(); + const { data: decryptFileKey } = useGetUserWsKey(currentWorkspace?.id!); + + if (!currentWorkspace) return null; + + const onRebuildIndices = async () => { + if (!currentWorkspace?.id) return; + setIsIndexing.on(); + try { + const encryptedSecrets = await fetchWorkspaceSecrets(currentWorkspace.id); + + if (!currentWorkspace || !decryptFileKey) { + return; + } + + const key = decryptAssymmetric({ + ciphertext: decryptFileKey.encryptedKey, + nonce: decryptFileKey.nonce, + publicKey: decryptFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { + const secretName = decryptSymmetric({ + ciphertext: encryptedSecret.secretKeyCiphertext, + iv: encryptedSecret.secretKeyIV, + tag: encryptedSecret.secretKeyTag, + key + }); + + return { + secretName, + secretId: encryptedSecret.id + }; + }); + await nameWorkspaceSecrets.mutateAsync({ + workspaceId: currentWorkspace.id, + secretsToUpdate + }); + + createNotification({ + text: "Successfully rebuilt secret indices", + type: "success" + }); + } catch (err) { + console.log(err); + } finally { + setIsIndexing.off(); + } + }; + + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + return ( +
+
+

Rebuild Secret Indices

+
+

+ This will rebuild indices of all secrets in the project. +

+ +
+ ); +}; From b6cc17d62aecdcd1bab8782ce64e0b9e7f004232 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 23 Jun 2024 01:21:26 +0530 Subject: [PATCH 074/302] feat: updated var names and permission, rate limit changes based on comments --- .../src/server/routes/v1/identity-aws-iam-auth-router.ts | 2 +- backend/src/server/routes/v1/identity-azure-auth-router.ts | 2 +- backend/src/server/routes/v1/identity-gcp-auth-router.ts | 2 +- .../src/server/routes/v1/identity-kubernetes-auth-router.ts | 2 +- .../{identity-ua.ts => identity-universal-auth-router.ts} | 4 ++-- backend/src/server/routes/v1/index.ts | 2 +- .../services/identity-aws-auth/identity-aws-auth-service.ts | 6 +++--- .../identity-azure-auth/identity-azure-auth-service.ts | 6 +++--- .../services/identity-gcp-auth/identity-gcp-auth-service.ts | 6 +++--- .../identity-kubernetes-auth-service.ts | 6 +++--- backend/src/services/identity-ua/identity-ua-service.ts | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) rename backend/src/server/routes/v1/{identity-ua.ts => identity-universal-auth-router.ts} (100%) diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index d3530797c..8a85323a6 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -271,7 +271,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) method: "DELETE", url: "/aws-auth/identities/:identityId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index bd18102cc..6b4a7fb37 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -265,7 +265,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider method: "DELETE", url: "/azure-auth/identities/:identityId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 64e76bc1e..0deeb95d3 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -271,7 +271,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) method: "DELETE", url: "/gcp-auth/identities/:identityId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index fc26525f7..4c54f1e7c 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -286,7 +286,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide method: "DELETE", url: "/kubernetes-auth/identities/:identityId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/identity-ua.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts similarity index 100% rename from backend/src/server/routes/v1/identity-ua.ts rename to backend/src/server/routes/v1/identity-universal-auth-router.ts index d2d60d112..b5a63f0db 100644 --- a/backend/src/server/routes/v1/identity-ua.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -299,7 +299,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { method: "DELETE", url: "/universal-auth/identities/:identityId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -450,7 +450,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/universal-auth/identities/:identityId/client-secrets/:clientSecretId", config: { - rateLimit: writeLimit + rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index eee7dac65..c2969b382 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -9,7 +9,7 @@ import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; -import { registerIdentityUaRouter } from "./identity-ua"; +import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; 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 0df860f10..9cb39aece 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 @@ -323,7 +323,7 @@ export const identityAwsAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -339,9 +339,9 @@ export const identityAwsAuthServiceFactory = ({ }); const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { - const deletedUniversalAuth = await identityAwsAuthDAL.delete({ identityId }, tx); + const deletedAwsAuth = await identityAwsAuthDAL.delete({ identityId }, tx); await identityDAL.updateById(identityId, { authMethod: null }, tx); - return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedAwsAuth?.[0], orgId: identityMembershipOrg.orgId }; }); return revokedIdentityAwsAuth; }; 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 084443745..0d52bf6a0 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 @@ -302,7 +302,7 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -318,9 +318,9 @@ export const identityAzureAuthServiceFactory = ({ }); const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { - const deletedUniversalAuth = await identityAzureAuthDAL.delete({ identityId }, tx); + const deletedAzureAuth = await identityAzureAuthDAL.delete({ identityId }, tx); await identityDAL.updateById(identityId, { authMethod: null }, tx); - return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedAzureAuth?.[0], orgId: identityMembershipOrg.orgId }; }); return revokedIdentityAzureAuth; }; 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 97436598e..824e593ce 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 @@ -337,7 +337,7 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -353,9 +353,9 @@ export const identityGcpAuthServiceFactory = ({ }); const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { - const deletedUniversalAuth = await identityGcpAuthDAL.delete({ identityId }, tx); + const deletedGcpAuth = await identityGcpAuthDAL.delete({ identityId }, tx); await identityDAL.updateById(identityId, { authMethod: null }, tx); - return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedGcpAuth?.[0], orgId: identityMembershipOrg.orgId }; }); return revokedIdentityGcpAuth; }; 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 2c73d025b..820777b46 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 @@ -555,7 +555,7 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -571,9 +571,9 @@ export const identityKubernetesAuthServiceFactory = ({ }); const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { - const deletedUniversalAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); + const deletedKubernetesAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); await identityDAL.updateById(identityId, { authMethod: null }, tx); - return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedKubernetesAuth?.[0], orgId: identityMembershipOrg.orgId }; }); return revokedIdentityKubernetesAuth; }; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 3d9f0c3cb..00dfa5d60 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -355,7 +355,7 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, From 084fc7c99e4b594a6a37951be266583b5b7b8810 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 23 Jun 2024 01:25:27 +0530 Subject: [PATCH 075/302] feat: resolved gcp auth revoke error --- .../services/identity-gcp-auth/identity-gcp-auth-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 824e593ce..edac7c132 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 @@ -326,9 +326,9 @@ export const identityGcpAuthServiceFactory = ({ }: TRevokeGcpAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); - if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) throw new BadRequestError({ - message: "The identity does not have aws auth" + message: "The identity does not have gcp auth" }); const { permission } = await permissionService.getOrgPermission( actor, From eeaabe44ec875921c43dc0c0129ac10828ed33a6 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sat, 22 Jun 2024 16:44:42 -0700 Subject: [PATCH 076/302] Update LDAP docs --- docs/documentation/platform/ldap/overview.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx index 4d6c75e15..4502158d0 100644 --- a/docs/documentation/platform/ldap/overview.mdx +++ b/docs/documentation/platform/ldap/overview.mdx @@ -14,8 +14,6 @@ then you should contact sales@infisical.com to purchase an enterprise license to You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). -To note, configuring LDAP retains the end-to-end encrypted nature of authentication in Infisical because we decouple the authentication and decryption steps; the LDAP server cannot and will not have access to the decryption key needed to decrypt your secrets. - LDAP providers: - Active Directory From 45c153e59281fe0981edcfc3ab3e3c02abb324e2 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sat, 22 Jun 2024 18:31:12 -0700 Subject: [PATCH 077/302] Update terraform-cloud.mdx --- docs/integrations/cloud/terraform-cloud.mdx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx index 3fd70df73..d68e8a14f 100644 --- a/docs/integrations/cloud/terraform-cloud.mdx +++ b/docs/integrations/cloud/terraform-cloud.mdx @@ -27,12 +27,6 @@ Prerequisites: ![integrations terraform cloud authorization](../../images/integrations/terraform/integrations-terraformcloud-auth.png) - - If this is your project's first cloud integration, then you'll have to grant - Infisical access to your project's environment variables. Although this step - breaks E2EE, it's necessary for Infisical to sync the environment variables to - the cloud platform. - Select which Infisical environment secrets and Terraform Cloud variable type you want to sync to which Terraform Cloud workspace/project and press create integration to start syncing secrets to Terraform Cloud. @@ -40,4 +34,4 @@ Prerequisites: ![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud-create.png) ![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud.png) - \ No newline at end of file + From 36a34b0f582f6171f126b8f2c1673687d7fd985b Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sat, 22 Jun 2024 18:38:37 -0700 Subject: [PATCH 078/302] Update sdk docs --- docs/sdks/overview.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 578e8ad0f..bff6dcb9f 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -19,6 +19,9 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Java application on demand + + Manage secrets for your Go application on demand + Manage secrets for your C#/.NET application on demand From c9c77f6c5894b05d4e630b93173123aa8fa82e19 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sat, 22 Jun 2024 19:02:43 -0700 Subject: [PATCH 079/302] Update saml docs --- docs/documentation/platform/sso/overview.mdx | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 9ab0acc3a..ec0371dba 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -15,9 +15,6 @@ description: "Learn how to log in to Infisical via SSO protocols." You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). -To note, Infisical's SSO implementation decouples the **authentication** and **decryption** steps – which implies that no -Identity Provider can have access to the decryption key needed to decrypt your secrets (this also implies that Infisical requires entering the user's Master Password on top of authenticating with SSO). - ## Identity providers Infisical supports these and many other identity providers: From c2c693d29590f9e787cf2592f3f31cb25fb7ab20 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sun, 23 Jun 2024 13:22:30 -0700 Subject: [PATCH 080/302] Update mint.json --- docs/mint.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/mint.json b/docs/mint.json index 1caad0715..0da112cfe 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -638,5 +638,8 @@ ], "integrations": { "intercom": "hsg644ru" + }, + "analytics": { + "koala": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" } } From d026a9b988c8ac65a86fcdc3705969b9a370e178 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sun, 23 Jun 2024 13:23:10 -0700 Subject: [PATCH 081/302] Update mint.json --- docs/mint.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/mint.json b/docs/mint.json index 0da112cfe..7978225c9 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -640,6 +640,8 @@ "intercom": "hsg644ru" }, "analytics": { - "koala": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" + "koala": { + "publicApiKey": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" + } } } From 6d9330e87089c1c287f061387c545f8555b448fa Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Sun, 23 Jun 2024 14:27:11 -0700 Subject: [PATCH 082/302] Update machine-identities.mdx --- .../platform/identities/machine-identities.mdx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 9cc6c4c3d..2db1834c4 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -26,13 +26,6 @@ A typical workflow for using identities consists of four steps: 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. - - Currently, identities can only be used to make authenticated requests to the Infisical API, SDKs, Terraform, Kubernetes Operator, and Infisical Agent. They do not work with clients such as CLI, Ansible look up plugin, etc. - -Machine Identity support for the rest of the clients is planned to be released in the current quarter. - - - ## Authentication Methods To interact with various resources in Infisical, Machine Identities are able to authenticate using: From 00617ea7e8b51381c2a9f10f95df0aa2a1b3e477 Mon Sep 17 00:00:00 2001 From: Han Wang <44352119+handotdev@users.noreply.github.com> Date: Mon, 24 Jun 2024 00:56:39 -0700 Subject: [PATCH 083/302] Update style.css --- docs/style.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/style.css b/docs/style.css index 3359151e4..4d9877c6c 100644 --- a/docs/style.css +++ b/docs/style.css @@ -10,7 +10,6 @@ #sidebar { left: 0; - padding-left: 48px; padding-right: 30px; border-right: 1px; border-color: #cdd64b; @@ -18,6 +17,10 @@ border-right: 1px solid #ebebeb; } +#sidebar-content { + padding-left: 2rem; +} + #sidebar .relative .sticky { opacity: 0; } @@ -154,4 +157,4 @@ .flex-1 .flex .items-center { /* background-color: #f5f5f5; */ -} \ No newline at end of file +} From b7b606ab9a9148301c05b37e0e5ec79029c5da1a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 24 Jun 2024 14:01:24 +0200 Subject: [PATCH 084/302] Update overview.mdx --- docs/sdks/overview.mdx | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index bff6dcb9f..4cc476e2c 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -46,7 +46,4 @@ From local development to production, Infisical SDKs provide the easiest way for Note: The exact parameter name may differ depending on the language. - - The SDK caches every secret and falls back to the cached value if a request fails. If no cached value is found, and the request fails, then the SDK throws an error. - From 8703314c0c8fd92bbaac7cb50a4404f9a00e967c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 24 Jun 2024 20:11:53 +0800 Subject: [PATCH 085/302] doc: added bitbucket integration with cli --- .../bitbucket/integrations-bitbucket-env.png | Bin 0 -> 185083 bytes docs/integrations/cicd/bitbucket.mdx | 84 ++++++++++++++---- 2 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 docs/images/integrations/bitbucket/integrations-bitbucket-env.png diff --git a/docs/images/integrations/bitbucket/integrations-bitbucket-env.png b/docs/images/integrations/bitbucket/integrations-bitbucket-env.png new file mode 100644 index 0000000000000000000000000000000000000000..8c683a1001076b6656b57397149268a9100b5fa1 GIT binary patch literal 185083 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ⅅhlG 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

-HF2vU*oVtFb+G^`aiKlTh~}5%CBv#;l3~>0S>l#M$W`E zp)^=dIr6?!%@&2Xvg@WfKHN+6=st_AA_oF-dtcr8$?)r9{~klQ^i{jJ^=ZZi;JhY6AnKF<@H*uRQG@xX zvtK)szQu>Rm2c1?X_Mc!2{fPrGfYFi#r-b!?*L7+N!$&fwU_7m#EHgC;z^>gC;($5^`}d!S8w=vdQ8h%sh2(tcv+-O&dPb)8=g%VxLl zDmvKx)(r5}j-len$|Y4r(goh_FN_yWJ1XlZKl@J7l*4&5Cs@;UPv3+^L|f+luq+h~ ziD2Q=U~w#wA%p)#rNpEkUY#xo$9ef89P{2&Ft74>^7~Vvy(ns#T!y=L2FiI#ruQ4> zyja?x+oK+<2TEi$>sJwRY3oE`u0!Pmj1(dxYonJCT~1uHwvMjMS&^KII8Jhzg}!## z)ZZ`di$dVtEPhQpR4Zcty)ZG|w17&0VW*U|lc-%LLNv`nEnQj;y)2b_exz9LlnbuB zr3`#~RURs72h5Tg2NivNx)!U#Ohm=faLXf2pg8IP*E=cMQniCOcHKVrf*HoiiCVY^ z%2~=j9C8rCo5cBejgLfs^6zSG>zApqHUn>tCNtKJ zRHGY^HgFC)80CGhh~YO9pELR$q?wU3Y)uHH7(NIl(p)JQ8U9L;HGwOwRYhq?R0vm$ z(eg~->KZIMbP_>Z$mntm*=W@%@gg{PR<~A&;&y3S(8RyLRcEAMVAXBN2cB}_SfS-r zR>BVdQU(O);O83Di28c!xUjO%3`W_C9L^(sK$+Gv?`!XzS7YQ}6l4}aKEGVUY0_h_ zN2o=h>lQ$-JE%7O`G5tSTv%+H9JrqCigAP5RY@H`_}+^JasiGmk4QJctFFQg=z6M; zDnN1oXNv+PnhCSN?)ir9&JK9$Rn|DrEOZre94pE+PwYbXwzgVxIuoBPrqV`pd!tJQ zG6n53qu6-o|9~p2Oq9b3m|`24=%!|Nt?nPqN{$!3pZxRLE=Uioj3|-2|UY3Uef_dzc#2wi7RHqX!&O zS34OT$n&l3JhnPz4e+gUx7ua(YcU%J3aJzv1yUfV)Y9RK5;1Pa!WY!t7Ob<0HUatF zPosJh>|v!^yeonC6o4GKd>Y_H#gtv`v{VTI$|VN8dFEnD)L=p@;B03{Rnc*u`ZiVg zc6k3xT0D4RGklnj|JtGN-eJkNtrt^+dTk{2hH6>frO8e=rcU@&9`60HE)?W>Xsj12 zLTnqw%dW24wkM?RbzM-~Ip<%pZr^RFy}9rLXw3EwuYNuD7N zpfw>B6HgUb_&s-=sW6g3R_qH@F^! zglcm~v`0H-{nSc#_K0|Qiu-9?Gl{Uo-kJ;!Ed@W6PaJc)KrFV|tm<;`D(!DE#xfzl)9>(*H$R?73X$PBq zPlHw)6Visp6SzyIZ01OvA?yBH^tCP(M1XllqRjMRcgkAar{$^d&K)7CcBf^+R#uM4 zk=<*@&Z84%F>FE$Fy-ar8~bKddEc}LAUdk>*nXq{G>#Lno^8HoKohJz?z>C#HrDW7}vV?P)%W{;-X7C^OnplYD!Xs6O`ff(G!eRq!DO zm*sME%s7TyTVB6k2^DxwcO^1cmp(Vkz1?ynshYsHE$xlN#iyI^m2Hsu>@yNecNg6| zmL<3 z-cUYp>@?jgYMawodPS&10|h}e)|eq{ht!jQqg57G8&*KrVwB%TMX>149o2f@6R#`7 zw8Ik(UHeMQn&wvAS|a4&%9<-dhs?WcPIo~Psy}JZVNh?;8KaNI>aIK~p9X2$C)mZE49DUs+fT z5@`3I!5<(FJkycuh90|>t4H2k1%BBQCk>46J73X(daE3Pvr{lth=KxXYz9br&eQbX zOUF&@+vEWQl7n8y{ziiJu*1-1{%S6<=W#n?Ua-ishzlC@W=;QgVKZC-vpj&&)b`Dh@|!seyQcPa+S>K$knR z3p{7Xap@{Q1-A}MnkpTTQUofQh@m8G!5`1-2-Fk(_t#J4AO;uvqd&!c&VSH(-sF}? z?$s|~#PfHjm^tF3G3NUWrn-A%3FS`ke|U6%fVZWVZu|17Y~YV_nBApvKbqDmrLz27 zS2hU_8QNsZe8S!g~kshAC6hFipp6^>#fRS#ShEMYr&P0 z*8|jWHGA7;UaEZOHNSI`E=2GXG}kvkxdF^++n?iQAGbqbqiBv zc;Q*~ux66ICO@V3G`uWno05N~;C}s{;i9do}8vnlrid#mGf(AO}77Y%M@4Ft)-;+V-6G z?V)zU2s2q)wLaZj|KZo94&2xaKgd?$g8JUo!vVTIMfq-`naLs97fIJPy|o;7Uu>Yy z8oK&a&xygj52=ahybpl!IRlo>#}^X?*9RF4#-&vz`r#knm)A)LgNZTvuZKTqiU9yRO>^@d(MbrpSu-eXMEQ*1A{$hYQakYg(vlNc zT1i=uT8<{XfJNBU$Zbl>r$hD{Bfs~jH(lJ6ESQNzEb3Dzpk>Ap_KrNB@|Io9HA8<` zqVLW8maDnWtxS0(kYH;O)-6h;*{JPv;H;yew~-e}d#`ru-@S{M?*ooXwA04Dzk>|_ zJ8LYRR5QG|=eku}WV9hXlBsc0T+1+WZIITkAGEIfE4y6*%GDIaU-23ztY%48; zvlPGv%|#a3+L1kJIUljLpqiQ5yHcg_R9`S2c;oMiWZr|zpDRgfOCmp5@}-{m@qCN* zPTv*hHl3~#%xXthPY<-p!~*RKc@59%&lvtY0ltdeM@*tGSvhu+PsiqV-T1gdZ%rajzDv30nc=`-ndMo(52Y^Fto zXA?yxDJ#S=`d;E5t=!S@9~Fb6bv+W*bv@8}>|qDfAt`!5|T_9W}z# z7wWZ>8^{^irL%E?Yu`QGM$vj z<^wtA67gY8(gJ`$%V_RX`*s?~OHgqMvyT{QCuk7KuN~tK$T^PcWwGjp09Z#H{S}%O z2>qQqNAEF$R-7}4J)}ZBW*CD#fDtQx(gHQ8J@|d)-_OYhS?zY-p<1M$!L=0G-|sGH ziZpOaAH~otl?;ggF;Uy4^oVEAYtJ0K&@!SYL}OK+ort~sMK`<8mz8$>sdjkj?3BMZ z4rEuY>b3mA1_!klJ*^+W3Rtw4{ydB9nGmx4ho`qnjCbrd@cIwY=EOR9?_Fu*FA;*T zjZf_Yni8A9rG}-3C&x1YrD}~Ir&MXf86QD`rKjoYY={emll?o~t0shFi=sBktrx@I z_lZ?~_Pd1F$vVI12N(mj?%B(3J;?N?e;>Q^T2G__L-+hDE2HlI%#T!_6K#7fE<3#N z@OezP?KD`@A(Mj>X?H#}llWfnvTU|^O5{!~g-m|L z9PPOB+Rb%e)Fx$cyfn3QMQ2OsJ73c&{OTS|0ZbM?axVPUP7>RilT*qhuOVeRJoEGe zz45uq;eMxmEWY4~6FcJ`OD0p=kHe5jp79St>UrpRAK$RdvAuqm*t_1Yf_ zW7OsE{K$kY^|<1ffa?WY*837D6bYBYKQC1Jg{ix_+?hcu#OE@RZ2^B&WjgpCTwkD- zjQJ-5uFomoueXdZMkMA}1i4V@$NNw>1Z_+*p4aQ!={0*4IXtob)xL*Ff*;4YnF}e% zb^1A`ZCz*q({WAvjp6Th8stUpW2qLT_^97FV%gbt`vd`a#Jdwp{e=vzq$OPIbQU?% z^63?X#>8R&pYVUoj_2UI(I*tr&vz7iIE@c!BLu^MwcE3Hfr{bw+6tkIvGsD_RHBn< zAuvEMKMcbP!CuC1ASM$ZqyE7y`Z)asN&iDo8%m^%oKyyKnQP{n*Rkd|9}Q@Lfwlj>zF?2|@m~vvsRw zlxU5dA2l@{0~cGsmT7<~$us@37^yXk10-;!p6!V|H^YZK1H&5}dFp$XZs{q}&EWwX zip<7+Ty{fTcEoPL)OD~3%O&J~t+^%VStWxV9V6?#sk;P@&o~V`M?&^0a(G$ow46-J z7r-yL^@N$|%n6v@B5Y8IK~Nfdk3&_1P(-ar1}F_O{M$ERIgsz=c=_b~I9-wM7e((k z6N})hN(J|$L$%+lqUT-pzX|{B9d8aK~STyjY|5ME5_+*-{ z%$4lpd@Y(uSo666-!h6|FMRAePh|ZdPRA-g|NTJ1k8Qj!qRIc-ALsY1J*4x{kBuo# zE6oNsxp!6U~_K}o0_ zsVKO|%V*+(4ek*~J_#D|#gYg{k2_}AQ|t?$dt{_^^$A&}Q?=G*R9g2@NvxkloL#!V z*RSzPFg0=hyi?xXzn<97d|-OjgE*sAmB@$?997&2NE+ed$I(Hos$NOOrQZGWmyY~Y z1NJBYEhBfc_Y>RclB<4>^bsXEgNp3eV(1nZ??VayMOTvkup;SfgU9LRds|zLF~r7i zgLL3_VBAi?Xk+Hpa0zvp*ZRC7PFRCQ04%OCp~YIgEW*yiRmRztvCItr7L8l4_v? z`b>xsHqr7kF;FxFK3^7aF_Dzi0e6rD3nBLz{Zulmt#QXIWtjx?$wFM=tt1M@F>@Zu zs^rVX0Mey3{^90iuyj48mp>kSLF3cdAxy0lwOVGJ~LCK zEBNv7ME%OUcE4P)XO(1PrZiTWIKz! ziiSVT1-eUo>Id(O2x2`-UrTan6&ED;nJ^l`Q$8USjcp6A(Uiq&G0{dy$NiStsG8;? z1-&G{H&4s)XAd=r^IkXc;F>wl&u1wOOIEf;XqRfT7!d1)BJ@rnLibg1u1-cudirHP z3CiILZKdc+pCQV9A_}+w(We~FHZQ8)JIT+lLW&I9f_})O7V2j44b=;W{>3Xh@1jjk#G*33&TvLLK_$8~OzFr@H zGURgoqwwx)3?maS#eUNORK%slgn=5IzbWjOTPm8$$jBWqGy(F7nG(jjF~H5qwPi(* z!>9hN6H?(65Ok!1i(D8-wF-|3amS_5Qs1K-4~1@)DZusiYKkC}F>j{pdy4emsR7=W z={aXR+Yc`T53bmRCIZIVc11g)ZCIbcOSkT(2#A3vy9`Q>zUnYS%H@A1XD^{(2#p0+ zGNXj~{ibYwuww7GIKmm>t@&1@SXFhV_l8Nk7msI-uE%~ z8JuY1x_3J<)s)#orQ~@w>-CaLQQDcyu!nPbRb~*Ha5kXSE?L*$#t$Lccmg>j#=Hf1 z$nXy{8T)t81L9j4D5AM}!6|Ss;@A(KmR|6;el`B-07uyGIkXi$2D?>P48t9%}}Ya9UvaapRHxbF3iV#3EEB;Eg0&k1I$&! ztus*C{D=@7*x6B_W%R?U=Mg-6jxSg?qI0xMsx16fRM`0kOWksJ9o4o{PK(f*MT*OB zu^_U2<6p83D@4m39E7t3vSmahK^9%!?#c027?+**(>>Rnmu#fP9B92?aZ(X@62HmW8FDsMn5{5JP5CXzh`5x3_hJNG zfKlw~c(Um2^?BQ4ABW^8_$Gbf&}=IWv|?i_QI&rSI|5Uds%|+qCw_5BF0TT6xXH@l z&wdD{C-w??A)w1~_ndFO+hAB-KZv5|*|`hH?Qq8$Bo54Od>S{`yY9pBhhU?xTCK-C za`kG^`{{7t{kRF(a)89qfx{bnluWG1*U>sB8OarV7sZyGyR%VY0)4V*I6Z+<0tH5D)eabY1+9jn@>o3bZp z*)g{a!94DAdf;Ok2#$obOjGI%G4fb@DVC{$x(T$pIvy<@QomVhK*vju8~x=>r-(ai zLV91S^-K{oO{2t=?~e+Aucr0$O*TH3J73f>V}~E?sLd0#VCeb;sWn{XVHz22G74Wy zvf+wx_jsFq!A*s7v-;X{YX<(AnpaL%x1w!M(?ONKOC}*Ic&TB^Ya<(OnJmQI;${N&+qx6|r$LslU;`=;7y3*I~@&QazmJNKeDvGQlo zR6PMyiVAX2gwgg#29)y}x4ihph4GtzqE*)TTZ-yT>kF=;CBDX3nxd9%l#HH4{e1u% zoOs^+J{P9?Y}aS?p)GdB4EJ8Zy=KA9)?V!J!vQQmx^tW=2VRVub6Xi%lK5{Vnrr#) z{ZU!T@=WRAzcj7m$h$Bk;Q_&eY;`d6d-3y0DUFs3eG|kpcqHv|8!YX(^zje@NE+a8 z4%QHEmA(ERo=9SiP(XhQ!HGY01h@hhybl9hn**va#hCdtqV9%isqAVnbLSw4>DoLz zT#O35D###oRGDHI70?T3Clt>KEKZA$2%?dYXyVHbuu(8$V0dtBUeI?rN46x}pd=jU zVbS#~TUvF%d4H`dtX#MTrcvg+!8kU=5>RNvwn!T`WQ4#;(A|8LOnHYpt3X4U>k41< z{cf!~%#{39Uy6aY4>`RamTg!Pd)0;4R2f?Cxx7~Grpr)&=wkM&vB8Z+W-$XUyuF-=P;?i7GXexB3lp490T0EU9KW!^ zbM+{rFWNi2s^Qv=q(!MS1_nhpVQ@jpR1=X zNWwqgm(2+*m6Nk8?UpYqNYPpC;-Hf-&fOaylrM-?rVLiPLk<#O0UUU*UAPU*gk5e` z;-@rxehG_}7Im3^G$BWm%*eOX0Bc3ZSA+WwF{g)|jnKk`XFA!zE3J~i>9ef19rgqr zvyQt2$D;)2-F?vetxaH5voA5DA&mJTuoj2qGUbU;oTQZoZZl)-fC7XWVOnXD-OU9|Qd>YvBROt!lpchx9<@$;FgWhP zwJ#1Y=lh-RZwA@qdV1c6t%!3%sxs`?yODu`RN58`Vzq#Ep4lyj9}4c1jS^&dujM zmP~v%5`Q(mC)bDKhq--c95DA0$5+8GbdLpCcelG!sN+6;=Y$VDzx?UptVT@sh^PT_ zc}kYdlMj%27}1lRle6W1n0_8lkg>7cXl9(v73VMKy8w;r`zw@--86jNQuVppTFKfUErUnV-VEX;`hu>e7tTe{VV%n%RKn+xu%{{5bO|G749=5UNQyMck5ar@<4)>unRBo>ZT4 zXW(IOVyc&su~Np@xKXXWu5M5|7Oq|(O(Wa@dbaJb)U07LIqxEL>GS37)E^DJdZYir z-dhF4wPoGH!Gc>zkYE77`8mN zpz~*~y@4R@Nqe@tW%>5$3v#m*V3nL_$ zqmX%Mpsbk92&&Rj%HmOSZk%r%P&lPfI^%qG8<$nj zBnN(>8rqt{%QQ`Q+IjnApD96RxRmro7M@z| zQbv2LOf{nR4HcN$VmXsbmD-U4nB!o=2X?zWr`NK!U29SOC}v!I5lD>t?2RRTP5^jp zqSkVW%nzvew4;|CtcY(#TMd)o>YcB!9ISa&?I$PVwV&ixHQ3=}-HXO2>aBOU#K2%D z6t<`Od|qa{C@)m&HD~)W=Ed|wJAcZVR8hSVe~2se$=d9Jiu)#!jG|t>S0xd)edRq= z`?-@yQ^q(V9*RJrIY$!P=pUR|wI5U(IZ23%shWs=;$Lr=dDIZ_R&0%vzuWAH}7|;Cs7OSj_?YDb_t6_r- zO{DOZ^f~?p4yWPA)kH2BY*Ztyq_+@4>zQy`E|+(VMy=GVa>6hu!29G%`0_*nSKe7S zy9;kAu)_tJx!dTS(6X>C*J-b}wu5Sn*xsBvN+Vr>9C-jG42iC3pQXt->_wcf64`K% zBfq}Mc^Rbz50t`C+V+mzz+4V8!sKgYxnHQMmd2ZL@UWjO8Sl7mSniqQ<%}k6fJqjV z?t@KZH(v~BR-5R6O`EiGyv&y0?D_7dN$=`Vozckw9klEfMkn@1F*gIU zD7-uO7v+%me5);O;rL@yt`k4XGCRj*>O)@)gCUmm9IGbuKh?HwD~GA=t9&R=@Ysbr z;mFL{Qm7&!0NVuGdN1{~_Im>nt!|i$-`%8#ZRYteC;`6#D^7L!F(r^f&GO;ZP6v;} zpo_O#+_tp#nd&8y*+}`W4wr2B9%0Vca#6W~){n0mw;GKQlKpM^FuWm`7mgw$s;mE` z{DK~262j<>3}}#kk^GHWo2ryjP}q#@ciTu!16Zq9gY21OihEZIZ~gBp?^)+bZFCX- zF8O3*Z{0eq>GPJf?XxOE8`Vz32qCo{EvGNn2g5&)FI5+N=G<+2M-`#v`Gh~~q5bY& z!DooAm7vR|&P&nTE142Aulz*iE*=3d937_xbBDc=ppc}u#PF(Dk0I=ZrnFd81^&&6t{b!FT$=!_Aup#Xumi??2M`)xi@fTq{caDgihTVB9TVG14-aU=0OuMy9N(u z9DgFqZ|BQCz;?Lbn+$@~h`DmNpYPll&|I5J9vMPDavqE)MiZ=m_5s=h_wI`)v~kt1%qlG9Q~a4> z7f=u?Y6P&`vAkC54zEet*0vZ3fE;j=)$JRBcF>gd?_J}BR7TTOmxeu~>mSgY!3Vyj z?0rq)m&yTxp)F^8 zi&+um5Oh+Lw%U+MWSiX>d~>5opK*G)=yuF6Z#^?HQ1Xo?O+#uuDD6VzN-j)Y$+O{L z-r9;n>nQ_PY-maS`2PF((4kmvfTqNva>HsuuM7~(d(%ZTk427W=D0o_NK9frk(hGp z?whNLDi|7*n=Pi1xh}Jc_`WO18*>y!{4pe{sK)60HF7}rwHLtp?}bq=$G>VUNSGHn zT|!&COd{+ClIBXpJdDB^LAa-9rE>7=WG%puFy=tDC@Mj2(KaB9CmpeR5t=%=-@ATL z5_!=#o{(YctxuWtrQv#J<;Tk_ZwA2Fr9m4E17R{>+>sq4>>Q=u@y+00#2V_0#nNw= z*x3kAA@hg)le3e^k1dZtu`&2b4l>@8C5sEYmdm&2Kvm|^oIh8RMN(piX*J(gP!~Rg z<)@jn*xKchG}ys-zGDBR-evy4Ma!ZxxDk$Y1u+2;1PP~(n3$*K#NkK&ZFo01Ns~jk zbeXFnH}e_qnO9|aij6=D-x}c#e61qI!*DRyRx#A0^giR*4c|;p%qM#9UlF0E_(Q3< zhq7T;*f{WWapC%J-3g}+31nZ@(KoU+zu@m`KOOJqa~cwMApxjP?GNB?t|+Tt0o|Fe z?vwQ=+t=q5oC^`|+8U=9o2l;6Y+ZvJlwE3XzFn5GS1;jjq!t1!O3<1gOUo1H%;QY zy6~(-DOyA|?l<4Ne$>?CDOzA(%D0d)>x4~wI8A04LhSAqDw!S!%~839Qz&i0xE z5Mqz9a3B?{_j?9M`-n*G&CM8qACKQ*rZLZmr5|sO)JbwDaT-@!YXZ5Uw2n#E&rDlICM}Z%XGz%p zQaHw4rI?M60MXVuOM6Z$pgzY(u$lufsJyAuJzZbW_SIh2WQP?_4m zB6_wZ#-JoH3OtgBU0x()U(>c$I*h4oexTZiGRef@Z<)k0yeEo~SJmYv^lP7KK4F}H zq4Go!W4aF|vFdH5f`#sUONv$S`y!XWLvx)&1n55&4u-EVp(Hf$kAv>XNCj8q1io*q zpL=5B>|$mmm!}t9)|$Rivxs3?^M!AB{-Q=C%W3IZR;HMejD6t zZ~IQgv`OTooyW2yE#iTC4`v^b=3Qauzl1Yf=RbfE zQt&H>eKDSMBXSdcRar=^pn@M3gsmlfNp5@0eV1!s*X~kgwIo+Tdns+fA`FqD)d=4t@a9%_JI(KQXqHR?$-Tv@O3 z5llk9qy#9CozXz`O!^{Hqxoj&3Lll{D1B&l=GF04{xuLg?;$ZX`8Ej6+RSo1M$nm5 zB1`OxnWsW7`mavfnn<6ulgOdxI(NM^ubF;em&3XpX3A-=b5K8+F4oMkCe>sOBVWSy ztIEX26?m+(D7;Z8^BDd2WG-38GIkKo`{r<-5BibE*%+l!4>pkaJB2IQwNVd32Q}tK zWx_};Y@|^%i4w)(nQbR0i=>966ErxQ53^Ty_vPvsII#AgV1$$P!1KO-yY;xsK4<X59W&Fy#I1P;SN4DByI3H+L3v{`$oV31W^?n0l#{Dz|lrw?@ztoHCH z+3glckTvbL7990eFvD!_U}c#MR|E#lZ;V|mE)q!{^+cCY$ssly%Zcp9G?7_Y~EN zFy_en7N)Xv!-(cGFgIksBumNGQ{Tbnfx7o(C&H{P5 z&Z2UY(@tp@eU}p$fAlI8q*bAyAY58EfL54P{!=~3skD^2fsU#WG#VwXC)_c%WH;IT zMs1To9xd<W<}cH4VAL-spXAus*f&r?-e&Td|Gg*XYZqRDkOdovg>4Oz$HKZ%~; z5eV>4gr8uV?a6_>Y+`)`lu>vqyOf4v@(0eCXsdi?brbac25r2hdl|+uYNYUzr~D^k zj|fcy8|sA_P|^ckcy(A*$Ax8F7Wh@i=E2af=UGt%;Sz?wv^l4P?+xgiwM#(e&8r!E znYS8_cv-xIkFK4}=zs=qt(ThNYUbBXV(R6CQMgLaeeu`bqa<3b4du zF8E5Ar&+1;ijE)+8tzCd0vEoOxGkhcTtqvx&|1vu4%?%i-W`+>0+F;+6$d zZ<<8KZ_u-!+=LI5RzA)UzeydN5NiN%z8C9{nb+&>dY~!lkhGF$^qwGN!*#;$ZM@6W5d;&Jugut zyVVo?C}Yh+>#~%%I%7-wi&M{OLXyZP@|f|2l>whgl#Wl#C(oA@U6R~X&P_8EYZr7g z-`PTIR(ZDL;cvXt`N+F|{u$e2nNAFz!RKezXU?bd$qdX)sZy-NlAlKV+&23w z+|502)+UD9v;$HtlCa~}373d3X_T2^ZDsY-Lm2=9=`G>OE8)Wm^{D{Rd(9m&6LN^& zD0S%UoB<7#$2J?pGj>P%F}gK60kt&}Q1Ns?2xCcZ)Ps2Ntrl0nQ23&~cziUHM}??N z3^z1BHJ5lT^d4Ie0WU9}RiwuU)!}Tvw*<>z?q!%mpmN}{VP035VEHg?<;0>Jpa{zj zOVEE0da{)jl=18K%zKqWs6`F7Z8rLaExO-`DoCIX4A1fFa0k)J2`vEC&!TH6kp$#u zd5cavdi04j4bjdy^#cmUv%R^QQ_H9WtOO|O7k#;4d-aClx=pMki-kHYfTmA5HF3Rt zBvQAVKEicV6dF|iFd&|{>PJHUj0J$2d3rOzn^uv%UUC4+$_2~JGWo84oVSI*zk+UN zMg>HQbe09DZJk>?TDV7ky_;_aq1rpSKWLOWym3pXNP*arTaUciTrEl4;$85rtE`89 zt@$?6p?F%@HJ|A36PK8N)9De)_Kn87C7i9~*Wh+(bt>9^iM)h8Nscod7|TpvF{b`x#z zwb*|HAeT+xMUEwx$1RBgZQo_=87hkmUa|GXv+XNBt|zS6>Fi3{)7B@xG%i;arQbGi<$CY{A_n%mkbTBX3S1NeecVJZ z-)ZMn=1ww8S`XR*EeM!h1fQEbG z+`+#CZX6+()|Mbjvzc!>6}VarPvMz$C517RUd1@=Z~$QlfErIb{vLhb&r2>s9u|(373yCzvG#@Ll3Qr!FKp6DiX>i8(E($;zbVWBdG7HwkyZBOH`Pr>5fTY*t-m#6`th+T4He3=pWm;wEXlC`4@ z*w^;RZMimwFb;m5+l3`REv!^xI+PcX+MKNc)R*DohfUIz*LOSD6BRrl}{XEXq(k(JoPs?0c zbQ!Czz_>B?Cd`w<{twZqzXiP^e86gix?%i+Un=VDPEbuJS15;&u`mhlSAIg0`KZq6 zV-;yMs=SA3NxUE**f;2yns7-B*$j{Vn?YJ7pf7PdGoO?K*~skr%~9UnLOls<7Tx9T zz5`>g_r*p1GL{XJT0J5{P4rj0850o1{7|rxo*IO;+}`$wWdaw1q`*L2i>@~2|9TWls%gpe&T{q`3ZpM*B~KuTpn9v{KbOYIc8YIRD{ds{BsX)Nq{l zApXC7_>enE-B7FX@})n|`(Is`84_pucy|4?U10lfAO4~Go!aa4Ps5f!lB$2V82|IR zUKhO>%0-0oVuR*?c;-LW=GiY`hS`59clrH!U;L|8X^xNrmXW%!$-U<8ft8+NTMZDq zTla|mx|(GBcv0!_vBardLS2FMMMCpcxaRt_6u8eOuY{eiAnYL^Lj-$g^9T0$Uw@4l z4KP)LkFLrhT|#v&V@RiZqbyHtm8Vx`ghlg5cTKk)mKp!HV6gK zEkaZ?uW3>>R(7Bj`;VaQU)r;8w&I=jDg`mm!HchJR=- zKg&{XypiMiH6EEd;Zi5oN0p^`65=2wk5kKCK$iDcuI1Y*0Su`1BZ+;lIEpJ3pOEqo zEF)P?WpW#fllatUC<|Kzh~A!{eGT%EYVCM^_!zVoz6q+hApPDV>~LP1{^f#e{C!Wm z;KeLE|HIsr`i=Cs<4ZzH>!{tdx|CC^dRLW@jABp#Dy#^2UE^OVA^S?m00WUwWh}?Y z{+pojLj~072Xc?TKxS+CWq#INPO$WLyC8a#x?<1I>!P$#`8)C3c4a5~IKWm>p+6Ji zUglC^yq!}2W44oPz*M>WbuV=@!T`r zqPeHJE(`A>gzU)pjc%hqX2TVHd%%FHsUy0;=W{l>q3%tIQ0>Nd3kV1Qhul3osTvbz zw~u1VYTw=tn(ksuiWotokD+KT#A|UDSbem9u{EOV9FRRNxrma-b+l=`qihJ99IumA zrfG0#+WL1%*&dqpklkm#vxiAYU;RdhebUQ;wGZ|eD<7*qzT>Z-VfQ_<=PU{nZ*XEt znkXFXgW}U2OcwI=B;*Y)_nm{4-}e&dEy$(_;Ns}}6@YoH;uN8gcB~LfF8-%@8N zG1%K>Zy+d&0XUFH3Br;)R+q^Gzt%-_1gu(79=Y=D>oI#~6i!%2g{L@t|F9T$PK9); zcj4ea3l9G30Dq*IN9{+O&WkR@JS-%wX4+eGICh3A+F2FBLITHQ*KGQrbg_#JtIT-Q zuX0=8u@Q8weJRHs%b{vjc~;XBi*eyLCk~l1B{+J3k(hyWIWxK3u3`JwB*%roOVvyF zjb`-Aa0i3T2s08lH#Zk+x?2#^>AJsVl;Eow#XK~FS>b%Qv)zXN1)(20-(2?5A`N3! zkkVC)lQxzm=RQ{_3ER9PS0?^N8a3)lWZfj*SbPD2ViBF;C(*XNQ#=j8?&7fAV{AQt zO=f?<>Y3z;vrBg91-kw89e*j=3{BsgX>gav|K?|$OMIH7+pdewjM;~r$Yadk)akmF zi~#gN+xQ^OIRD!VxpSmF^;&on1&Y>Ekl?e2pGBjKd<4ftPA8$rihZHu^x(?!-aPhJ za!jrju77Weck#*^py-_@REQdygpYF3#&OM_lYTjlqj=5hkWH+HU+12oME4{>(lzX| z2gmi=yd2!}&Nvrb7{Voy=sPyqlXJqr6u8V?W7*dDeSC?w_}<9+i5C>DNU4+7^-w-; ztsYrebg!eM!`7bR(SegShPj)KyEor*wdgGC2QIE&49z!RF_caiTzJ-hsQX0)pF z=RUheW`6q6rF1{GBe3R+a)pE=+u@%5{DQ-oI;8H|as0b6%ifv9>PRf2VS?JMXe!&8 zOi?Y+1tF@hFlw*L$&b-dYFs4E9i6I8e%*=+gw6+W}w#pcCn3Ya3k zZr?oJohkKjftCzGB&|E|8R&|$-mn(?YoTdjSfq@u7S!Nrd~gcMxMiWHy?XwQ`fB7{ zb^rMQ$ILvS2o~K4Znt$>d=I&YH9M(Y2AOl0$FTCq%0v)r%XlZN#mq ztqSn^2BwNT7^j;?$RxV~LBVk3 z(+75@qd^8oiWfnSWb9^NJRm)n>&0i7-eI$U6{d=zCfx;J+@Q;70TLA604-|i;-lW} z{#}^iD{(T=Rq5-YeFZ=~T4Kkb_sVG=ChE4~OxNx!wxedaLjL$isn@Au?6+FYoW}1h z{B_CW8hX1WzPebp`sr633`SozcwasBUs-{xOV^^IJPc6gwBJV!C@d9KdTsuxNBpua z^}wAuHcA&cXHXgDALz@eNkBLG*f7L3ontTz!lLQeRU$XUekEHk-~Gr<%%3nhZMewm zA{4yd$id@=DpMBVqm{x+_=4e>U?tAHvHm6IjfHL(A7RDZIN_o)4@J0BV(=FV9kT0I zUZ~G(hriNc>S|xNhv82ujR6e^HB(Ohd=+$j(DjJhIO(eoWnOBQa|9yIjFq<5r`lnQ zr+Bp@;yH=3D|^K4WNFL(pk&N#qV>j3N-7j$j3>?Cf#BE-j0@wCV)umncFbH1~{(&!Sh zxJ9d6qn>9%DaWM9RnqeyA>X=r?!;DKZ zWjc@(e+WJ-(&2gKk8e%uuo?$%Ls)Zt`mSMR=z|^jL3#Yv_ zkg`EYf#IJI(26dd6GXxoc4#(QLsO9Dx`ppiPxgm1T~H*)`=eU+`n&gqc-NE`KrDKE{7J#_DBPg5A75PNyu%_I*hR?JpA~ zF$*BBQwUe}ZC6lj#uxuQ;Iio6`U0!YZ0-j=?1 z0HxksO6p<8{2r9C__k4`D|vRqaT`bK4|4iZ)Z=XWg1QN0S*;68^S?(hvpvIcU@HB@ zrT9Ux_~HGQAYA0jFGszVI`e0Kpbz=X3#Y}CCVu?{i{||_L!KN4R}>d@TP+qkD&nXx zmZ4|NjVbHNSV3Q`NmgecHw|dlP9`2V?wtl-E?JfJziHm@T_aOyEjmx?dV|ZUJy*8) z1m&Vk^Srzj1Wvy4R&i@p#d>XeR`7t)EkLYl*}JfzzOEbYOXFn7$UzC!d$5n$DhnTU zX#uo8>%3Vj?Zi3ETFSq=!kC$9Nsk00yx|{9$M%Ds#q@LF3k{F?(cA){zU%E^JyHqL zL?hQB@=>rE`q{w!HH)AeQ3s}-HuSl-L75nN!#Ww(=-0@@tFGPL{a~$_!&>O`w2R0W zZND+VQw>@(0{QV(sFpPco54bz3)Qw^&baN9$rDxbA~)ennn9Pe*9* zku8*8mdM0llOVfB)l5YDvLx^fzG{(`6nZczM`h+k%)Gn!I|DlA;^e-nic3yMt|7GZ z#CyiZ66jt1BI>6XJ0w79z>Nu4+7FO4xOs|@U$J1b15>oQ8RzI|^ZSEH#xEZjwmwFV zXuDObsVV};56^S5^N8i^wh;;{Nta#D)+#>A5jU;#g*eH|ak1(Izm8)k#qFW+_ zM`dr(got0sur9eds*NX}?;ejOPjqjl0dMfEwz7&9?7V);(MYR~hbl|78oqdCBY>7N4O-*1>W%tj(=(1Y8OroTs}oGw&$fMBYq@&a;TU$0`;1fdK;p#(Efau2!I+QC z!~?3#atI~pd5tbE&7{d61&n`r(JcssBAe8+MeC_0KAI^zfrpoMP34axpFQp8R7@u0 z>e!p?#)n{Isp%PbbryoL0)()L4$ZR4NT;p>7m;4hb8f~yb-JcHyy}k7+W5gm+sD&! zxx4TTBbXqu{=-U@ti~Ne4bHsRJkB$2>p`@~72y*QT#coIKJrLaoryvgpq&tZE0;zo z?~GNRa@zOEPI3&M>CJ!(*a^%nIaFg9hZ`2@T583Sf*&8K+`K<|>XbSHvs+~Ee?9b~ z-viOMDP4<~F~}}td7aP}AnZpfXP}P~U?Z+y&XoXQ(CXY-YoR}1I}=nam}#;%qkAal zJg0s*xC>9)Oc%2xY^xO98ONw4GxHF4x1^_HM_CE9!m2&8TBeXZ)QlaT1Pva-vfXw& zo+dN&j}lr}L@GS4+Sg6==@3_Y8gLKgpupM@QTUm3?Mb7~mamBMa7ZA{T7q(S+x%6ExnY^k3hK^1jl(L% z=PW@Kh4kW~I9gi&$psbeT##bJonRZMRudojVc|8%_7lErCk>VH=*6yb3c5(tB990p zf%3Lpp?PnJH{A!3Xpy^CZc~@MjI7kC#f46O3J17}nHZM99ol-cF$xcFZZeMwb_r`9 zpLCT{aCc*AG|!JFvV(^bIc0)XL8+4xR~uye>@~XDC_y;^hx*2E8mz`yj&X2_9t?tT zott#xL6DKLN`eqt#b*3)WwVm|7_2|ZkKdF$H?HBM|C`GpR_!q{v&ur{Xr@e51UMzS z#jPc@aGCU{@1c%o-aF|rby0(*NeIe#W*lB0yxc2z>%FullShk1@_YWY*}@$W)9rYV zWR>%E#J#a0JIl-)!da3H!hp(r>bgQ_-+NgWTp8CQl&CiIs;w8Uvn&R$hd>RGI7y4*q7DG)Rq0B5FgcUIzlBHYu zaMpJg*PWq%I_6A>$b3D1!+uW-S=M`W9CX_)F=kuJ)iinQZ|mbj1RU2}ND|=a{zcnT z`~|zVVkBtiBCs>0UIeHpI2aE&IFu8+<;-*c>muGink+^MKX3+r*UOHh7HA4XxRqnNLWs6W~fPjnWHK>bN{e;U|dDaN7uZ_ zf1S=?Mh6zUf2F~r9}RYD?36`q(Sl%3wm-4}lm~&_;Z4ae*Rl=vYqz$H8r*}&CxeHC zr3W0^@lx_YkN~a7UUGYSBG`(RpH&cc2tFS~8w^RdT$S0T77uyVvfz1uPw66s@kpus z<3sfC?_`pnP-A>Zeuhe)o&5|a_~WBT4|Q=HFBbHnLUl&xbJIx>*9#pusaI>HAX|(P zvd73|v!`dIbuY%G_t3*+=djFff&11YNGl_|kI3>qN^0;nTwgzIDT7!)w4BI>$F7Q( zs9pN_=83o5D%fH>cwLl$`GuXK2J9q5zOf{JJfz2elJs<)YI@GZB%}7H%}=+Y{D4WS zF%q|^-7 zd=YtSZhMjSp!ODuAlA}L1lY{vgm0)=>ItPh{Yy^>!xS&d#C6~P@;~Y$5Iy*3d+T4O z2t5p9zJl(ogzE2;9@S=WeWDmj%&=(Lzm`Ja)-THCLOc&pEY0s1_P$RbDo<^*VtUfzh%}v@t0MPJ`;QmM z;6SqmRM+gYpM5L37YP0?dn~>D_#X3{8ZsVd0!bw-GxqiewTR!s;w8eo^v(5fn}Qp% zUGw>vmX|cXvmC+ap4;ekGg^hXM8$g62NFe=_V(9(1)U+J#`bjmewvyU3^r4ZA%?SI z#Br+j?R-C-0;Fj{D8?zqsEkjq(ocHXYvmsp0njX7S&Eo(Zf{?$;a*+YFy& zpBoM~@i7Vaz$?@@1i|kpusv{8u)Yzg>ggA@DH$%$uV)5cfYp~KisCTPh$}+HmGDv%PWSe02NV4&klZhC}ZE_T_`h zX6iz1y&Vg9wCgeg_NZSk&ajxg_{LN)YocSUZ+D077=1S-EMo`8b^RG~0=jffs2V*d ztgqYriOTc-B|0o5oHUfgrj(C6@|5>eBxWGjr<(VDEd~CsPUX;&4TVs7M3oelPhX!?X0B6y)VB?7JDfmLPc+MX9}p53EJ_;M}@s?+yD!9!-|CyG8qbyeAt zu^Z4wt$`UD%(H-Oj;u=6po*Mo=<)OHMiYLEX+w;Eoz%QS!I>Ko`n9HFf?)$SJ1;MP zAtEJ@y~pk{kmvmp+(QF#Zz#VnJPU7@9v<@zx^yodaj&Zyd~<#Kt68{3u4Z0LzJ{8I z%{rfOvIjx(DUgt@sDs6hS)=qvVcGEB{A*Nu96C8T#o?v&_F2z;j<0H{yl-ZPW)osi zJ1ss3Nfn;=-moQjfP4#;Sy}OEBIF9v@Z!mxd|Sw{${cP=EH9rykEcZEu#~7p)q<2M zM@ec^KJ)ARocr7@6=_LPioxskNHWoh&guznQSZDQ3V#?9|7Zb&t=~X5QiE|n3>U^1 zU)BW9ZgWV2*d@#t=hH8m_1ZPQXq#6h{dTVSXfl8H?0F}qxAki1LLjN)!%g9d%~qZl7=5 z6Idc0{h01#di(HN;)c}mRb$h&^0Doe+Vn5vexn!jeKPXubF;yzH{ny;t$^!&(Q_S; z#TC{qBAS%0en#>-b7?oDx`)L!_bOGS?y~~Ln1J((6E_Ulo89qo)nSJvxcunq>TmO( zT;uiUG1oV9)Dxn-Z(foaV9y1k?d~mfs=k_`Z57~_;>ingu_i!xByCrg@{U)`Q{XKzQz2W40V_PS zZx8Aaorx!avP~HyxN8UvPP#T|`dsbayS|zwXq)T_epi5(&gswcn1)Kr_vF=`DL`|p z`e^&q;tvIMrs~rbsGVU`2n1nj2g>(r<;AGb+4L~K)*!`k z`7phDQv^Eh;gDS$HzOVUiymI~J6{{XJ}?tOZnjQNGcg9ZfC(|b^wu?`ML#bUNDCeh z@Jp9(V_c)HTt463?kXqr`XF-{D!e+BmVQn! zV?r;;k`d@=ZX!sK>~G=Um(vfj*|rZnzNcRc9N4D5P!w_qYFNmQ*WUaOX5a;wL08xI z?dy2mC52K>&|<~nb%XZKg=XA>`DmWBeci!Mu&CCXg@zlS@-JHP(!WQsmFtC|UZCOV zeJHz~f1i<|wr0Mo)dbNynJQKWq9dp7;O6j?mzt#lLz&L|p2AyvGr=eO5v%3sHfmcI zEn^uv-{ML`W3Xhu`xd0Oykgy|4&@U^(BKui!iLRW7skrIwhIq5{ zPaylFh1OofqLeHC3msogr!U6vk0)w>PrtN_I7x$v(ysq7F+m%aXo~RP6~6ec_ z?;jtc0Ly_X)?)#A`N9s7pWAWfb|`&`2_Cd9*41*<-)) zwTp%kTVnIe@+#u?nth?AubV^)c^4%n+$qSBH ec-bE`8Xt|0D3hAX-rw0u> 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 T@YI0?;J0_rfv@x3k0$&H<-mNU literal 0 HcmV?d00001 diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx index 8f29e43a5..757f0ac98 100644 --- a/docs/integrations/cicd/bitbucket.mdx +++ b/docs/integrations/cicd/bitbucket.mdx @@ -7,26 +7,74 @@ Prerequisites: - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - Navigate to your project's integrations tab in Infisical. + + + + + Navigate to your project's integrations tab in Infisical. - ![integrations](../../images/integrations.png) + ![integrations](../../images/integrations.png) - Press on the Bitbucket tile and grant Infisical access to your Bitbucket account. + Press on the Bitbucket tile and grant Infisical access to your Bitbucket account. - ![integrations bitbucket authorization](../../images/integrations/bitbucket/integrations-bitbucket-auth.png) + ![integrations bitbucket authorization](../../images/integrations/bitbucket/integrations-bitbucket-auth.png) - - If this is your project's first cloud integration, then you'll have to grant - Infisical access to your project's environment variables. Although this step - breaks E2EE, it's necessary for Infisical to sync the environment variables to - the cloud platform. - - - - Select which Infisical environment secrets you want to sync to which Bitbucket repo and press start integration to start syncing secrets to the repo. + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + Select which Infisical environment secrets you want to sync to which Bitbucket repo and press start integration to start syncing secrets to the repo. - ![integrations bitbucket](../../images/integrations/bitbucket/integrations-bitbucket.png) - - \ No newline at end of file + ![integrations bitbucket](../../images/integrations/bitbucket/integrations-bitbucket.png) + + + + + + + + Configure a [Machine Identity](https://infisical.com/docs/documentation/platform/identities/universal-auth) for your project and give it permissions to read secrets from your desired Infisical projects and environments. + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + Create Bitbucket variables (can be either workspace, repository, or deployment-level) to store Machine Identity Client ID and Client Secret. + + ![integrations bitbucket](../../images/integrations/bitbucket/integrations-bitbucket-env.png) + + + Edit your Bitbucket pipeline YAML file to include the use of the Infisical CLI to fetch and inject secrets into any script or command within the pipeline. + + #### Example + + ```yaml + image: atlassian/default-image:3 + + pipelines: + default: + - step: + name: Build application with secrets from Infisical + script: + - apt update && apt install -y curl + - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash + - apt-get update && apt-get install -y infisical + - export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=$INFISICAL_CLIENT_ID --client-secret=$INFISICAL_CLIENT_SECRET --silent --plain) + - infisical run --projectId=1d0443c1-cd43-4b3a-91a3-9d5f81254a89 --env=dev -- npm run build + ``` + + + Set the values of `projectId` and `env` flags in the `infisical run` command to your intended source path. For more options, refer to the CLI command reference [here](https://infisical.com/docs/cli/commands/run). + + + + + + From 6029eaa9df148e75dd0be2b90aa96629412fed23 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 24 Jun 2024 20:17:31 +0800 Subject: [PATCH 086/302] misc: modified step title --- docs/integrations/cicd/bitbucket.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx index 757f0ac98..5bd7194e6 100644 --- a/docs/integrations/cicd/bitbucket.mdx +++ b/docs/integrations/cicd/bitbucket.mdx @@ -50,7 +50,7 @@ Prerequisites: ![integrations bitbucket](../../images/integrations/bitbucket/integrations-bitbucket-env.png) - + Edit your Bitbucket pipeline YAML file to include the use of the Infisical CLI to fetch and inject secrets into any script or command within the pipeline. #### Example From 745f1c4e12ba465f85e5d96ad6d5d31462f40f5e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 24 Jun 2024 23:24:07 +0800 Subject: [PATCH 087/302] misc: only display when user is admin --- .../RebuildSecretIndicesSection.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx index ef298a962..23c495074 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx @@ -67,6 +67,11 @@ export const RebuildSecretIndicesSection = () => { }; const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + + if (!isAdmin) { + return null; + } + return (

From ca86f3d2b60cf7ed2df2e5cfc10f2555676c6e62 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 24 Jun 2024 16:19:41 +0000 Subject: [PATCH 088/302] chore: renamed new migration files to latest timestamp (gh-action) --- ...617041053_add-oidc-auth.ts => 20240624161942_add-oidc-auth.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240617041053_add-oidc-auth.ts => 20240624161942_add-oidc-auth.ts} (100%) diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240624161942_add-oidc-auth.ts similarity index 100% rename from backend/src/db/migrations/20240617041053_add-oidc-auth.ts rename to backend/src/db/migrations/20240624161942_add-oidc-auth.ts From ab19e7df6df2357f066642ff9ab427a21f04b4b2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:16:19 +0200 Subject: [PATCH 089/302] Feat: Default SAML/LDAP organization slug --- .../20240620142418_default-saml-ldap-org.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts diff --git a/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts b/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts new file mode 100644 index 000000000..67753b679 --- /dev/null +++ b/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const DEFAULT_ORG_ID_FIELD = "defaultOrgId"; + +export async function up(knex: Knex): Promise { + const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_ORG_ID_FIELD); + + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + if (!hasDefaultOrgColumn) { + t.uuid(DEFAULT_ORG_ID_FIELD).nullable(); + t.foreign(DEFAULT_ORG_ID_FIELD).references("id").inTable(TableName.Organization).onDelete("SET NULL"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_ORG_ID_FIELD); + + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + if (hasDefaultOrgColumn) { + t.dropForeign([DEFAULT_ORG_ID_FIELD]); + t.dropColumn(DEFAULT_ORG_ID_FIELD); + } + }); +} From dfb53dd3330106d99772002ff888e23cc3ee6867 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:16:32 +0200 Subject: [PATCH 090/302] Helper omit function --- backend/src/lib/fn/object.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 87db80343..8e9ede8db 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -13,6 +13,22 @@ export const pick = (obj: T, keys: TKey ); }; +/** + * Omit a list of properties from an object + * into a new object + */ +export const omit = (obj: T, keys: TKeys[]): Omit => { + if (!obj) return {} as Omit; + return (Object.keys(obj) as TKeys[]).reduce( + (acc, key) => { + if (!keys.includes(key)) { + (acc as T)[key] = obj[key]; + } + return acc; + }, + {} as Omit + ); +}; /** * Removes (shakes out) undefined entries from an * object. Optional second argument shakes out values From 04dee70a55e8708dd5379fc7a7fb4482fc772d6e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:17:02 +0200 Subject: [PATCH 091/302] Type changes --- backend/src/db/schemas/super-admin.ts | 3 ++- backend/src/server/routes/v1/admin-router.ts | 8 ++++++-- backend/src/services/super-admin/super-admin-types.ts | 6 ++++++ frontend/src/hooks/api/admin/types.ts | 2 ++ frontend/src/hooks/api/serverDetails/types.ts | 2 +- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 87ba35c83..1f2b6820b 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -17,7 +17,8 @@ export const SuperAdminSchema = z.object({ instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), trustSamlEmails: z.boolean().default(false).nullable().optional(), trustLdapEmails: z.boolean().default(false).nullable().optional(), - trustOidcEmails: z.boolean().default(false).nullable().optional() + trustOidcEmails: z.boolean().default(false).nullable().optional(), + defaultOrgId: z.string().uuid().nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index ea701c828..59a9f064e 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -22,6 +22,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { 200: z.object({ config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).extend({ isMigrationModeOn: z.boolean(), + defaultOrgSlug: z.string().nullable(), isSecretScanningDisabled: z.boolean() }) }) @@ -52,11 +53,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean().optional(), trustLdapEmails: z.boolean().optional(), - trustOidcEmails: z.boolean().optional() + trustOidcEmails: z.boolean().optional(), + defaultOrgSlug: z.string().optional().nullable() }), response: { 200: z.object({ - config: SuperAdminSchema + config: SuperAdminSchema.extend({ + defaultOrgSlug: z.string().nullable() + }) }) } }, diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index e444c8843..0c3789a37 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,3 +1,5 @@ +import { TSuperAdminUpdate } from "@app/db/schemas"; + export type TAdminSignUpDTO = { email: string; password: string; @@ -15,3 +17,7 @@ export type TAdminSignUpDTO = { ip: string; userAgent: string; }; + +export type TUpdateServerCfgDTO = Omit & { + defaultOrgSlug?: string | null; +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 0d06a2aa1..f1abf1a7a 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -7,6 +7,8 @@ export type TServerConfig = { trustLdapEmails: boolean; trustOidcEmails: boolean; isSecretScanningDisabled: boolean; + defaultOrgSlug: string | null; + defaultOrgId: string | null; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 911526404..3e22c2684 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -4,5 +4,5 @@ export type ServerStatus = { emailConfigured: boolean; secretScanningConfigured: boolean; redisConfigured: boolean; - samlDefaultOrgSlug: boolean + samlDefaultOrgSlug: string; }; From aa6cca738e48d16fde45c6e4e9222c49e33915b6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:17:14 +0200 Subject: [PATCH 092/302] Update index.ts --- backend/src/server/routes/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 265fc18fd..21e867da4 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -458,6 +458,7 @@ export const registerRoutes = async ( userDAL, authService: loginService, serverCfgDAL: superAdminDAL, + orgDAL, orgService, keyStore }); From 30b8d597963de25d522bcc53dc881f016ba6089b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:17:23 +0200 Subject: [PATCH 093/302] Feat: Default SAML/LDAP organization slug --- .../services/super-admin/super-admin-dal.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index 64133ed7e..90f1004da 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -1,7 +1,30 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TSuperAdmin } from "@app/db/schemas"; import { ormify } from "@app/lib/knex"; export type TSuperAdminDALFactory = ReturnType; -export const superAdminDALFactory = (db: TDbClient) => ormify(db, TableName.SuperAdmin, {}); +export const superAdminDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.SuperAdmin); + + const findById = async (id: string) => { + const config = await db(TableName.SuperAdmin) + .where(`${TableName.SuperAdmin}.id`, id) + .leftJoin(TableName.Organization, `${TableName.SuperAdmin}.defaultOrgId`, `${TableName.Organization}.id`) + .select( + db.ref("*").withSchema(TableName.SuperAdmin) as unknown as keyof TSuperAdmin, + db.ref("slug").withSchema(TableName.Organization).as("defaultOrgSlug") + ) + .first(); + + return { + defaultOrgSlug: config?.defaultOrgSlug || null, + ...config + } as TSuperAdmin & { defaultOrgSlug: string | null }; + }; + + return { + ...orm, + findById + }; +}; From 1930d40be80be3ffc4faa5d3e0503899527c8be1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:17:33 +0200 Subject: [PATCH 094/302] Feat: Default SAML/LDAP organization slug --- .../super-admin/super-admin-service.ts | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 27e198d85..b06757984 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,21 +1,24 @@ import bcrypt from "bcrypt"; -import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { TOrganizations, TSuperAdmin } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; +import { omit } from "@app/lib/fn"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; +import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { TAdminSignUpDTO } from "./super-admin-types"; +import { TAdminSignUpDTO, TUpdateServerCfgDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; + orgDAL: Pick; userDAL: TUserDALFactory; authService: Pick; orgService: Pick; @@ -25,7 +28,7 @@ type TSuperAdminServiceFactoryDep = { export type TSuperAdminServiceFactory = ReturnType; // eslint-disable-next-line -export let getServerCfg: () => Promise; +export let getServerCfg: () => Promise; const ADMIN_CONFIG_KEY = "infisical-admin-cfg"; const ADMIN_CONFIG_KEY_EXP = 60; // 60s @@ -33,6 +36,7 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; export const superAdminServiceFactory = ({ serverCfgDAL, + orgDAL, userDAL, authService, orgService, @@ -42,16 +46,18 @@ export const superAdminServiceFactory = ({ // TODO(akhilmhdh): bad pattern time less change this later to me itself getServerCfg = async () => { const config = await keyStore.getItem(ADMIN_CONFIG_KEY); + // missing in keystore means fetch from db if (!config) { const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (serverCfg) { await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(serverCfg)); // insert it back to keystore } return serverCfg; } - const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin; + const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin & { defaultOrgSlug: string | null }; return { ...keyStoreServerCfg, // this is to allow admin router to work @@ -70,10 +76,33 @@ export const superAdminServiceFactory = ({ return newCfg; }; - const updateServerCfg = async (data: TSuperAdminUpdate) => { - const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data); - await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); - return updatedServerCfg; + const updateServerCfg = async (data: TUpdateServerCfgDTO) => { + let organization: TOrganizations | undefined; + if (data.defaultOrgSlug) { + organization = await orgDAL.findOne({ + slug: data.defaultOrgSlug + }); + + if (!organization) { + throw new BadRequestError({ + name: "Update server config", + message: "Failed to find default organization" + }); + } + } + + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, { + ...omit(data, ["defaultOrgSlug"]), + defaultOrgId: organization?.id || null + }); + + const result = { + ...updatedServerCfg, + defaultOrgSlug: organization?.slug || null + }; + + await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(result)); + return result; }; const adminSignUp = async ({ From 45da56346546a338adb185de60dffc715205dc0c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:18:08 +0200 Subject: [PATCH 095/302] Convert navigate function to hook --- frontend/src/views/Login/Login.tsx | 9 +++-- .../components/InitialStep/InitialStep.tsx | 36 ++++++++++++------- .../Login/components/MFAStep/MFAStep.tsx | 9 ++--- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index cad207aa8..36cd355f5 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -1,16 +1,15 @@ import { useEffect, useState } from "react"; -import { useRouter } from "next/router"; import { isLoggedIn } from "@app/reactQuery"; import { InitialStep, MFAStep, SSOStep } from "./components"; -import { navigateUserToSelectOrg } from "./Login.utils"; +import { useNavigateToSelectOrganization } from "./Login.utils"; export const Login = () => { - const router = useRouter(); const [step, setStep] = useState(0); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); const queryParams = new URLSearchParams(window.location.search); @@ -21,10 +20,10 @@ export const Login = () => { const callbackPort = queryParams?.get("callback_port"); // case: a callback port is set, meaning it's a cli login request: redirect to select org with callback port if (callbackPort) { - navigateUserToSelectOrg(router, callbackPort); + navigateToSelectOrganization(callbackPort); } else { // case: no callback port, meaning it's a regular login request: redirect to select org - navigateUserToSelectOrg(router); + navigateToSelectOrganization(); } } catch (error) { console.log("Error - Not logged in yet"); diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 82bee7eb4..bce018af0 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useEffect, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -16,7 +16,7 @@ import { Button, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; import { useFetchServerStatus } from "@app/hooks/api"; -import { navigateUserToSelectOrg } from "../../Login.utils"; +import { useNavigateToSelectOrganization } from "../../Login.utils"; type Props = { setStep: (step: number) => void; @@ -39,16 +39,28 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const captchaRef = useRef(null); const { data: serverDetails } = useFetchServerStatus(); + const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); + + const redirectToSaml = (orgSlug: string) => { + const callbackPort = queryParams.get("callback_port"); + const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${orgSlug}${ + callbackPort ? `?callback_port=${callbackPort}` : "" + }`; + router.push(redirectUrl); + }; + useEffect(() => { - if (serverDetails?.samlDefaultOrgSlug) { - const callbackPort = queryParams.get("callback_port"); - const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${ - serverDetails?.samlDefaultOrgSlug - }${callbackPort ? `?callback_port=${callbackPort}` : ""}`; - router.push(redirectUrl); - } + if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug); }, [serverDetails?.samlDefaultOrgSlug]); + const handleSaml = useCallback((step: number) => { + if (config.defaultOrgSlug) { + redirectToSaml(config.defaultOrgSlug); + } else { + setStep(step); + } + }, []); + const handleLogin = async (e: FormEvent) => { e.preventDefault(); try { @@ -75,7 +87,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } - navigateUserToSelectOrg(router, callbackPort!); + navigateToSelectOrganization(callbackPort!); } else { setLoginError(true); createNotification({ @@ -100,7 +112,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } - navigateUserToSelectOrg(router); + navigateToSelectOrganization(); // case: login does not require MFA step createNotification({ @@ -211,7 +223,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: colorSchema="primary" variant="outline_bg" onClick={() => { - setStep(2); + handleSaml(2); }} leftIcon={} className="mx-0 h-10 w-full" diff --git a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx index 5e613008d..5f04454bc 100644 --- a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx +++ b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx @@ -16,7 +16,7 @@ import { useSelectOrganization, verifyMfaToken } from "@app/hooks/api/auth/queri import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; -import { navigateUserToOrg, navigateUserToSelectOrg } from "../../Login.utils"; +import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; // The style for the verification code input const props = { @@ -50,6 +50,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); const [mfaCode, setMfaCode] = useState(""); + const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); const [triesLeft, setTriesLeft] = useState(undefined); const { t } = useTranslation(); @@ -93,7 +94,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateUserToSelectOrg(router, callbackPort); + navigateToSelectOrganization(callbackPort); } // case: no orgs found, so we navigate the user to create an org // cli login will fail in this case @@ -166,7 +167,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateUserToSelectOrg(router, callbackPort); + navigateToSelectOrganization(callbackPort); } // case: no orgs found, so we navigate the user to create an org // cli login will fail in this case @@ -195,7 +196,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { if (organizationId) { await navigateUserToOrg(router, organizationId); } else { - navigateUserToSelectOrg(router); + navigateToSelectOrganization(); } } else { createNotification({ From 56bc25025a6e7925532fe67491e2670f04f00799 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:18:19 +0200 Subject: [PATCH 096/302] Update Login.utils.tsx --- frontend/src/views/Login/Login.utils.tsx | 33 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/Login/Login.utils.tsx b/frontend/src/views/Login/Login.utils.tsx index b6e3c1a10..337a4dc10 100644 --- a/frontend/src/views/Login/Login.utils.tsx +++ b/frontend/src/views/Login/Login.utils.tsx @@ -1,5 +1,7 @@ -import { NextRouter } from "next/router"; +import { NextRouter, useRouter } from "next/router"; +import { useServerConfig } from "@app/context"; +import { useSelectOrganization } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { userKeys } from "@app/hooks/api/users/queries"; import { queryClient } from "@app/reactQuery"; @@ -27,14 +29,29 @@ export const navigateUserToOrg = async (router: NextRouter, organizationId?: str } }; -export const navigateUserToSelectOrg = (router: NextRouter, cliCallbackPort?: string) => { - queryClient.invalidateQueries(userKeys.getUser); +export const useNavigateToSelectOrganization = () => { + const { config } = useServerConfig(); + const selectOrganization = useSelectOrganization(); + const router = useRouter(); - let redirectTo = "/login/select-organization"; + const navigate = async (cliCallbackPort?: string) => { + if (config.defaultOrgId) { + await selectOrganization.mutateAsync({ + organizationId: config.defaultOrgId + }); - if (cliCallbackPort) { - redirectTo += `?callback_port=${cliCallbackPort}`; - } + await navigateUserToOrg(router, config.defaultOrgId); + } - router.push(redirectTo, undefined, { shallow: true }); + queryClient.invalidateQueries(userKeys.getUser); + let redirectTo = "/login/select-organization"; + + if (cliCallbackPort) { + redirectTo += `?callback_port=${cliCallbackPort}`; + } + + router.push(redirectTo, undefined, { shallow: true }); + }; + + return { navigateToSelectOrganization: navigate }; }; From 4905ad1f4898681d917c7d90f664c60e6cd4a442 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:18:36 +0200 Subject: [PATCH 097/302] Feat: Default SAML/LDAP organization slug --- .../components/PasswordStep/PasswordStep.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index 06438a2f8..10be08f4c 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef,useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -16,7 +16,7 @@ import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; -import { navigateUserToOrg, navigateUserToSelectOrg } from "../../Login.utils"; +import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; type Props = { providerAuthToken: string; @@ -39,8 +39,11 @@ export const PasswordStep = ({ const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); - const { callbackPort, organizationId, hasExchangedPrivateKey } = - jwt_decode(providerAuthToken) as any; + const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); + + const { callbackPort, organizationId, hasExchangedPrivateKey } = jwt_decode( + providerAuthToken + ) as any; const handleExchange = async () => { try { @@ -92,7 +95,7 @@ export const PasswordStep = ({ // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateUserToSelectOrg(router, callbackPort); + navigateToSelectOrganization(callbackPort); } // case: no orgs found, so we navigate the user to create an org else { @@ -176,7 +179,7 @@ export const PasswordStep = ({ // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateUserToSelectOrg(router, callbackPort); + navigateToSelectOrganization(callbackPort); } // case: no orgs found, so we navigate the user to create an org else { @@ -220,7 +223,7 @@ export const PasswordStep = ({ const userOrgs = await fetchOrganizations(); if (userOrgs.length > 0) { - navigateUserToSelectOrg(router); + navigateToSelectOrganization(); } else { await navigateUserToOrg(router); } @@ -270,7 +273,7 @@ export const PasswordStep = ({

- What's your Infisical password? + What's your Infisical password?

From e1a4185f76f1645bffbd573e98ed638b75b0a3d7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:18:54 +0200 Subject: [PATCH 098/302] Hide org slug input when default slug is set --- frontend/src/views/Login/LoginLDAP.tsx | 36 +++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/frontend/src/views/Login/LoginLDAP.tsx b/frontend/src/views/Login/LoginLDAP.tsx index 021ac7334..278439808 100644 --- a/frontend/src/views/Login/LoginLDAP.tsx +++ b/frontend/src/views/Login/LoginLDAP.tsx @@ -4,15 +4,19 @@ import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { Button, Input } from "@app/components/v2"; +import { useServerConfig } from "@app/context"; import { loginLDAPRedirect } from "@app/hooks/api/auth/queries"; export const LoginLDAP = () => { const router = useRouter(); + const { config } = useServerConfig(); const queryParams = new URLSearchParams(window.location.search); const passedOrgSlug = queryParams.get("organizationSlug"); const passedUsername = queryParams.get("username"); - const [organizationSlug, setOrganizationSlug] = useState(passedOrgSlug || ""); + const [organizationSlug, setOrganizationSlug] = useState( + config.defaultOrgSlug || passedOrgSlug || "" + ); const [username, setUsername] = useState(passedUsername || ""); const [password, setPassword] = useState(""); @@ -63,21 +67,23 @@ export const LoginLDAP = () => { What's your LDAP Login?

-
-
- setOrganizationSlug(e.target.value)} - type="text" - placeholder="Enter your organization slug..." - isRequired - autoComplete="email" - id="email" - className="h-12" - isDisabled={passedOrgSlug !== null} - /> + {!config.defaultOrgSlug && ( +
+
+ setOrganizationSlug(e.target.value)} + type="text" + placeholder="Enter your organization slug..." + isRequired + autoComplete="email" + id="email" + className="h-12" + isDisabled={passedOrgSlug !== null} + /> +
-
+ )}
Date: Thu, 20 Jun 2024 18:19:00 +0200 Subject: [PATCH 099/302] Feat: Default SAML/LDAP organization slug --- .../admin/DashboardPage/DashboardPage.tsx | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index f16c236b4..b841e61e2 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -40,7 +40,8 @@ const formSchema = z.object({ allowedSignUpDomain: z.string().optional().nullable(), trustSamlEmails: z.boolean(), trustLdapEmails: z.boolean(), - trustOidcEmails: z.boolean() + trustOidcEmails: z.boolean(), + defaultOrgSlug: z.string().optional().nullable() }); type TDashboardForm = z.infer; @@ -62,7 +63,8 @@ export const AdminDashboardPage = () => { allowedSignUpDomain: config.allowedSignUpDomain, trustSamlEmails: config.trustSamlEmails, trustLdapEmails: config.trustLdapEmails, - trustOidcEmails: config.trustOidcEmails + trustOidcEmails: config.trustOidcEmails, + defaultOrgSlug: config.defaultOrgSlug } }); @@ -86,10 +88,17 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = - formData; + const { + signUpMode, + allowedSignUpDomain, + trustSamlEmails, + trustLdapEmails, + trustOidcEmails, + defaultOrgSlug + } = formData; await updateServerConfig({ + defaultOrgSlug, allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, @@ -130,7 +139,7 @@ export const AdminDashboardPage = () => {
@@ -165,7 +174,7 @@ export const AdminDashboardPage = () => { />
{signupMode === "anyone" && ( -
+
Restrict signup by email domain(s)
@@ -191,7 +200,33 @@ export const AdminDashboardPage = () => { />
)} -
+ +
+
+ Default organization slug +
+
+ Select the slug of the organization you want to set as default for SAML/LDAP + logins. +
+ ( + + + + )} + /> +
+ +
Trust emails
Select if you want Infisical to trust external emails from SAML/LDAP/OIDC From b0f3476e4a7fc0762d65b9525990b1f75fa2374f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:36:11 +0200 Subject: [PATCH 100/302] Fix: Completely hide org slug input field when org slug is passed or default slug is provided --- frontend/src/views/Login/LoginLDAP.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/views/Login/LoginLDAP.tsx b/frontend/src/views/Login/LoginLDAP.tsx index 278439808..a8dc6c738 100644 --- a/frontend/src/views/Login/LoginLDAP.tsx +++ b/frontend/src/views/Login/LoginLDAP.tsx @@ -67,7 +67,7 @@ export const LoginLDAP = () => { What's your LDAP Login?

- {!config.defaultOrgSlug && ( + {!config.defaultOrgSlug && !passedOrgSlug && (
{ autoComplete="email" id="email" className="h-12" - isDisabled={passedOrgSlug !== null} />
From 91bdd7ea6a7d858ed697195e6c0ff5bdf9348cd2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:52:35 +0200 Subject: [PATCH 101/302] Fix: UI descriptions --- .../admin/DashboardPage/DashboardPage.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index b841e61e2..917bd1c54 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -41,7 +41,8 @@ const formSchema = z.object({ trustSamlEmails: z.boolean(), trustLdapEmails: z.boolean(), trustOidcEmails: z.boolean(), - defaultOrgSlug: z.string().optional().nullable() + defaultOrgSlug: z.string().optional().nullable(), + defaultAuthOrgSlug: z.string().optional().nullable() }); type TDashboardForm = z.infer; @@ -64,7 +65,8 @@ export const AdminDashboardPage = () => { trustSamlEmails: config.trustSamlEmails, trustLdapEmails: config.trustLdapEmails, trustOidcEmails: config.trustOidcEmails, - defaultOrgSlug: config.defaultOrgSlug + defaultOrgSlug: config.defaultOrgSlug, + defaultAuthOrgSlug: config.defaultAuthOrgSlug } }); @@ -94,11 +96,12 @@ export const AdminDashboardPage = () => { trustSamlEmails, trustLdapEmails, trustOidcEmails, - defaultOrgSlug + defaultOrgSlug, + defaultAuthOrgSlug } = formData; await updateServerConfig({ - defaultOrgSlug, + defaultAuthOrgSlug, allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, @@ -207,15 +210,16 @@ export const AdminDashboardPage = () => {
Select the slug of the organization you want to set as default for SAML/LDAP - logins. + logins. When this field is set, users will also skip the organization selection + page and automatically be logged into the default organization.
( Date: Thu, 20 Jun 2024 18:53:07 +0200 Subject: [PATCH 102/302] Updated "defaultOrgId" and "defaultOrgSlug" to "defaultAuthOrgId" and "defaultAuthOrgSlug" --- .../20240620142418_default-saml-ldap-org.ts | 14 +++++++------- backend/src/db/schemas/super-admin.ts | 2 +- backend/src/server/routes/v1/admin-router.ts | 6 +++--- .../src/services/super-admin/super-admin-dal.ts | 8 ++++---- .../services/super-admin/super-admin-service.ts | 14 +++++++------- .../src/services/super-admin/super-admin-types.ts | 4 ++-- frontend/src/hooks/api/admin/types.ts | 4 ++-- frontend/src/views/Login/Login.utils.tsx | 6 +++--- frontend/src/views/Login/LoginLDAP.tsx | 4 ++-- .../Login/components/InitialStep/InitialStep.tsx | 4 ++-- .../views/admin/DashboardPage/DashboardPage.tsx | 3 --- 11 files changed, 33 insertions(+), 36 deletions(-) diff --git a/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts b/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts index 67753b679..fec132df4 100644 --- a/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts +++ b/backend/src/db/migrations/20240620142418_default-saml-ldap-org.ts @@ -2,26 +2,26 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; -const DEFAULT_ORG_ID_FIELD = "defaultOrgId"; +const DEFAULT_AUTH_ORG_ID_FIELD = "defaultAuthOrgId"; export async function up(knex: Knex): Promise { - const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_ORG_ID_FIELD); + const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_AUTH_ORG_ID_FIELD); await knex.schema.alterTable(TableName.SuperAdmin, (t) => { if (!hasDefaultOrgColumn) { - t.uuid(DEFAULT_ORG_ID_FIELD).nullable(); - t.foreign(DEFAULT_ORG_ID_FIELD).references("id").inTable(TableName.Organization).onDelete("SET NULL"); + t.uuid(DEFAULT_AUTH_ORG_ID_FIELD).nullable(); + t.foreign(DEFAULT_AUTH_ORG_ID_FIELD).references("id").inTable(TableName.Organization).onDelete("SET NULL"); } }); } export async function down(knex: Knex): Promise { - const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_ORG_ID_FIELD); + const hasDefaultOrgColumn = await knex.schema.hasColumn(TableName.SuperAdmin, DEFAULT_AUTH_ORG_ID_FIELD); await knex.schema.alterTable(TableName.SuperAdmin, (t) => { if (hasDefaultOrgColumn) { - t.dropForeign([DEFAULT_ORG_ID_FIELD]); - t.dropColumn(DEFAULT_ORG_ID_FIELD); + t.dropForeign([DEFAULT_AUTH_ORG_ID_FIELD]); + t.dropColumn(DEFAULT_AUTH_ORG_ID_FIELD); } }); } diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 1f2b6820b..29e41c78e 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -18,7 +18,7 @@ export const SuperAdminSchema = z.object({ trustSamlEmails: z.boolean().default(false).nullable().optional(), trustLdapEmails: z.boolean().default(false).nullable().optional(), trustOidcEmails: z.boolean().default(false).nullable().optional(), - defaultOrgId: z.string().uuid().nullable().optional() + defaultAuthOrgId: z.string().uuid().nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 59a9f064e..943161b89 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -22,7 +22,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { 200: z.object({ config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).extend({ isMigrationModeOn: z.boolean(), - defaultOrgSlug: z.string().nullable(), + defaultAuthOrgSlug: z.string().nullable(), isSecretScanningDisabled: z.boolean() }) }) @@ -54,12 +54,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { trustSamlEmails: z.boolean().optional(), trustLdapEmails: z.boolean().optional(), trustOidcEmails: z.boolean().optional(), - defaultOrgSlug: z.string().optional().nullable() + defaultAuthOrgSlug: z.string().optional().nullable() }), response: { 200: z.object({ config: SuperAdminSchema.extend({ - defaultOrgSlug: z.string().nullable() + defaultAuthOrgSlug: z.string().nullable() }) }) } diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index 90f1004da..9fd47b9ac 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -10,17 +10,17 @@ export const superAdminDALFactory = (db: TDbClient) => { const findById = async (id: string) => { const config = await db(TableName.SuperAdmin) .where(`${TableName.SuperAdmin}.id`, id) - .leftJoin(TableName.Organization, `${TableName.SuperAdmin}.defaultOrgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.Organization, `${TableName.SuperAdmin}.defaultAuthOrgId`, `${TableName.Organization}.id`) .select( db.ref("*").withSchema(TableName.SuperAdmin) as unknown as keyof TSuperAdmin, - db.ref("slug").withSchema(TableName.Organization).as("defaultOrgSlug") + db.ref("slug").withSchema(TableName.Organization).as("defaultAuthOrgSlug") ) .first(); return { - defaultOrgSlug: config?.defaultOrgSlug || null, + defaultAuthOrgSlug: config?.defaultAuthOrgSlug || null, ...config - } as TSuperAdmin & { defaultOrgSlug: string | null }; + } as TSuperAdmin & { defaultAuthOrgSlug: string | null }; }; return { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index b06757984..61c7fdf21 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -28,7 +28,7 @@ type TSuperAdminServiceFactoryDep = { export type TSuperAdminServiceFactory = ReturnType; // eslint-disable-next-line -export let getServerCfg: () => Promise; +export let getServerCfg: () => Promise; const ADMIN_CONFIG_KEY = "infisical-admin-cfg"; const ADMIN_CONFIG_KEY_EXP = 60; // 60s @@ -57,7 +57,7 @@ export const superAdminServiceFactory = ({ return serverCfg; } - const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin & { defaultOrgSlug: string | null }; + const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin & { defaultAuthOrgSlug: string | null }; return { ...keyStoreServerCfg, // this is to allow admin router to work @@ -78,9 +78,9 @@ export const superAdminServiceFactory = ({ const updateServerCfg = async (data: TUpdateServerCfgDTO) => { let organization: TOrganizations | undefined; - if (data.defaultOrgSlug) { + if (data.defaultAuthOrgSlug) { organization = await orgDAL.findOne({ - slug: data.defaultOrgSlug + slug: data.defaultAuthOrgSlug }); if (!organization) { @@ -92,13 +92,13 @@ export const superAdminServiceFactory = ({ } const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, { - ...omit(data, ["defaultOrgSlug"]), - defaultOrgId: organization?.id || null + ...omit(data, ["defaultAuthOrgSlug"]), + defaultAuthOrgId: organization?.id || null }); const result = { ...updatedServerCfg, - defaultOrgSlug: organization?.slug || null + defaultAuthOrgSlug: organization?.slug || null }; await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(result)); diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 0c3789a37..94742751c 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -18,6 +18,6 @@ export type TAdminSignUpDTO = { userAgent: string; }; -export type TUpdateServerCfgDTO = Omit & { - defaultOrgSlug?: string | null; +export type TUpdateServerCfgDTO = Omit & { + defaultAuthOrgSlug?: string | null; }; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index f1abf1a7a..524bc6ace 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -7,8 +7,8 @@ export type TServerConfig = { trustLdapEmails: boolean; trustOidcEmails: boolean; isSecretScanningDisabled: boolean; - defaultOrgSlug: string | null; - defaultOrgId: string | null; + defaultAuthOrgSlug: string | null; + defaultAuthOrgId: string | null; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/views/Login/Login.utils.tsx b/frontend/src/views/Login/Login.utils.tsx index 337a4dc10..00f6037f1 100644 --- a/frontend/src/views/Login/Login.utils.tsx +++ b/frontend/src/views/Login/Login.utils.tsx @@ -35,12 +35,12 @@ export const useNavigateToSelectOrganization = () => { const router = useRouter(); const navigate = async (cliCallbackPort?: string) => { - if (config.defaultOrgId) { + if (config.defaultAuthOrgId) { await selectOrganization.mutateAsync({ - organizationId: config.defaultOrgId + organizationId: config.defaultAuthOrgId }); - await navigateUserToOrg(router, config.defaultOrgId); + await navigateUserToOrg(router, config.defaultAuthOrgId); } queryClient.invalidateQueries(userKeys.getUser); diff --git a/frontend/src/views/Login/LoginLDAP.tsx b/frontend/src/views/Login/LoginLDAP.tsx index a8dc6c738..1e99c611d 100644 --- a/frontend/src/views/Login/LoginLDAP.tsx +++ b/frontend/src/views/Login/LoginLDAP.tsx @@ -15,7 +15,7 @@ export const LoginLDAP = () => { const passedUsername = queryParams.get("username"); const [organizationSlug, setOrganizationSlug] = useState( - config.defaultOrgSlug || passedOrgSlug || "" + config.defaultAuthOrgSlug || passedOrgSlug || "" ); const [username, setUsername] = useState(passedUsername || ""); const [password, setPassword] = useState(""); @@ -67,7 +67,7 @@ export const LoginLDAP = () => { What's your LDAP Login?

- {!config.defaultOrgSlug && !passedOrgSlug && ( + {!config.defaultAuthOrgSlug && !passedOrgSlug && (
{ - if (config.defaultOrgSlug) { - redirectToSaml(config.defaultOrgSlug); + if (config.defaultAuthOrgSlug) { + redirectToSaml(config.defaultAuthOrgSlug); } else { setStep(step); } diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 917bd1c54..d863e6765 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -41,7 +41,6 @@ const formSchema = z.object({ trustSamlEmails: z.boolean(), trustLdapEmails: z.boolean(), trustOidcEmails: z.boolean(), - defaultOrgSlug: z.string().optional().nullable(), defaultAuthOrgSlug: z.string().optional().nullable() }); @@ -65,7 +64,6 @@ export const AdminDashboardPage = () => { trustSamlEmails: config.trustSamlEmails, trustLdapEmails: config.trustLdapEmails, trustOidcEmails: config.trustOidcEmails, - defaultOrgSlug: config.defaultOrgSlug, defaultAuthOrgSlug: config.defaultAuthOrgSlug } }); @@ -96,7 +94,6 @@ export const AdminDashboardPage = () => { trustSamlEmails, trustLdapEmails, trustOidcEmails, - defaultOrgSlug, defaultAuthOrgSlug } = formData; From c735beea32e0b7c48c5e97fbb4d08874416e56d5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 01:02:33 +0200 Subject: [PATCH 103/302] Fix: Requested changes --- backend/src/server/routes/index.ts | 1 - backend/src/server/routes/v1/admin-router.ts | 2 +- .../services/super-admin/super-admin-dal.ts | 26 +++++++--- .../super-admin/super-admin-service.ts | 38 +++----------- .../services/super-admin/super-admin-types.ts | 6 --- .../admin/DashboardPage/DashboardPage.tsx | 51 +++++++++++++------ 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 21e867da4..265fc18fd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -458,7 +458,6 @@ export const registerRoutes = async ( userDAL, authService: loginService, serverCfgDAL: superAdminDAL, - orgDAL, orgService, keyStore }); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 943161b89..24c7e2a6e 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -54,7 +54,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { trustSamlEmails: z.boolean().optional(), trustLdapEmails: z.boolean().optional(), trustOidcEmails: z.boolean().optional(), - defaultAuthOrgSlug: z.string().optional().nullable() + defaultAuthOrgId: z.string().optional().nullable() }), response: { 200: z.object({ diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index 9fd47b9ac..eae42cbe1 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -1,14 +1,16 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName, TSuperAdmin } from "@app/db/schemas"; +import { TableName, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { ormify } from "@app/lib/knex"; export type TSuperAdminDALFactory = ReturnType; export const superAdminDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.SuperAdmin); + const superAdminOrm = ormify(db, TableName.SuperAdmin); - const findById = async (id: string) => { - const config = await db(TableName.SuperAdmin) + const findById = async (id: string, tx?: Knex) => { + const config = await (tx || db)(TableName.SuperAdmin) .where(`${TableName.SuperAdmin}.id`, id) .leftJoin(TableName.Organization, `${TableName.SuperAdmin}.defaultAuthOrgId`, `${TableName.Organization}.id`) .select( @@ -23,8 +25,20 @@ export const superAdminDALFactory = (db: TDbClient) => { } as TSuperAdmin & { defaultAuthOrgSlug: string | null }; }; + const updateById = async (id: string, data: TSuperAdminUpdate, tx?: Knex) => { + const updatedConfig = await superAdminOrm.transaction(async (trx) => { + await superAdminOrm.updateById(id, data, tx || trx); + const config = await findById(id, tx || trx); + + return config; + }); + + return updatedConfig; + }; + return { - ...orm, - findById + ...superAdminOrm, + findById, + updateById }; }; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 61c7fdf21..706e5c5f4 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,24 +1,21 @@ import bcrypt from "bcrypt"; -import { TOrganizations, TSuperAdmin } from "@app/db/schemas"; +import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; -import { omit } from "@app/lib/fn"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; -import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { TAdminSignUpDTO, TUpdateServerCfgDTO } from "./super-admin-types"; +import { TAdminSignUpDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; - orgDAL: Pick; userDAL: TUserDALFactory; authService: Pick; orgService: Pick; @@ -36,7 +33,6 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; export const superAdminServiceFactory = ({ serverCfgDAL, - orgDAL, userDAL, authService, orgService, @@ -76,33 +72,11 @@ export const superAdminServiceFactory = ({ return newCfg; }; - const updateServerCfg = async (data: TUpdateServerCfgDTO) => { - let organization: TOrganizations | undefined; - if (data.defaultAuthOrgSlug) { - organization = await orgDAL.findOne({ - slug: data.defaultAuthOrgSlug - }); + const updateServerCfg = async (data: TSuperAdminUpdate) => { + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data); - if (!organization) { - throw new BadRequestError({ - name: "Update server config", - message: "Failed to find default organization" - }); - } - } - - const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, { - ...omit(data, ["defaultAuthOrgSlug"]), - defaultAuthOrgId: organization?.id || null - }); - - const result = { - ...updatedServerCfg, - defaultAuthOrgSlug: organization?.slug || null - }; - - await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(result)); - return result; + await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); + return updatedServerCfg; }; const adminSignUp = async ({ diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 94742751c..e444c8843 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,5 +1,3 @@ -import { TSuperAdminUpdate } from "@app/db/schemas"; - export type TAdminSignUpDTO = { email: string; password: string; @@ -17,7 +15,3 @@ export type TAdminSignUpDTO = { ip: string; userAgent: string; }; - -export type TUpdateServerCfgDTO = Omit & { - defaultAuthOrgSlug?: string | null; -}; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index d863e6765..1c6ba8b8c 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -21,7 +21,7 @@ import { Tabs } from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; -import { useUpdateServerConfig } from "@app/hooks/api"; +import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; import { RateLimitPanel } from "./RateLimitPanel"; @@ -41,7 +41,7 @@ const formSchema = z.object({ trustSamlEmails: z.boolean(), trustLdapEmails: z.boolean(), trustOidcEmails: z.boolean(), - defaultAuthOrgSlug: z.string().optional().nullable() + defaultAuthOrgId: z.string() }); type TDashboardForm = z.infer; @@ -64,7 +64,7 @@ export const AdminDashboardPage = () => { trustSamlEmails: config.trustSamlEmails, trustLdapEmails: config.trustLdapEmails, trustOidcEmails: config.trustOidcEmails, - defaultAuthOrgSlug: config.defaultAuthOrgSlug + defaultAuthOrgId: config.defaultAuthOrgId ?? "" } }); @@ -74,6 +74,8 @@ export const AdminDashboardPage = () => { const { orgs } = useOrganization(); const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); + const organizations = useGetOrganizations(); + const isNotAllowed = !user?.superAdmin; // TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split @@ -94,11 +96,11 @@ export const AdminDashboardPage = () => { trustSamlEmails, trustLdapEmails, trustOidcEmails, - defaultAuthOrgSlug + defaultAuthOrgId } = formData; await updateServerConfig({ - defaultAuthOrgSlug, + defaultAuthOrgId: defaultAuthOrgId || null, allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, @@ -155,13 +157,13 @@ export const AdminDashboardPage = () => { name="signUpMode" render={({ field: { onChange, ...field }, fieldState: { error } }) => ( + )} /> From 7ab5c020001920f2b55fa7f5eb8bd5e976b464c1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 18:17:16 +0200 Subject: [PATCH 104/302] Requested changes --- backend/src/lib/fn/object.ts | 16 ---------------- .../src/services/super-admin/super-admin-dal.ts | 8 +++----- .../views/admin/DashboardPage/DashboardPage.tsx | 10 +++++----- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 8e9ede8db..87db80343 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -13,22 +13,6 @@ export const pick = (obj: T, keys: TKey ); }; -/** - * Omit a list of properties from an object - * into a new object - */ -export const omit = (obj: T, keys: TKeys[]): Omit => { - if (!obj) return {} as Omit; - return (Object.keys(obj) as TKeys[]).reduce( - (acc, key) => { - if (!keys.includes(key)) { - (acc as T)[key] = obj[key]; - } - return acc; - }, - {} as Omit - ); -}; /** * Removes (shakes out) undefined entries from an * object. Optional second argument shakes out values diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index eae42cbe1..f95040aec 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -26,11 +26,9 @@ export const superAdminDALFactory = (db: TDbClient) => { }; const updateById = async (id: string, data: TSuperAdminUpdate, tx?: Knex) => { - const updatedConfig = await superAdminOrm.transaction(async (trx) => { - await superAdminOrm.updateById(id, data, tx || trx); - const config = await findById(id, tx || trx); - - return config; + const updatedConfig = await (superAdminOrm || tx).transaction(async (trx: Knex) => { + await superAdminOrm.updateById(id, data, trx); + return findById(id, trx); }); return updatedConfig; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 1c6ba8b8c..6b20b2084 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -226,15 +226,15 @@ export const AdminDashboardPage = () => { dropdownContainerClassName="bg-mineshaft-800" defaultValue={field.value ?? " "} onValueChange={(e) => { - if (e === " ") { - onChange(null); - } else { - onChange(e); + if (e === "EMPTY") { + onChange(""); + return; } + onChange(e); }} {...field} > - Select organization... + Select organization... {organizations.data?.map((org) => ( {org.name} From 1aaca12781ee0c8feb83e0ed4e01224e91412691 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 19:06:10 +0200 Subject: [PATCH 105/302] Update super-admin-dal.ts --- backend/src/services/super-admin/super-admin-dal.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index f95040aec..04ec8b791 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -19,9 +19,13 @@ export const superAdminDALFactory = (db: TDbClient) => { ) .first(); + if (!config) { + return null; + } + return { - defaultAuthOrgSlug: config?.defaultAuthOrgSlug || null, - ...config + ...config, + defaultAuthOrgSlug: config?.defaultAuthOrgSlug || null } as TSuperAdmin & { defaultAuthOrgSlug: string | null }; }; From a6865585f362c4f6e1f2f0c09481211de1796fc8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 19:33:42 +0200 Subject: [PATCH 106/302] Fix: Failing to create admin config on first run --- .../src/services/super-admin/super-admin-dal.ts | 13 ++++++++++++- .../services/super-admin/super-admin-service.ts | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index 04ec8b791..7e707e6fa 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TSuperAdminDALFactory = ReturnType; @@ -32,7 +33,17 @@ export const superAdminDALFactory = (db: TDbClient) => { const updateById = async (id: string, data: TSuperAdminUpdate, tx?: Knex) => { const updatedConfig = await (superAdminOrm || tx).transaction(async (trx: Knex) => { await superAdminOrm.updateById(id, data, trx); - return findById(id, trx); + const config = await findById(id, trx); + + if (!config) { + throw new DatabaseError({ + error: "Failed to find updated super admin config", + message: "Failed to update super admin config", + name: "UpdateById" + }); + } + + return config; }); return updatedConfig; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 706e5c5f4..41b97efa4 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -47,9 +47,11 @@ export const superAdminServiceFactory = ({ if (!config) { const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); - if (serverCfg) { - await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(serverCfg)); // insert it back to keystore + if (!serverCfg) { + throw new BadRequestError({ name: "Admin config", message: "Admin config not found" }); } + + await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(serverCfg)); // insert it back to keystore return serverCfg; } @@ -67,8 +69,13 @@ export const superAdminServiceFactory = ({ const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (serverCfg) return; - // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition - const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true, id: ADMIN_CONFIG_DB_UUID }); + const newCfg = await serverCfgDAL.create({ + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: ADMIN_CONFIG_DB_UUID, + initialized: false, + allowSignUp: true, + defaultAuthOrgId: null + }); return newCfg; }; @@ -76,6 +83,7 @@ export const superAdminServiceFactory = ({ const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); + return updatedServerCfg; }; From 5a957514df7fcb23cac9ca8ce5c8e9ddacc06c13 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 21:06:11 +0200 Subject: [PATCH 107/302] Feat: Clear select option --- frontend/src/components/v2/Select/Select.tsx | 156 ++++++++++++------ frontend/src/components/v2/Select/index.tsx | 2 +- .../admin/DashboardPage/DashboardPage.tsx | 34 ++-- 3 files changed, 122 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 29dba23c7..015b16f98 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -36,61 +36,73 @@ export const Select = forwardRef( ref ): JSX.Element => { return ( - - - - {props.icon ? : placeholder} - +
+ { + if (!props.onValueChange) return; - - - - - - + - -
- -
-
- - {isLoading ? ( -
- - Loading... -
- ) : ( - children +
+ {props.icon && } + +
+ + + + +
+ + - -
- -
-
-
-
-
+ position={position} + style={{ width: "var(--radix-select-trigger-width)" }} + > + +
+ +
+
+ + {isLoading ? ( +
+ + Loading... +
+ ) : ( + children + )} +
+ +
+ +
+
+ + + +
); } ); @@ -114,7 +126,7 @@ export const SelectItem = forwardRef( outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, isSelected && "bg-primary", isDisabled && - "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", + "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", className )} ref={forwardedRef} @@ -129,3 +141,45 @@ export const SelectItem = forwardRef( ); SelectItem.displayName = "SelectItem"; + +export type SelectClearProps = Omit & { + onClear: () => void; + selectValue: string; +}; + +export const SelectClear = forwardRef( + ( + { children, className, isSelected, isDisabled, onClear, selectValue, ...props }, + forwardedRef + ) => { + return ( + onClear()} + onClick={() => onClear()} + className={twMerge( + `relative mb-0.5 flex + cursor-pointer select-none items-center rounded-md py-2 pl-10 pr-4 text-sm + outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, + isSelected && "bg-primary", + isDisabled && + "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", + className + )} + ref={forwardedRef} + > +
+ +
+ {children} +
+ ); + } +); +SelectClear.displayName = "SelectClear"; diff --git a/frontend/src/components/v2/Select/index.tsx b/frontend/src/components/v2/Select/index.tsx index 6a783605a..3765851d5 100644 --- a/frontend/src/components/v2/Select/index.tsx +++ b/frontend/src/components/v2/Select/index.tsx @@ -1,2 +1,2 @@ export type { SelectItemProps, SelectProps } from "./Select"; -export { Select, SelectItem } from "./Select"; +export { Select, SelectClear, SelectItem } from "./Select"; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 6b20b2084..ef4ff099d 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -13,6 +13,7 @@ import { FormControl, Input, Select, + SelectClear, SelectItem, Switch, Tab, @@ -68,7 +69,8 @@ export const AdminDashboardPage = () => { } }); - const signupMode = watch("signUpMode"); + const signUpMode = watch("signUpMode"); + const defaultAuthOrgId = watch("defaultAuthOrgId"); const { user, isLoading: isUserLoading } = useUser(); const { orgs } = useOrganization(); @@ -90,14 +92,7 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { - signUpMode, - allowedSignUpDomain, - trustSamlEmails, - trustLdapEmails, - trustOidcEmails, - defaultAuthOrgId - } = formData; + const { allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = formData; await updateServerConfig({ defaultAuthOrgId: defaultAuthOrgId || null, @@ -175,7 +170,7 @@ export const AdminDashboardPage = () => { )} />
- {signupMode === "anyone" && ( + {signUpMode === "anyone" && (
Restrict signup by email domain(s) @@ -222,19 +217,22 @@ export const AdminDashboardPage = () => { isError={Boolean(error)} > + + )} + /> Date: Mon, 24 Jun 2024 14:14:51 -0700 Subject: [PATCH 113/302] Update altName example in docs --- docs/documentation/platform/pki/certificates.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 7107d3cb8..7dc3bca88 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -57,7 +57,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. - Issuing CA: The CA under which to issue the certificate. - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - Common Name (CN): The (common) name for the certificate like `service.acme.com`. - - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses. + - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses like `app1.acme.com, app2.acme.com`. - TTL: The lifetime of the certificate in seconds. From d69267a3ca2dfb0a0e0ef4a9269bf257e7b6cd65 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 24 Jun 2024 17:27:02 -0400 Subject: [PATCH 114/302] remove console.log --- .../certificate-authority/certificate-authority-service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 5a3243ee8..7d87545e2 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -776,7 +776,6 @@ export const certificateAuthorityServiceFactory = ({ throw new Error(`Invalid altName: ${altName}`); }); - console.log("the altNamesArray: ", altNamesArray); const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); extensions.push(altNamesExtension); } From d2acedf79e8e2dcc343c0341483b0eac55387ed3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 24 Jun 2024 22:18:39 +0000 Subject: [PATCH 115/302] chore: renamed new migration files to latest timestamp (gh-action) --- ...icate-alt-names.ts => 20240624221840_certificate-alt-names.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240624163425_certificate-alt-names.ts => 20240624221840_certificate-alt-names.ts} (100%) diff --git a/backend/src/db/migrations/20240624163425_certificate-alt-names.ts b/backend/src/db/migrations/20240624221840_certificate-alt-names.ts similarity index 100% rename from backend/src/db/migrations/20240624163425_certificate-alt-names.ts rename to backend/src/db/migrations/20240624221840_certificate-alt-names.ts From 5079a5889ad661a9b294b02b2cc695565a8a0fcd Mon Sep 17 00:00:00 2001 From: Han Wang <44352119+handotdev@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:37:35 -0700 Subject: [PATCH 116/302] Update overview.mdx --- docs/sdks/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 4cc476e2c..2502fcd11 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -19,7 +19,7 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Java application on demand - + Manage secrets for your Go application on demand From a3c8d068456b28b31d65c97166608456b909af87 Mon Sep 17 00:00:00 2001 From: Vlad Matsiiako <78047717+vmatsiiako@users.noreply.github.com> Date: Mon, 24 Jun 2024 20:25:53 -0700 Subject: [PATCH 117/302] Update overview.mdx --- docs/sdks/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 2502fcd11..3f779f187 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -19,7 +19,7 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Java application on demand - + Manage secrets for your Go application on demand From d4f9faf24d5800d6e69d65eb0fa54fe17b4c3402 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 25 Jun 2024 13:59:20 +0800 Subject: [PATCH 118/302] feat: added initial sync behavior for azure key integration --- .../integration-sync-secret.ts | 100 +++++++++++++++++- .../integrations/azure-key-vault/create.tsx | 36 ++++++- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 70e3435a2..e693a7fea 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -257,14 +257,27 @@ const syncSecretsGCPSecretManager = async ({ const syncSecretsAzureKeyVault = async ({ integration, secrets, - accessToken + accessToken, + createManySecretsRawFn, + updateManySecretsRawFn }: { - integration: TIntegrations; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; secrets: Record; accessToken: string; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; }) => { interface GetAzureKeyVaultSecret { id: string; // secret URI + value: string; attributes: { enabled: true; created: number; @@ -361,6 +374,83 @@ const syncSecretsAzureKeyVault = async ({ } }); + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + const secretKeysToRemoveFromDelete = new Set(); + + const metadata = IntegrationMetadataSchema.parse(integration.metadata); + if (!integration.lastUsed) { + Object.keys(res).forEach((key) => { + // first time using integration + const underscoredKey = key.replace(/-/g, "_"); + + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(underscoredKey in secrets)) { + secretsToAdd[underscoredKey] = res[key].value; + setSecrets.push({ + key, + value: res[key].value + }); + } else if (secrets[underscoredKey]?.value !== res[key].value) { + secretsToUpdate[underscoredKey] = res[key].value; + const toEditSecretIndex = setSecrets.findIndex((secret) => secret.key === key); + if (toEditSecretIndex >= 0) { + setSecrets[toEditSecretIndex].value = res[key].value; + } + } + + secretKeysToRemoveFromDelete.add(key); + + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(underscoredKey in secrets)) { + secretsToAdd[underscoredKey] = res[key].value; + setSecrets.push({ + key, + value: res[key].value + }); + } + + secretKeysToRemoveFromDelete.add(key); + break; + } + default: + break; + } + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + const setSecretAzureKeyVault = async ({ key, value, @@ -428,7 +518,7 @@ const syncSecretsAzureKeyVault = async ({ }); } - for await (const deleteSecret of deleteSecrets) { + for await (const deleteSecret of deleteSecrets.filter((secret) => !secretKeysToRemoveFromDelete.has(secret.key))) { const { key } = deleteSecret; await request.delete(`${integration.app}/secrets/${key}?api-version=7.3`, { headers: { @@ -3512,7 +3602,9 @@ export const syncIntegrationSecrets = async ({ await syncSecretsAzureKeyVault({ integration, secrets, - accessToken + accessToken, + createManySecretsRawFn, + updateManySecretsRawFn }); break; case Integrations.AWS_PARAMETER_STORE: diff --git a/frontend/src/pages/integrations/azure-key-vault/create.tsx b/frontend/src/pages/integrations/azure-key-vault/create.tsx index acf2c61bd..e6ed57302 100644 --- a/frontend/src/pages/integrations/azure-key-vault/create.tsx +++ b/frontend/src/pages/integrations/azure-key-vault/create.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; +import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -16,6 +17,18 @@ import { import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +const initialSyncBehaviors = [ + { + label: "No Import - Overwrite all values in Azure Vault", + value: IntegrationSyncBehavior.OVERWRITE_TARGET + }, + { + label: "Import - Prefer values from Azure Vault", + value: IntegrationSyncBehavior.PREFER_TARGET + }, + { label: "Import - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE } +]; + export default function AzureKeyVaultCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); @@ -30,6 +43,9 @@ export default function AzureKeyVaultCreateIntegrationPage() { const [vaultBaseUrl, setVaultBaseUrl] = useState(""); const [vaultBaseUrlErrorText, setVaultBaseUrlErrorText] = useState(""); + const [initialSyncBehavior, setInitialSyncBehavior] = useState( + IntegrationSyncBehavior.PREFER_SOURCE + ); const [isLoading, setIsLoading] = useState(false); @@ -59,7 +75,10 @@ export default function AzureKeyVaultCreateIntegrationPage() { isActive: true, app: vaultBaseUrl, sourceEnvironment: selectedSourceEnvironment, - secretPath + secretPath, + metadata: { + initialSyncBehavior + } }); setIsLoading(false); @@ -107,6 +126,21 @@ export default function AzureKeyVaultCreateIntegrationPage() { onChange={(e) => setVaultBaseUrl(e.target.value)} /> + + + + +
+ handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + + ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx new file mode 100644 index 000000000..38a07f53b --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx @@ -0,0 +1 @@ +export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index bdbd41c1c..c7da5fd28 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -1,3 +1,4 @@ +import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AutoCapitalizationSection } from "../AutoCapitalizationSection"; import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection"; import { DeleteProjectSection } from "../DeleteProjectSection"; @@ -17,6 +18,7 @@ export const ProjectGeneralTab = () => { + From d34b2669c5398276c7991f714ec304d6d397f5ed Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 25 Jun 2024 21:32:24 +0800 Subject: [PATCH 120/302] misc: finalized success message --- backend/src/server/routes/v1/project-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 4cde6a96a..969d113ea 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -404,7 +404,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); return { - message: "Successfull changed project audit log retention", + message: "Successfully updated project's audit logs retention period", workspace }; } From fa392382da1a2b16a2d330a1adff2307c4cebbd0 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 25 Jun 2024 23:41:17 +0800 Subject: [PATCH 121/302] feat: added option to share secret directly from main page --- .../components/SecretListView/SecretItem.tsx | 23 +++++++++++++-- .../SecretListView/SecretListView.tsx | 15 +++++++++- .../SecretListView/SecretListView.utils.ts | 7 +++-- .../components/AddShareSecretForm.tsx | 29 +++++++++---------- .../components/AddShareSecretModal.tsx | 13 +++++++++ 5 files changed, 65 insertions(+), 22 deletions(-) diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 98a803020..3726f3057 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -61,6 +61,7 @@ type Props = { onCreateTag: () => void; environment: string; secretPath: string; + handleSecretShare: () => void; }; export const SecretItem = memo( @@ -75,7 +76,8 @@ export const SecretItem = memo( onCreateTag, onToggleSecretSelect, environment, - secretPath + secretPath, + handleSecretShare }: Props) => { const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); @@ -420,8 +422,9 @@ export const SecretItem = memo( 0 - ? `Every ${secretReminderRepeatDays} day${Number(secretReminderRepeatDays) > 1 ? "s" : "" - } + ? `Every ${secretReminderRepeatDays} day${ + Number(secretReminderRepeatDays) > 1 ? "s" : "" + } ` : "Reminder" } @@ -461,6 +464,20 @@ export const SecretItem = memo( )} + + + + + + handlePopUpOpen("createSharedSecret", { + value: secret.valueOverride ?? secret.value + }) + } /> ))}
@@ -396,6 +403,12 @@ export const SecretListView = ({ isOpen={popUp.createTag.isOpen} onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)} /> + ); }; diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts index c80296b7f..1e1e4c7a8 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts @@ -10,6 +10,7 @@ import { faCopy, faEllipsis, faKey, + faShare, faTags } from "@fortawesome/free-solid-svg-icons"; import { z } from "zod"; @@ -66,7 +67,8 @@ export enum FontAwesomeSpriteName { Override = "secret-override", Close = "close", CheckedCircle = "check-circle", - ReplicatedSecretKey = "secret-replicated" + ReplicatedSecretKey = "secret-replicated", + ShareSecret = "share-secret" } // this is an optimization technique @@ -82,5 +84,6 @@ export const FontAwesomeSpriteSymbols = [ { icon: faCodeBranch, symbol: FontAwesomeSpriteName.Override }, { icon: faClose, symbol: FontAwesomeSpriteName.Close }, { icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle }, - { icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey } + { icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey }, + { icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret } ]; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx index 07a5d2f05..5a97dd9e4 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -6,14 +6,7 @@ import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; -import { - Button, - FormControl, - Input, - ModalClose, - Select, - SelectItem -} from "@app/components/v2"; +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing"; const schema = yup.object({ @@ -31,7 +24,8 @@ export const AddShareSecretForm = ({ handleSubmit, control, isSubmitting, - setNewSharedSecret + setNewSharedSecret, + isInputDisabled }: { isPublic: boolean; inModal: boolean; @@ -39,6 +33,7 @@ export const AddShareSecretForm = ({ control: any; isSubmitting: boolean; setNewSharedSecret: (value: string) => void; + isInputDisabled?: boolean; }) => { const publicSharedSecretCreator = useCreatePublicSharedSecret(); const privateSharedSecretCreator = useCreateSharedSecret(); @@ -124,12 +119,13 @@ export const AddShareSecretForm = ({ }; return (
-
+
(