diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index a9b2046ae6..9af533ee3c 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -50,6 +50,13 @@ jobs: environment: name: Gamma steps: + - uses: twingate/github-action@v1 + with: + # The Twingate Service Key used to connect Twingate to the proper service + # Learn more about [Twingate Services](https://docs.twingate.com/docs/services) + # + # Required + service-key: ${{ secrets.TWINGATE_GAMMA_SERVICE_KEY }} - name: Checkout code uses: actions/checkout@v2 - name: Setup Node.js environment @@ -74,21 +81,21 @@ jobs: uses: pr-mpt/actions-commit-hash@v2 - name: Download task definition run: | - aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json + aws ecs describe-task-definition --task-definition infisical-core-gamma-stage --query taskDefinition > task-definition.json - name: Render Amazon ECS task definition id: render-web-container uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json - container-name: infisical-core-platform + container-name: infisical-core image: infisical/staging_infisical:${{ steps.commit.outputs.short }} environment-variables: "LOG_LEVEL=info" - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-web-container.outputs.task-definition }} - service: infisical-core-platform - cluster: infisical-core-platform + service: infisical-core-gamma-stage + cluster: infisical-gamma-stage wait-for-service-stability: true production-postgres-deployment: diff --git a/.infisicalignore b/.infisicalignore index 855047fe4c..b7fc38b356 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -5,3 +5,4 @@ frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/M frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 docs/self-hosting/configuration/envars.mdx:generic-api-key:106 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 +docs/mint.json:generic-api-key:651 diff --git a/backend/package-lock.json b/backend/package-lock.json index 8bd4a98a0b..97c3351399 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,6 +38,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", @@ -57,6 +58,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", @@ -6790,6 +6792,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", @@ -7896,6 +7909,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", @@ -9603,6 +9665,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", @@ -10728,6 +10798,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", @@ -10851,6 +10929,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", @@ -10870,6 +10956,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", @@ -10897,6 +10992,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", @@ -11948,6 +12057,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", @@ -14027,6 +14145,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 f2a8582d6d..ab397daa57 100644 --- a/backend/package.json +++ b/backend/package.json @@ -99,6 +99,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", @@ -118,6 +119,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 e5cf3f79f0..c7e58fb099 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -13,6 +13,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 { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; @@ -102,6 +103,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 79342ea276..4fdfda70c0 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -134,6 +134,9 @@ import { TLdapGroupMaps, TLdapGroupMapsInsert, TLdapGroupMapsUpdate, + TOidcConfigs, + TOidcConfigsInsert, + TOidcConfigsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -549,6 +552,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/20240624161942_add-oidc-auth.ts b/backend/src/db/migrations/20240624161942_add-oidc-auth.ts new file mode 100644 index 0000000000..3f4b0636d2 --- /dev/null +++ b/backend/src/db/migrations/20240624161942_add-oidc-auth.ts @@ -0,0 +1,49 @@ +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("discoveryURL"); + tb.string("issuer"); + tb.string("authorizationEndpoint"); + tb.string("jwksUri"); + tb.string("tokenEndpoint"); + tb.string("userinfoEndpoint"); + tb.text("encryptedClientId").notNullable(); + tb.string("configurationType").notNullable(); + tb.string("clientIdIV").notNullable(); + tb.string("clientIdTag").notNullable(); + 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(); + 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/migrations/20240624172027_default-saml-ldap-org.ts b/backend/src/db/migrations/20240624172027_default-saml-ldap-org.ts new file mode 100644 index 0000000000..fec132df42 --- /dev/null +++ b/backend/src/db/migrations/20240624172027_default-saml-ldap-org.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const DEFAULT_AUTH_ORG_ID_FIELD = "defaultAuthOrgId"; + +export async function up(knex: Knex): Promise { + 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_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_AUTH_ORG_ID_FIELD); + + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + if (hasDefaultOrgColumn) { + t.dropForeign([DEFAULT_AUTH_ORG_ID_FIELD]); + t.dropColumn(DEFAULT_AUTH_ORG_ID_FIELD); + } + }); +} diff --git a/backend/src/db/migrations/20240624221840_certificate-alt-names.ts b/backend/src/db/migrations/20240624221840_certificate-alt-names.ts new file mode 100644 index 0000000000..fa076b7b4b --- /dev/null +++ b/backend/src/db/migrations/20240624221840_certificate-alt-names.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + const hasAltNamesColumn = await knex.schema.hasColumn(TableName.Certificate, "altNames"); + if (!hasAltNamesColumn) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames").defaultTo(""); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "altNames")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("altNames"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index b635420d51..833396fb16 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -19,7 +19,8 @@ export const CertificatesSchema = z.object({ notBefore: z.date(), notAfter: z.date(), revokedAt: z.date().nullable().optional(), - revocationReason: z.number().nullable().optional() + revocationReason: z.number().nullable().optional(), + altNames: z.string().default("").nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index df126b7f03..af8c2070a1 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -43,6 +43,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/models.ts b/backend/src/db/schemas/models.ts index d7a5e6de17..bdc574bcb8 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -78,6 +78,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 0000000000..d78f9e9a7e --- /dev/null +++ b/backend/src/db/schemas/oidc-configs.ts @@ -0,0 +1,34 @@ +// 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(), + discoveryURL: z.string().nullable().optional(), + 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(), + configurationType: z.string(), + clientIdIV: z.string(), + clientIdTag: z.string(), + encryptedClientSecret: z.string(), + clientSecretIV: z.string(), + clientSecretTag: z.string(), + allowedEmailDomains: 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/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 91035ab8e0..211626b7c5 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -17,8 +17,8 @@ export const ProjectsSchema = z.object({ updatedAt: z.date(), version: z.number().default(1), upgradeStatus: z.string().nullable().optional(), - kmsCertificateKeyId: z.string().uuid().nullable().optional(), - pitVersionLimit: z.number().default(10) + pitVersionLimit: z.number().default(10), + kmsCertificateKeyId: z.string().uuid().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 417d4e05e6..29e41c78ef 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -16,7 +16,9 @@ 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(), + defaultAuthOrgId: z.string().uuid().nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index d04bd86fd1..6bd9176d8c 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -8,6 +8,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"; @@ -64,7 +65,14 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/pki" } ); - await server.register(registerSamlRouter, { prefix: "/sso" }); + 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/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts new file mode 100644 index 0000000000..e675121e97 --- /dev/null +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -0,0 +1,355 @@ +/* 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 RedisStore from "connect-redis"; +import { Redis } from "ioredis"; +import { z } from "zod"; + +import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; +import { OIDCConfigurationType } from "@app/ee/services/oidc/oidc-config-types"; +import { getConfig } from "@app/lib/config/env"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +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" }); + + /* + - 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:", + ttl: 600 // 10 minutes + }); + + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, + cookie: { + secure: appCfg.HTTPS_ENABLED, + sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server + } + }); + + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + // redirect to IDP for login + server.route({ + url: "/login", + method: "GET", + config: { + rateLimit: authRateLimit + }, + schema: { + querystring: z.object({ + orgSlug: z.string().trim(), + callbackPort: z.string().trim().optional() + }) + }, + preValidation: [ + async (req, res) => { + const { orgSlug, callbackPort } = req.query; + + // ensure fresh session state per login attempt + await req.session.regenerate(); + + req.session.set("oidcOrgSlug", orgSlug); + + 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); + } + ], + 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) => { + await req.session.destroy(); + + if (req.passportUser.isUserCompleted) { + return res.redirect( + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + + // signup + return res.redirect( + `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + ); + } + }); + + server.route({ + url: "/login/error", + method: "GET", + handler: async (req, res) => { + await req.session.destroy(); + + return res.status(500).send({ + error: "Authentication error", + details: req.query + }); + } + }); + + server.route({ + url: "/config", + method: "GET", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + orgSlug: z.string().trim() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + configurationType: true, + discoveryURL: true, + isActive: true, + orgId: true, + allowedEmailDomains: 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; + } + }); + + server.route({ + method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + 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(", "); + }), + discoveryURL: z.string().trim(), + configurationType: z.nativeEnum(OIDCConfigurationType), + issuer: z.string().trim(), + authorizationEndpoint: z.string().trim(), + jwksUri: z.string().trim(), + tokenEndpoint: z.string().trim(), + userinfoEndpoint: z.string().trim(), + clientId: z.string().trim(), + clientSecret: z.string().trim(), + isActive: z.boolean() + }) + .partial() + .merge(z.object({ orgSlug: z.string() })), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + configurationType: true, + discoveryURL: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + allowedEmailDomains: 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({ + 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(", "); + }), + configurationType: z.nativeEnum(OIDCConfigurationType), + issuer: z.string().trim().optional().default(""), + discoveryURL: z.string().trim().optional().default(""), + authorizationEndpoint: z.string().trim().optional().default(""), + jwksUri: z.string().trim().optional().default(""), + tokenEndpoint: z.string().trim().optional().default(""), + userinfoEndpoint: z.string().trim().optional().default(""), + clientId: z.string().trim(), + clientSecret: z.string().trim(), + isActive: z.boolean(), + orgSlug: z.string().trim() + }) + .superRefine((data, ctx) => { + if (data.configurationType === OIDCConfigurationType.CUSTOM) { + if (!data.issuer) { + ctx.addIssue({ + path: ["issuer"], + message: "Issuer is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.authorizationEndpoint) { + ctx.addIssue({ + path: ["authorizationEndpoint"], + message: "Authorization endpoint is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.jwksUri) { + ctx.addIssue({ + path: ["jwksUri"], + message: "JWKS URI is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.tokenEndpoint) { + ctx.addIssue({ + path: ["tokenEndpoint"], + message: "Token endpoint is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.userinfoEndpoint) { + ctx.addIssue({ + path: ["userinfoEndpoint"], + message: "Userinfo endpoint is required", + code: z.ZodIssueCode.custom + }); + } + } else { + // eslint-disable-next-line no-lonely-if + if (!data.discoveryURL) { + ctx.addIssue({ + path: ["discoveryURL"], + message: "Discovery URL is required", + code: z.ZodIssueCode.custom + }); + } + } + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + configurationType: true, + discoveryURL: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true, + allowedEmailDomains: 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/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 8a0d2aef92..0c6ff51c8e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -65,25 +65,31 @@ export enum EventType { ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + REVOKE_IDENTITY_UNIVERSAL_AUTH = "revoke-identity-universal-auth", LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-auth", + REVOKE_IDENTITY_KUBERNETES_AUTH = "revoke-identity-kubernetes-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", LOGIN_IDENTITY_GCP_AUTH = "login-identity-gcp-auth", ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", + REVOKE_IDENTITY_GCP_AUTH = "revoke-identity-gcp-auth", GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", + REVOKE_IDENTITY_AWS_AUTH = "revoke-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", + REVOKE_IDENTITY_AZURE_AUTH = "revoke-identity-azure-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -434,6 +440,13 @@ interface GetIdentityUniversalAuthEvent { }; } +interface DeleteIdentityUniversalAuthEvent { + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityKubernetesAuthEvent { type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH; metadata: { @@ -457,6 +470,13 @@ interface AddIdentityKubernetesAuthEvent { }; } +interface DeleteIdentityKubernetesAuthEvent { + type: EventType.REVOKE_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + }; +} + interface UpdateIdentityKubernetesAuthEvent { type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH; metadata: { @@ -493,6 +513,14 @@ interface GetIdentityUniversalAuthClientSecretsEvent { }; } +interface GetIdentityUniversalAuthClientSecretByIdEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface RevokeIdentityUniversalAuthClientSecretEvent { type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -525,6 +553,13 @@ interface AddIdentityGcpAuthEvent { }; } +interface DeleteIdentityGcpAuthEvent { + type: EventType.REVOKE_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + }; +} + interface UpdateIdentityGcpAuthEvent { type: EventType.UPDATE_IDENTITY_GCP_AUTH; metadata: { @@ -570,6 +605,13 @@ interface AddIdentityAwsAuthEvent { }; } +interface DeleteIdentityAwsAuthEvent { + type: EventType.REVOKE_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + }; +} + interface UpdateIdentityAwsAuthEvent { type: EventType.UPDATE_IDENTITY_AWS_AUTH; metadata: { @@ -613,6 +655,13 @@ interface AddIdentityAzureAuthEvent { }; } +interface DeleteIdentityAzureAuthEvent { + type: EventType.REVOKE_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + }; +} + interface UpdateIdentityAzureAuthEvent { type: EventType.UPDATE_IDENTITY_AZURE_AUTH; metadata: { @@ -1003,24 +1052,30 @@ export type Event = | LoginIdentityUniversalAuthEvent | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent + | DeleteIdentityUniversalAuthEvent | GetIdentityUniversalAuthEvent | LoginIdentityKubernetesAuthEvent + | DeleteIdentityKubernetesAuthEvent | AddIdentityKubernetesAuthEvent | UpdateIdentityKubernetesAuthEvent | GetIdentityKubernetesAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent + | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent | LoginIdentityGcpAuthEvent | AddIdentityGcpAuthEvent + | DeleteIdentityGcpAuthEvent | UpdateIdentityGcpAuthEvent | GetIdentityGcpAuthEvent | LoginIdentityAwsAuthEvent | AddIdentityAwsAuthEvent | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent + | DeleteIdentityAwsAuthEvent | LoginIdentityAzureAuthEvent | AddIdentityAzureAuthEvent + | DeleteIdentityAzureAuthEvent | UpdateIdentityAzureAuthEvent | GetIdentityAzureAuthEvent | CreateEnvironmentEvent diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index f5b482c3a7..b63ecda109 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -23,6 +23,8 @@ import { } 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 { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -30,6 +32,7 @@ import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membe import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-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"; @@ -84,6 +87,8 @@ type TLdapConfigServiceFactoryDep = { userAliasDAL: Pick; permissionService: Pick; licenseService: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TLdapConfigServiceFactory = ReturnType; @@ -103,7 +108,9 @@ export const ldapConfigServiceFactory = ({ userDAL, userAliasDAL, permissionService, - licenseService + licenseService, + tokenService, + smtpService }: TLdapConfigServiceFactoryDep) => { const createLdapCfg = async ({ actor, @@ -509,7 +516,7 @@ export const ldapConfigServiceFactory = ({ if (!orgMembership) { await orgMembershipDAL.create( { - userId: userAlias.userId, + userId: newUser.id, inviteEmail: email, orgId, role: OrgMembershipRole.Member, @@ -642,6 +649,22 @@ export const ldapConfigServiceFactory = ({ } ); + 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 }; }; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 45325d414d..d69f7bf955 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -29,6 +29,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 565387296c..36b03ff803 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -46,6 +46,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-dal.ts b/backend/src/ee/services/oidc/oidc-config-dal.ts new file mode 100644 index 0000000000..470916c616 --- /dev/null +++ b/backend/src/ee/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/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts new file mode 100644 index 0000000000..b983492bc2 --- /dev/null +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -0,0 +1,637 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; +import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; + +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 "@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 { 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"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; + +import { TOidcConfigDALFactory } from "./oidc-config-dal"; +import { + OIDCConfigurationType, + TCreateOidcCfgDTO, + TGetOidcCfgDTO, + TOidcLoginDTO, + TUpdateOidcCfgDTO +} from "./oidc-config-types"; + +type TOidcConfigServiceFactoryDep = { + userDAL: Pick< + TUserDALFactory, + "create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" + >; + userAliasDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgMembershipDAL: Pick; + orgBotDAL: Pick; + licenseService: Pick; + tokenService: Pick; + smtpService: Pick; + permissionService: Pick; + oidcConfigDAL: Pick; +}; + +export type TOidcConfigServiceFactory = ReturnType; + +export const oidcConfigServiceFactory = ({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + permissionService, + tokenService, + orgBotDAL, + 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, + configurationType: oidcCfg.configurationType, + discoveryURL: oidcCfg.discoveryURL, + 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 serverCfg = await getServerCfg(); + 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 (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: serverCfg.trustOidcEmails, + username: serverCfg.trustOidcEmails ? email : 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 userEnc = await userDAL.findUserEncKeyByUserId(user.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, + hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), + authMethod: AuthMethod.OIDC, + authType: UserAliasType.OIDC, + isUserCompleted, + ...(callbackPort && { callbackPort }) + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME + } + ); + + 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 }; + }; + + const updateOidcCfg = async ({ + orgSlug, + allowedEmailDomains, + configurationType, + discoveryURL, + 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 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, + 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 = { + allowedEmailDomains, + configurationType, + discoveryURL, + 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, + allowedEmailDomains, + configurationType, + discoveryURL, + 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 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, + 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, + configurationType, + discoveryURL, + authorizationEndpoint, + allowedEmailDomains, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + orgId: org.id, + encryptedClientId, + clientIdIV, + clientIdTag, + encryptedClientSecret, + clientSecretIV, + clientSecretTag + }); + + return oidcCfg; + }; + + const getOrgAuthStrategy = async (orgSlug: string, callbackPort?: 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 + }); + + if (!oidcCfg || !oidcCfg.isActive) { + throw new BadRequestError({ + message: "Failed to authenticate with OIDC SSO" + }); + } + + let issuer: Issuer; + if (oidcCfg.configurationType === OIDCConfigurationType.DISCOVERY_URL) { + if (!oidcCfg.discoveryURL) { + throw new BadRequestError({ + message: "OIDC not configured correctly" + }); + } + issuer = await Issuer.discover(oidcCfg.discoveryURL); + } else { + if ( + !oidcCfg.issuer || + !oidcCfg.authorizationEndpoint || + !oidcCfg.jwksUri || + !oidcCfg.tokenEndpoint || + !oidcCfg.userinfoEndpoint + ) { + throw new BadRequestError({ + message: "OIDC not configured correctly" + }); + } + issuer = new OpenIdIssuer({ + issuer: oidcCfg.issuer, + authorization_endpoint: oidcCfg.authorizationEndpoint, + jwks_uri: oidcCfg.jwksUri, + token_endpoint: oidcCfg.tokenEndpoint, + userinfo_endpoint: oidcCfg.userinfoEndpoint + }); + } + + const client = new issuer.Client({ + client_id: oidcCfg.clientId, + client_secret: oidcCfg.clientSecret, + redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/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" + }); + } + + 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, + firstName: claims.given_name ?? "", + lastName: claims.family_name ?? "", + orgId: org.id, + callbackPort + }) + .then(({ isUserCompleted, providerAuthToken }) => { + cb(null, { isUserCompleted, providerAuthToken }); + }) + .catch((error) => { + cb(error); + }); + } + ); + + return strategy; + }; + + return { oidcLogin, getOrgAuthStrategy, getOidc, updateOidcCfg, createOidcCfg }; +}; diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts new file mode 100644 index 0000000000..6e36b796b3 --- /dev/null +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -0,0 +1,56 @@ +import { TGenericPermission } from "@app/lib/types"; + +export enum OIDCConfigurationType { + CUSTOM = "custom", + DISCOVERY_URL = "discoveryURL" +} + +export type TOidcLoginDTO = { + externalId: string; + email: string; + firstName: string; + lastName?: string; + orgId: string; + callbackPort?: string; +}; + +export type TGetOidcCfgDTO = + | ({ + type: "external"; + orgSlug: string; + } & TGenericPermission) + | { + type: "internal"; + orgSlug: string; + }; + +export type TCreateOidcCfgDTO = { + issuer?: string; + authorizationEndpoint?: string; + discoveryURL?: string; + configurationType: OIDCConfigurationType; + allowedEmailDomains?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +} & TGenericPermission; + +export type TUpdateOidcCfgDTO = Partial<{ + issuer: string; + authorizationEndpoint: string; + allowedEmailDomains: string; + discoveryURL: string; + jwksUri: string; + configurationType: OIDCConfigurationType; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +}> & + TGenericPermission; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 9fece040bc..6b7b3b2b23 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -116,7 +116,6 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d768066fa7..0e987f41b7 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -42,6 +42,13 @@ export const IDENTITIES = { }, DELETE: { identityId: "The ID of the identity to delete." + }, + GET_BY_ID: { + identityId: "The ID of the identity to get details.", + orgId: "The ID of the org of the identity" + }, + LIST: { + orgId: "The ID of the organization to list identities." } } as const; @@ -65,6 +72,9 @@ export const UNIVERSAL_AUTH = { RETRIEVE: { identityId: "The ID of the identity to retrieve." }, + REVOKE: { + identityId: "The ID of the identity to revoke." + }, UPDATE: { identityId: "The ID of the identity to update.", clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", @@ -83,6 +93,10 @@ export const UNIVERSAL_AUTH = { LIST_CLIENT_SECRETS: { identityId: "The ID of the identity to list client secrets for." }, + GET_CLIENT_SECRET: { + identityId: "The ID of the identity to get the client secret from.", + clientSecretId: "The ID of the client secret to get details." + }, REVOKE_CLIENT_SECRET: { identityId: "The ID of the identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." @@ -104,6 +118,27 @@ export const AWS_AUTH = { iamRequestBody: "The base64-encoded body of the signed request. Most likely, the base64-encoding of Action=GetCallerIdentity&Version=2011-06-15.", iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." + }, + REVOKE: { + identityId: "The ID of the identity to revoke." + } +} as const; + +export const AZURE_AUTH = { + REVOKE: { + identityId: "The ID of the identity to revoke." + } +} as const; + +export const GCP_AUTH = { + REVOKE: { + identityId: "The ID of the identity to revoke." + } +} as const; + +export const KUBERNETES_AUTH = { + REVOKE: { + identityId: "The ID of the identity to revoke." } } as const; @@ -347,6 +382,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.", @@ -804,6 +840,8 @@ export const CERTIFICATE_AUTHORITIES = { caId: "The ID of the CA to issue the certificate from", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", + altNames: + "A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.", ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index 8d7ea656a6..29f7163063 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -101,33 +101,51 @@ export const getUserPrivateKey = async ( password: string, user: Pick< TUserEncryptionKeys, - "protectedKeyTag" | "protectedKey" | "protectedKeyIV" | "encryptedPrivateKey" | "iv" | "salt" | "tag" + | "protectedKeyTag" + | "protectedKey" + | "protectedKeyIV" + | "encryptedPrivateKey" + | "iv" + | "salt" + | "tag" + | "encryptionVersion" > ) => { - const derivedKey = await argon2.hash(password, { - salt: Buffer.from(user.salt), - memoryCost: 65536, - timeCost: 3, - parallelism: 1, - hashLength: 32, - type: argon2.argon2id, - raw: true - }); - if (!derivedKey) throw new Error("Failed to derive key from password"); - const key = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: user.protectedKey as string, - iv: user.protectedKeyIV as string, - tag: user.protectedKeyTag as string, - key: derivedKey - }); + if (user.encryptionVersion === 1) { + return decryptSymmetric128BitHexKeyUTF8({ + ciphertext: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag, + key: password.slice(0, 32).padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0") + }); + } + if (user.encryptionVersion === 2 && user.protectedKey && user.protectedKeyIV && user.protectedKeyTag) { + const derivedKey = await argon2.hash(password, { + salt: Buffer.from(user.salt), + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, + type: argon2.argon2id, + raw: true + }); + if (!derivedKey) throw new Error("Failed to derive key from password"); + const key = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: user.protectedKey, + iv: user.protectedKeyIV, + tag: user.protectedKeyTag, + key: derivedKey + }); - const privateKey = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: user.encryptedPrivateKey, - iv: user.iv, - tag: user.tag, - key: Buffer.from(key, "hex") - }); - return privateKey; + const privateKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag, + key: Buffer.from(key, "hex") + }); + return privateKey; + } + throw new Error(`GetUserPrivateKey: Encryption version not found`); }; export const buildUserProjectKey = async (privateKey: string, publickey: string) => { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f20a622aba..a748396d4b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -32,6 +32,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"; @@ -250,6 +252,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); @@ -392,7 +395,9 @@ export const registerRoutes = async ( userDAL, userAliasDAL, permissionService, - licenseService + licenseService, + tokenService, + smtpService }); const telemetryService = telemetryServiceFactory({ @@ -904,6 +909,19 @@ export const registerRoutes = async ( secretSharingDAL }); + const oidcService = oidcConfigServiceFactory({ + orgDAL, + orgMembershipDAL, + userDAL, + userAliasDAL, + licenseService, + tokenService, + smtpService, + orgBotDAL, + permissionService, + oidcConfigDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -924,6 +942,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/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 97c3449d9c..24c7e2a6e4 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(), + defaultAuthOrgSlug: z.string().nullable(), isSecretScanningDisabled: z.boolean() }) }) @@ -51,11 +52,15 @@ 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(), + defaultAuthOrgId: z.string().optional().nullable() }), response: { 200: z.object({ - config: SuperAdminSchema + config: SuperAdminSchema.extend({ + defaultAuthOrgSlug: z.string().nullable() + }) }) } }, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 7573c0bd29..533f249f2b 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -9,7 +9,10 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; -import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -452,6 +455,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { .object({ friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") 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 f8c0451683..8a85323a60 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 @@ -266,4 +266,51 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) return { identityAwsAuth }; } }); + + server.route({ + method: "DELETE", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete AWS Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(AWS_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.revokeIdentityAwsAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId + } + } + }); + + return { identityAwsAuth }; + } + }); }; 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 d10cd131b2..6b4a7fb37d 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { IdentityAzureAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AZURE_AUTH } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -259,4 +260,51 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider return { identityAzureAuth }; } }); + + server.route({ + method: "DELETE", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(AZURE_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.revokeIdentityAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId + } + } + }); + + return { identityAzureAuth }; + } + }); }; 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 34940eb13e..0deeb95d30 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { IdentityGcpAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { GCP_AUTH } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -265,4 +266,51 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) return { identityGcpAuth }; } }); + + server.route({ + method: "DELETE", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(GCP_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.revokeIdentityGcpAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId + } + } + }); + + return { identityGcpAuth }; + } + }); }; 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 2273459166..4c54f1e7cb 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { IdentityKubernetesAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { KUBERNETES_AUTH } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -280,4 +281,54 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; } }); + + server.route({ + method: "DELETE", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(KUBERNETES_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.omit({ + caCert: true, + tokenReviewerJwt: true + }) + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.revokeIdentityKubernetesAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId + } + } + }); + + return { identityKubernetesAuth }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index e174cf974a..a425f963e1 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { IdentitiesSchema, OrgMembershipRole } from "@app/db/schemas"; +import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { IDENTITIES } from "@app/lib/api-docs"; -import { creationLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -170,4 +170,94 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { return { identity }; } }); + + server.route({ + method: "GET", + url: "/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get an identity by id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(IDENTITIES.GET_BY_ID.identityId) + }), + response: { + 200: z.object({ + identity: IdentityOrgMembershipsSchema.extend({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional(), + identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) + }) + }) + } + }, + handler: async (req) => { + const identity = await server.services.identity.getIdentityById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.identityId + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List identities", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + orgId: z.string().describe(IDENTITIES.LIST.orgId) + }), + response: { + 200: z.object({ + identities: IdentityOrgMembershipsSchema.extend({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional(), + identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) + }).array() + }) + } + }, + handler: async (req) => { + const identities = await server.services.identity.listOrgIdentities({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.query.orgId + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-ua.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts similarity index 81% rename from backend/src/server/routes/v1/identity-ua.ts rename to backend/src/server/routes/v1/identity-universal-auth-router.ts index 670f52416d..b5a63f0dbc 100644 --- a/backend/src/server/routes/v1/identity-ua.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -134,7 +134,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identityUniversalAuth = await server.services.identityUa.attachUa({ + const identityUniversalAuth = await server.services.identityUa.attachUniversalAuth({ actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, @@ -219,7 +219,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identityUniversalAuth = await server.services.identityUa.updateUa({ + const identityUniversalAuth = await server.services.identityUa.updateUniversalAuth({ actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, @@ -272,7 +272,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identityUniversalAuth = await server.services.identityUa.getIdentityUa({ + const identityUniversalAuth = await server.services.identityUa.getIdentityUniversalAuth({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -295,6 +295,53 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/universal-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete Universal Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(UNIVERSAL_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityUniversalAuth: IdentityUniversalAuthsSchema + }) + } + }, + handler: async (req) => { + const identityUniversalAuth = await server.services.identityUa.revokeIdentityUniversalAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityUniversalAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH, + metadata: { + identityId: identityUniversalAuth.identityId + } + } + }); + + return { identityUniversalAuth }; + } + }); + server.route({ method: "POST", url: "/universal-auth/identities/:identityId/client-secrets", @@ -325,14 +372,15 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { clientSecret, clientSecretData, orgId } = await server.services.identityUa.createUaClientSecret({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - ...req.body - }); + const { clientSecret, clientSecretData, orgId } = + await server.services.identityUa.createUniversalAuthClientSecret({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId, + ...req.body + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -374,24 +422,76 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { clientSecrets: clientSecretData, orgId } = await server.services.identityUa.getUaClientSecrets({ + const { clientSecrets: clientSecretData, orgId } = await server.services.identityUa.getUniversalAuthClientSecrets( + { + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + } + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId, + event: { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS, + metadata: { + identityId: req.params.identityId + } + } + }); + return { clientSecretData }; + } + }); + + server.route({ + method: "GET", + url: "/universal-auth/identities/:identityId/client-secrets/:clientSecretId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get Universal Auth Client Secret for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(UNIVERSAL_AUTH.GET_CLIENT_SECRET.identityId), + clientSecretId: z.string().describe(UNIVERSAL_AUTH.GET_CLIENT_SECRET.clientSecretId) + }), + response: { + 200: z.object({ + clientSecretData: sanitizedClientSecretSchema + }) + } + }, + handler: async (req) => { + const clientSecretData = await server.services.identityUa.getUniversalAuthClientSecretById({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - identityId: req.params.identityId + identityId: req.params.identityId, + clientSecretId: req.params.clientSecretId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId, + orgId: clientSecretData.orgId, event: { - type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS, + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, metadata: { - identityId: req.params.identityId + identityId: clientSecretData.identityId, + clientSecretId: clientSecretData.id } } }); + return { clientSecretData }; } }); @@ -421,7 +521,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const clientSecretData = await server.services.identityUa.revokeUaClientSecret({ + const clientSecretData = await server.services.identityUa.revokeUniversalAuthClientSecret({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index eee7dac65d..c2969b382e 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/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index e3f1528ade..b92138ec8f 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/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 29a2a176f8..8090b937ab 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -9,6 +9,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TTokenDALFactory } from "../auth-token/auth-token-dal"; @@ -201,7 +202,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; } } @@ -258,7 +262,13 @@ export const authLoginServiceFactory = ({ }); // from password decrypt the private key if (password) { - const privateKey = await getUserPrivateKey(password, userEnc); + const privateKey = await getUserPrivateKey(password, userEnc).catch((err) => { + logger.error( + err, + `loginExchangeClientProof: private key generation failed for [userId=${user.id}] and [email=${user.email}] ` + ); + return ""; + }); const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); const { iv, tag, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey); await userDAL.updateUserEncryptionByUserId(userEnc.userId, { @@ -344,9 +354,12 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId); + const selectedOrg = await orgDAL.findById(organizationId); if (!hasOrganizationMembership) { - throw new UnauthorizedError({ message: "User does not have access to the organization" }); + throw new UnauthorizedError({ + message: `User does not have access to the organization named ${selectedOrg?.name}` + }); } await tokenDAL.incrementTokenSessionVersion(user.id, decodedToken.tokenVersionId); @@ -571,7 +584,8 @@ export const authLoginServiceFactory = ({ const { authMethod, userName } = decodedProviderToken; if (!userName) throw new BadRequestError({ message: "Missing user name" }); const organizationId = - (isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId + (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod)) && + decodedProviderToken.orgId ? decodedProviderToken.orgId : undefined; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 8cf2c9d34f..7ac65c4686 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -165,7 +165,8 @@ export const authSignupServiceFactory = ({ protectedKeyTag, encryptedPrivateKey, iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag + tag: encryptedPrivateKeyTag, + encryptionVersion: 2 }); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { @@ -192,7 +193,10 @@ export const authSignupServiceFactory = ({ tx ); // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it - if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && organizationId) { + if ( + (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod as AuthMethod)) && + organizationId + ) { const [pendingOrgMembership] = await orgDAL.findMembership({ [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, @@ -325,7 +329,8 @@ export const authSignupServiceFactory = ({ protectedKeyTag, encryptedPrivateKey, iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag + tag: encryptedPrivateKeyTag, + encryptionVersion: 2 }); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 8e7b922539..9210093ab7 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/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 2345180a3f..7d87545e24 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import crypto, { KeyObject } from "crypto"; import ms from "ms"; +import { z } from "zod"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -38,6 +39,7 @@ import { TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; +import { hostnameRegex } from "./certificate-authority-validators"; type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityDAL: Pick< @@ -653,6 +655,7 @@ export const certificateAuthorityServiceFactory = ({ caId, friendlyName, commonName, + altNames, ttl, notBefore, notAfter, @@ -738,6 +741,45 @@ export const certificateAuthorityServiceFactory = ({ kmsService }); + const extensions: x509.Extension[] = [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ]; + + if (altNames) { + const altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (hostnameRegex.test(altName)) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -748,12 +790,7 @@ export const certificateAuthorityServiceFactory = ({ signingKey: caPrivateKey, publicKey: csrObj.publicKey, signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) - ] + extensions }); const skLeafObj = KeyObject.from(leafKeys.privateKey); @@ -771,6 +808,7 @@ export const certificateAuthorityServiceFactory = ({ status: CertStatus.ACTIVE, friendlyName: friendlyName || commonName, commonName, + altNames, serialNumber, notBefore: notBeforeDate, notAfter: notAfterDate diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 3ba7624c0f..8af8b679c6 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -75,6 +75,7 @@ export type TIssueCertFromCaDTO = { caId: string; friendlyName?: string; commonName: string; + altNames: string; ttl: string; notBefore?: string; notAfter?: string; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 77bf9ad2fc..a6d6c8c230 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -6,3 +6,29 @@ const isValidDate = (dateString: string) => { }; export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); + +export const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; +export const validateAltNamesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each alt name and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }) + .refine( + (data) => { + if (data === "") return true; + // Split and validate each alt name + return data.split(", ").every((name) => { + return hostnameRegex.test(name) || z.string().email().safeParse(name).success; + }); + }, + { + message: "Each alt name must be a valid hostname or email address" + } + ); 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 a589449093..9cb39aece0 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 @@ -7,11 +7,12 @@ import { IdentityAuthMethod } from "@app/db/schemas"; 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; @@ -24,12 +25,13 @@ import { TGetAwsAuthDTO, TGetCallerIdentityResponse, TLoginAwsAuthDTO, + TRevokeAwsAuthDTO, TUpdateAwsAuthDTO } from "./identity-aws-auth-types"; type TIdentityAwsAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; - identityAwsAuthDAL: Pick; + identityAwsAuthDAL: Pick; identityOrgMembershipDAL: Pick; identityDAL: Pick; licenseService: Pick; @@ -301,10 +303,54 @@ export const identityAwsAuthServiceFactory = ({ return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; }; + const revokeIdentityAwsAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + throw new BadRequestError({ + message: "The identity does not have aws auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke aws auth of identity with more privileged role" + }); + + const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { + const deletedAwsAuth = await identityAwsAuthDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedAwsAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityAwsAuth; + }; + return { login, attachAwsAuth, updateAwsAuth, - getAwsAuth + getAwsAuth, + revokeIdentityAwsAuth }; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts index e45783ae1d..c24186ee0e 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -52,3 +52,7 @@ export type TGetCallerIdentityResponse = { ResponseMetadata: { RequestId: string }; }; }; + +export type TRevokeAwsAuthDTO = { + identityId: string; +} & Omit; 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 fa439bdc00..0d52bf6a05 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 @@ -5,11 +5,12 @@ import { IdentityAuthMethod } from "@app/db/schemas"; 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; @@ -20,11 +21,15 @@ import { TAttachAzureAuthDTO, TGetAzureAuthDTO, TLoginAzureAuthDTO, + TRevokeAzureAuthDTO, TUpdateAzureAuthDTO } from "./identity-azure-auth-types"; type TIdentityAzureAuthServiceFactoryDep = { - identityAzureAuthDAL: Pick; + identityAzureAuthDAL: Pick< + TIdentityAzureAuthDALFactory, + "findOne" | "transaction" | "create" | "updateById" | "delete" + >; identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; identityDAL: Pick; @@ -277,10 +282,54 @@ export const identityAzureAuthServiceFactory = ({ return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; }; + const revokeIdentityAzureAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "The identity does not have azure auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke azure auth of identity with more privileged role" + }); + + const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { + const deletedAzureAuth = await identityAzureAuthDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedAzureAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityAzureAuth; + }; + return { login, attachAzureAuth, updateAzureAuth, - getAzureAuth + getAzureAuth, + revokeIdentityAzureAuth }; }; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts index 65459003c0..ec03451dbc 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -118,3 +118,7 @@ export type TDecodedAzureAuthJwt = { [key: string]: string; }; }; + +export type TRevokeAzureAuthDTO = { + identityId: string; +} & Omit; 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 5f829cb335..edac7c132f 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 @@ -5,11 +5,12 @@ import { IdentityAuthMethod } from "@app/db/schemas"; 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; @@ -21,11 +22,12 @@ import { TGcpIdentityDetails, TGetGcpAuthDTO, TLoginGcpAuthDTO, + TRevokeGcpAuthDTO, TUpdateGcpAuthDTO } from "./identity-gcp-auth-types"; type TIdentityGcpAuthServiceFactoryDep = { - identityGcpAuthDAL: Pick; + identityGcpAuthDAL: Pick; identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; identityDAL: Pick; @@ -315,10 +317,54 @@ export const identityGcpAuthServiceFactory = ({ return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; }; + const revokeIdentityGcpAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "The identity does not have gcp auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke gcp auth of identity with more privileged role" + }); + + const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { + const deletedGcpAuth = await identityGcpAuthDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedGcpAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityGcpAuth; + }; + return { login, attachGcpAuth, updateGcpAuth, - getGcpAuth + getGcpAuth, + revokeIdentityGcpAuth }; }; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts index 60ab36b58a..45e64b24b6 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -76,3 +76,7 @@ export type TDecodedGcpIamAuthJwt = { [key: string]: string; }; }; + +export type TRevokeGcpAuthDTO = { + identityId: string; +} & Omit; 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 f1e1c6be0a..820777b465 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 @@ -7,6 +7,7 @@ import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric, @@ -16,11 +17,11 @@ import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; -import { AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; @@ -32,13 +33,14 @@ import { TCreateTokenReviewResponse, TGetKubernetesAuthDTO, TLoginKubernetesAuthDTO, + TRevokeKubernetesAuthDTO, TUpdateKubernetesAuthDTO } from "./identity-kubernetes-auth-types"; type TIdentityKubernetesAuthServiceFactoryDep = { identityKubernetesAuthDAL: Pick< TIdentityKubernetesAuthDALFactory, - "create" | "findOne" | "transaction" | "updateById" + "create" | "findOne" | "transaction" | "updateById" | "delete" >; identityAccessTokenDAL: Pick; identityOrgMembershipDAL: Pick; @@ -533,10 +535,54 @@ export const identityKubernetesAuthServiceFactory = ({ return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; }; + const revokeIdentityKubernetesAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.KUBERNETES_AUTH) + throw new BadRequestError({ + message: "The identity does not have kubenetes auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke kubenetes auth of identity with more privileged role" + }); + + const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { + const deletedKubernetesAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedKubernetesAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityKubernetesAuth; + }; + return { login, attachKubernetesAuth, updateKubernetesAuth, - getKubernetesAuth + getKubernetesAuth, + revokeIdentityKubernetesAuth }; }; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index dbb42dce89..f1cde2be9a 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -59,3 +59,7 @@ export type TCreateTokenReviewResponse = { }; status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; }; + +export type TRevokeKubernetesAuthDTO = { + identityId: string; +} & Omit; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 5e940871b5..00dfa5d600 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -25,7 +25,9 @@ import { TCreateUaClientSecretDTO, TGetUaClientSecretsDTO, TGetUaDTO, + TGetUniversalAuthClientSecretByIdDTO, TRevokeUaClientSecretDTO, + TRevokeUaDTO, TUpdateUaDTO } from "./identity-ua-types"; @@ -136,7 +138,7 @@ export const identityUaServiceFactory = ({ return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg }; }; - const attachUa = async ({ + const attachUniversalAuth = async ({ accessTokenMaxTTL, identityId, accessTokenNumUsesLimit, @@ -227,7 +229,7 @@ export const identityUaServiceFactory = ({ return { ...identityUa, orgId: identityMembershipOrg.orgId }; }; - const updateUa = async ({ + const updateUniversalAuth = async ({ accessTokenMaxTTL, identityId, accessTokenNumUsesLimit, @@ -312,7 +314,7 @@ export const identityUaServiceFactory = ({ return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; - const getIdentityUa = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetUaDTO) => { + const getIdentityUniversalAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) @@ -333,7 +335,50 @@ export const identityUaServiceFactory = ({ return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId }; }; - const createUaClientSecret = async ({ + const revokeIdentityUniversalAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeUaDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke universal auth of identity with more privileged role" + }); + + const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { + const deletedUniversalAuth = await identityUaDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityUniversalAuth; + }; + + const createUniversalAuthClientSecret = async ({ actor, actorId, actorOrgId, @@ -396,7 +441,7 @@ export const identityUaServiceFactory = ({ }; }; - const getUaClientSecrets = async ({ + const getUniversalAuthClientSecrets = async ({ actor, actorId, actorOrgId, @@ -442,7 +487,47 @@ export const identityUaServiceFactory = ({ return { clientSecrets, orgId: identityMembershipOrg.orgId }; }; - const revokeUaClientSecret = async ({ + const getUniversalAuthClientSecretById = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + clientSecretId + }: TGetUniversalAuthClientSecretByIdDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to read identity client secret of project with more privileged role" + }); + + const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId); + return { ...clientSecret, identityId, orgId: identityMembershipOrg.orgId }; + }; + + const revokeUniversalAuthClientSecret = async ({ identityId, actorId, actor, @@ -475,7 +560,7 @@ export const identityUaServiceFactory = ({ const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); if (!hasPriviledge) throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" + message: "Failed to revoke identity client secret with more privileged role" }); const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { @@ -486,11 +571,13 @@ export const identityUaServiceFactory = ({ return { login, - attachUa, - updateUa, - getIdentityUa, - createUaClientSecret, - getUaClientSecrets, - revokeUaClientSecret + attachUniversalAuth, + updateUniversalAuth, + getIdentityUniversalAuth, + revokeIdentityUniversalAuth, + createUniversalAuthClientSecret, + getUniversalAuthClientSecrets, + revokeUniversalAuthClientSecret, + getUniversalAuthClientSecretById }; }; diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index 2cc4762a81..2045c21432 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -22,6 +22,10 @@ export type TGetUaDTO = { identityId: string; } & Omit; +export type TRevokeUaDTO = { + identityId: string; +} & Omit; + export type TCreateUaClientSecretDTO = { identityId: string; description: string; @@ -37,3 +41,8 @@ export type TRevokeUaClientSecretDTO = { identityId: string; clientSecretId: string; } & Omit; + +export type TGetUniversalAuthClientSecretByIdDTO = { + identityId: string; + clientSecretId: string; +} & Omit; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 95d742f334..0d31997254 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -27,10 +27,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - const findByOrgId = async (orgId: string, tx?: Knex) => { + const find = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.IdentityOrgMembership) - .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .where(filter) .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) .select(selectAllTableCols(TableName.IdentityOrgMembership)) @@ -79,5 +79,5 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - return { ...identityOrgOrm, findOne, findByOrgId }; + return { ...identityOrgOrm, find, findOne }; }; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 4659919fa8..f6eccfb99e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas"; 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"; @@ -11,7 +11,7 @@ import { TOrgPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; import { TIdentityDALFactory } from "./identity-dal"; import { TIdentityOrgDALFactory } from "./identity-org-dal"; -import { TCreateIdentityDTO, TDeleteIdentityDTO, TUpdateIdentityDTO } from "./identity-types"; +import { TCreateIdentityDTO, TDeleteIdentityDTO, TGetIdentityByIdDTO, TUpdateIdentityDTO } from "./identity-types"; type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; @@ -138,6 +138,24 @@ export const identityServiceFactory = ({ return { ...identity, orgId: identityOrgMembership.orgId }; }; + const getIdentityById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TGetIdentityByIdDTO) => { + const doc = await identityOrgMembershipDAL.find({ + [`${TableName.IdentityOrgMembership}.identityId` as "identityId"]: id + }); + const identity = doc[0]; + if (!identity) throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identity.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + return identity; + }; + const deleteIdentity = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteIdentityDTO) => { const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); @@ -172,7 +190,9 @@ export const identityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - const identityMemberships = await identityOrgMembershipDAL.findByOrgId(orgId); + const identityMemberships = await identityOrgMembershipDAL.find({ + [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId + }); return identityMemberships; }; @@ -180,6 +200,7 @@ export const identityServiceFactory = ({ createIdentity, updateIdentity, deleteIdentity, - listOrgIdentities + listOrgIdentities, + getIdentityById }; }; diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 10b943667c..5125413e85 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -16,6 +16,10 @@ export type TDeleteIdentityDTO = { id: string; } & Omit; +export type TGetIdentityByIdDTO = { + id: string; +} & Omit; + export interface TIdentityTrustedIp { ipAddress: string; type: IPType; 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 8fa60cde7c..825dea3a71 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/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a5a469a8f8..cbfccba91c 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 1aac324c56..10df2f2583 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; diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index 64133ed7e1..7e707e6fa9 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -1,7 +1,57 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; 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 superAdminOrm = ormify(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( + db.ref("*").withSchema(TableName.SuperAdmin) as unknown as keyof TSuperAdmin, + db.ref("slug").withSchema(TableName.Organization).as("defaultAuthOrgSlug") + ) + .first(); + + if (!config) { + return null; + } + + return { + ...config, + defaultAuthOrgSlug: config?.defaultAuthOrgSlug || null + } as TSuperAdmin & { defaultAuthOrgSlug: string | null }; + }; + + const updateById = async (id: string, data: TSuperAdminUpdate, tx?: Knex) => { + const updatedConfig = await (superAdminOrm || tx).transaction(async (trx: Knex) => { + await superAdminOrm.updateById(id, data, 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; + }; + + return { + ...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 f1d931b204..41b97efa42 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -25,7 +25,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 @@ -42,16 +42,20 @@ 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 + + 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; } - const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin; + const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin & { defaultAuthOrgSlug: string | null }; return { ...keyStoreServerCfg, // this is to allow admin router to work @@ -65,14 +69,21 @@ 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; }; 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; }; @@ -98,6 +109,7 @@ export const superAdminServiceFactory = ({ if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exist" }); const privateKey = await getUserPrivateKey(password, { + encryptionVersion: 2, salt, protectedKey, protectedKeyIV, diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index 09204644f4..7207e8acf5 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/docs/api-reference/endpoints/identities/get-by-id.mdx b/docs/api-reference/endpoints/identities/get-by-id.mdx new file mode 100644 index 0000000000..f721d3556a --- /dev/null +++ b/docs/api-reference/endpoints/identities/get-by-id.mdx @@ -0,0 +1,5 @@ +--- +title: "Get By ID" +openapi: "GET /api/v1/identities/{identityId}" +--- + diff --git a/docs/api-reference/endpoints/identities/list.mdx b/docs/api-reference/endpoints/identities/list.mdx new file mode 100644 index 0000000000..d8972e3a91 --- /dev/null +++ b/docs/api-reference/endpoints/identities/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/identities" +--- diff --git a/docs/api-reference/endpoints/universal-auth/get-client-secret-by-id.mdx b/docs/api-reference/endpoints/universal-auth/get-client-secret-by-id.mdx new file mode 100644 index 0000000000..477ee875cd --- /dev/null +++ b/docs/api-reference/endpoints/universal-auth/get-client-secret-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Client Secret By ID" +openapi: "GET /api/v1/auth/universal-auth/identities/{identityId}/client-secrets/{clientSecretId}" +--- diff --git a/docs/api-reference/endpoints/universal-auth/revoke.mdx b/docs/api-reference/endpoints/universal-auth/revoke.mdx new file mode 100644 index 0000000000..e2a19e93c3 --- /dev/null +++ b/docs/api-reference/endpoints/universal-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/universal-auth/identities/{identityId}" +--- diff --git a/docs/documentation/getting-started/platform.mdx b/docs/documentation/getting-started/platform.mdx index 1a1164a40a..7ce96a0ed8 100644 --- a/docs/documentation/getting-started/platform.mdx +++ b/docs/documentation/getting-started/platform.mdx @@ -12,14 +12,14 @@ From there, you can invite external members to the organization and start creati ### Projects The **Projects** page shows you all the projects that you have access to within your organization. -Here, you can also create a new project. +Here, you can also create a new project. ![organization overview](../../images/organization-overview.png) ### Members -The **Members** page lets you add or remove external members to your organization. -Note that you can configure your organization in Infisical to have members authenticate with the platform via protocols like SAML 2.0. +The **Members** page lets you add or remove external members to your organization. +Note that you can configure your organization in Infisical to have members authenticate with the platform via protocols like SAML 2.0 and OpenID Connect. ![organization members](../../images/organization/platform/organization-members.png) @@ -35,13 +35,14 @@ The **Secrets Overview** screen provides a bird's-eye view of all the secrets in ![dashboard secrets overview](../../images/dashboard-secrets-overview.png) In the above image, you can already see that: + - `STRIPE_API_KEY` is missing from the **Staging** environment. - `JWT_SECRET` is missing from the **Production** environment. - `BAR` is `EMPTY` in the **Production** environment. ### Dashboard -The secrets dashboard lets you manage secrets for a specific environment in a project. +The secrets dashboard lets you manage secrets for a specific environment in a project. Here, developers can override secrets, version secrets, rollback projects to any point in time and much more. ![dashboard](../../images/dashboard.png) @@ -61,4 +62,4 @@ which you can assign to members. That's it for the platform quickstart! — We encourage you to continue exploring the documentation to gain a deeper understanding of the extensive features and functionalities that Infisical has to offer. -Next, head back to [Getting Started > Introduction](/documentation/getting-started/overview) to explore ways to fetch secrets from Infisical to your apps and infrastructure. \ No newline at end of file +Next, head back to [Getting Started > Introduction](/documentation/getting-started/overview) to explore ways to fetch secrets from Infisical to your apps and infrastructure. diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 9cc6c4c3d5..2db1834c47 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: diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx index 4d6c75e153..4502158d07 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 diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index d45bb6d4f8..5e75f1a3b2 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -21,20 +21,19 @@ The **Settings** page lets you manage information about your organization includ ![organization settings general](../../images/platform/organization/organization-settings-general.png) - -- Security and Authentication: A set of setting to enforce or manage [SAML](/documentation/platform/sso/overview), [SCIM](/documentation/platform/scim/overview), [LDAP](/documentation/platform/ldap/overview), and other authentication configurations. +- Security and Authentication: A set of setting to enforce or manage [SAML](/documentation/platform/sso/overview), [OIDC](/documentation/platform/sso/overview), [SCIM](/documentation/platform/scim/overview), [LDAP](/documentation/platform/ldap/overview), and other authentication configurations. ![organization settings auth](../../images/platform/organization/organization-settings-auth.png) ## Access Control -The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. +The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. You can add or remove additional members as well as modify their permissions. ![organization members](../../images/platform/organization/organization-members.png) ![organization identities](../../images/platform/organization/organization-machine-identities.png) -In the **Organization Roles** tab, you can edit current or create new custom roles for members within the organization. +In the **Organization Roles** tab, you can edit current or create new custom roles for members within the organization. Note that Role-Based Access Management (RBAC) is partly a paid feature. @@ -42,13 +41,14 @@ In the **Organization Roles** tab, you can edit current or create new custom rol Infisical provides immutable roles like `admin`, `member`, etc. at the organization and project level for free. - If you're using Infisical Cloud, the ability to create custom roles is available under the **Pro Tier**. - If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. +If you're using Infisical Cloud, the ability to create custom roles is available under the **Pro Tier**. +If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. + ![organization roles](../../images/platform/organization/organization-members-roles.png) -As you can see next, Infisical supports granular permissions that you can tailor to each role. +As you can see next, Infisical supports granular permissions that you can tailor to each role. If you need certain members to only be able to access billing details, for example, then you can assign them that permission only. @@ -66,4 +66,4 @@ This includes the following items: - Receipts: The receipts of monthly/annual invoices. - Billing: The billing details of your organization including payment methods on file, tax IDs (if applicable), etc. -![organization usage and billing](../../images/platform/organization/organization-usage-billing.png) \ No newline at end of file +![organization usage and billing](../../images/platform/organization/organization-usage-billing.png) diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index fe55466811..7dc3bca880 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -56,9 +56,9 @@ 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 of the certificate. + - 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 like `app1.acme.com, app2.acme.com`. - TTL: The lifetime of the certificate in seconds. - - Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx new file mode 100644 index 0000000000..2b459d5ca3 --- /dev/null +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -0,0 +1,66 @@ +--- +title: "Auth0 OIDC" +description: "Learn how to configure Auth0 OIDC for Infisical SSO." +--- + + + Auth0 OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. + + + + + 1.1. From the Application's Page, navigate to the settings tab of the Auth0 application you want to integrate with Infisical. + ![OIDC auth0 list of applications](../../../images/sso/auth0-oidc/application-settings.png) + + 1.2. In the Application URIs section, set the **Application Login URI** and **Allowed Web Origins** fields to `https://app.infisical.com` and the **Allowed Callback URL** field to `https://app.infisical.com/api/v1/sso/oidc/callback`. + ![OIDC auth0 create application uris](../../../images/sso/auth0-oidc/application-uris.png) + ![OIDC auth0 create application origin](../../../images/sso/auth0-oidc/application-origin.png) + + If you’re self-hosting Infisical, then you will want to replace https://app.infisical.com with your own domain. + + + Once done, click **Save Changes**. + + 1.3. Proceed to the Connections Tab and enable desired connections. + ![OIDC auth0 application connections](../../../images/sso/auth0-oidc/application-connections.png) + + + + 2.1. From the application settings page, retrieve the **Client ID** and **Client Secret** + ![OIDC auth0 application credential](../../../images/sso/auth0-oidc/application-credential.png) + + 2.2. In the advanced settings (bottom-most section), retrieve the **OpenID Configuration URL** from the Endpoints tab. + ![OIDC auth0 application oidc url](../../../images/sso/auth0-oidc/application-urls.png) + + Keep these values handy as we will need them in the next steps. + + + + 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click **Manage**. + ![OIDC auth0 manage org Infisical](../../../images/sso/auth0-oidc/org-oidc-overview.png) + + 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. + ![OIDC auth0 paste values into Infisical](../../../images/sso/auth0-oidc/org-update-oidc.png) + + Once you've done that, press **Update** to complete the required configuration. + + + + Enabling OIDC allows members in your organization to log into Infisical via Auth0. + + ![OIDC auth0 enable OIDC](../../../images/sso/auth0-oidc/enable-oidc.png) + + + + + + If you're configuring OIDC SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/general-oidc.mdx b/docs/documentation/platform/sso/general-oidc.mdx new file mode 100644 index 0000000000..ae559cc597 --- /dev/null +++ b/docs/documentation/platform/sso/general-oidc.mdx @@ -0,0 +1,69 @@ +--- +title: "General OIDC" +description: "Learn how to configure OIDC for Infisical SSO with any OIDC-compliant identity provider" +--- + + + OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. + + +You can configure your organization in Infisical to have members authenticate with the platform through identity providers via [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html). + +Prerequisites: + +- The identity provider (Okta, Google, Azure AD, etc.) should support OIDC. +- Users in the IdP should have a configured `email` and `given_name`. + + + + 1.1. Register your application with the IdP to obtain a **Client ID** and **Client Secret**. These credentials are used by Infisical to authenticate with your IdP. + + 1.2. Configure **Redirect URL** to be `https://app.infisical.com/api/v1/sso/oidc/callback`. If you're self-hosting Infisical, replace the domain with your own. + + 1.3. Configure the scopes needed by Infisical (email, profile, openid) and ensure that they are mapped to the ID token claims. + + 1.4. Access the IdP’s OIDC discovery document (usually located at `https:///.well-known/openid-configuration`). This document contains important endpoints such as authorization, token, userinfo, and keys. + + + 2.1. Back in Infisical, in the Organization settings > Security > OIDC, click Manage + ![OIDC general manage org Infisical](../../../images/sso/general-oidc/org-oidc-manage.png) + + 2.2. You can configure OIDC either through the Discovery URL (Recommended) or by inputting custom endpoints. + + To configure OIDC via Discovery URL, set the **Configuration Type** field to **Discovery URL** and fill out the **Discovery Document URL** field. + + + Note that the Discovery Document URL typically takes the form: `https:///.well-known/openid-configuration`. + + + ![OIDC general discovery config](../../../images/sso/general-oidc/discovery-oidc-form.png) + + To configure OIDC via the custom endpoints, set the **Configuration Type** field to **Custom** and input the required endpoint fields. + ![OIDC general custom config](../../../images/sso/general-oidc/custom-oidc-form.png) + + 2.3. Optionally, you can define a whitelist of allowed email domains. + + Finally, fill out the **Client ID** and **Client Secret** fields and press **Update** to complete the required configuration. + + + + + Enabling OIDC SSO allows members in your organization to log into Infisical via the configured Identity Provider + + ![OIDC general enable OIDC](../../../images/sso/general-oidc/org-oidc-enable.png) + + + + + + + If you're configuring OIDC SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/keycloak-oidc.mdx b/docs/documentation/platform/sso/keycloak-oidc.mdx new file mode 100644 index 0000000000..d3818f61ce --- /dev/null +++ b/docs/documentation/platform/sso/keycloak-oidc.mdx @@ -0,0 +1,92 @@ +--- +title: "Keycloak OIDC" +description: "Learn how to configure Keycloak OIDC for Infisical SSO." +--- + + + Keycloak OIDC SSO is a paid feature. If you're using Infisical Cloud, then it + is available under the **Pro Tier**. If you're self-hosting Infisical, then + you should contact sales@infisical.com to purchase an enterprise license to + use it. + + + + + 1.1. In your realm, navigate to the **Clients** tab and click **Create client** to create a new client application. + + ![OIDC keycloak list of clients](../../../images/sso/keycloak-oidc/clients-list.png) + + + You don’t typically need to make a realm dedicated to Infisical. We recommend adding Infisical as a client to your primary realm. + + + 1.2. In the General Settings step, set **Client type** to **OpenID Connect**, the **Client ID** field to an appropriate identifier, and the **Name** field to a friendly name like **Infisical**. + + ![OIDC keycloak create client general settings](../../../images/sso/keycloak-oidc/create-client-general-settings.png) + + 1.3. Next, in the Capability Config step, ensure that **Client Authentication** is set to On and that **Standard flow** is enabled in the Authentication flow section. + + ![OIDC keycloak create client capability config settings](../../../images/sso/keycloak-oidc/create-client-capability.png) + + 1.4. In the Login Settings step, set the following values: + - Root URL: `https://app.infisical.com`. + - Home URL: `https://app.infisical.com`. + - Valid Redirect URIs: `https://app.infisical.com/api/v1/sso/oidc/callback`. + - Web origins: `https://app.infisical.com`. + + ![OIDC keycloak create client login settings](../../../images/sso/keycloak-oidc/create-client-login-settings.png) + + If you’re self-hosting Infisical, then you will want to replace https://app.infisical.com (base URL) with your own domain. + + + 1.5. Next, navigate to the **Client scopes** tab and select the client's dedicated scope. + + ![OIDC keycloak client scopes list](../../../images/sso/keycloak-oidc/client-scope-list.png) + + 1.6. Next, click **Add predefined mapper**. + + ![OIDC keycloak client mappers empty](../../../images/sso/keycloak-oidc/client-scope-mapper-menu.png) + + 1.7. Select the **email**, **given name**, **family name** attributes and click **Add**. + + ![OIDC keycloak client mappers predefined 1](../../../images/sso/keycloak-oidc/scope-predefined-mapper-1.png) + ![OIDC keycloak client mappers predefined 2](../../../images/sso/keycloak-oidc/scope-predefined-mapper-2.png) + + Once you've completed the above steps, the list of mappers should look like the following: + ![OIDC keycloak client mappers completed](../../../images/sso/keycloak-oidc/client-scope-complete-overview.png) + + + + 2.1. Back in Keycloak, navigate to Configure > Realm settings > General tab > Endpoints > OpenID Endpoint Configuration and copy the opened URL. This is what is to referred to as the Discovery Document URL and it takes the form: `https://keycloak-mysite.com/realms/myrealm/.well-known/openid-configuration`. + ![OIDC keycloak realm OIDC metadata](../../../images/sso/keycloak-oidc/realm-setting-oidc-config.png) + + 2.2. From the Clients page, navigate to the Credential tab and copy the **Client Secret** to be used in the next steps. + ![OIDC keycloak realm OIDC secret](../../../images/sso/keycloak-oidc/client-secret.png) + + + + 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click Manage + ![OIDC keycloak manage org Infisical](../../../images/sso/keycloak-oidc/manage-org-oidc.png) + + 3.2. For configuration type, select Discovery URL. Then, set the appropriate values for **Discovery Document URL**, **Client ID**, and **Client Secret**. + ![OIDC keycloak paste values into Infisical](../../../images/sso/keycloak-oidc/create-oidc.png) + + Once you've done that, press **Update** to complete the required configuration. + + + + Enabling OIDC SSO allows members in your organization to log into Infisical via Keycloak. + + ![OIDC keycloak enable OIDC](../../../images/sso/keycloak-oidc/enable-oidc.png) + + + + + + If you're configuring OIDC SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 9ab0acc3ae..4bb45cf48b 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -7,16 +7,14 @@ description: "Learn how to log in to Infisical via SSO protocols." Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. Infisical also offers SAML SSO authentication - but as paid features that can be unlocked on Infisical Cloud's **Pro** tier or - via enterprise license on self-hosted instances of Infisical. On this front, - we support industry-leading providers including Okta, Azure AD, and JumpCloud; - with any questions, please reach out to team@infisical.com. + and OpenID Connect (OIDC) but as paid features that can be unlocked on + Infisical Cloud's **Pro** tier or via enterprise license on self-hosted + instances of Infisical. On this front, we support industry-leading providers + including Okta, Azure AD, and JumpCloud; with any questions, please reach out + to team@infisical.com. -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). +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) or [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html). ## Identity providers @@ -30,6 +28,9 @@ Infisical supports these and many other identity providers: - [JumpCloud SAML](/documentation/platform/sso/jumpcloud) - [Keycloak SAML](/documentation/platform/sso/keycloak-saml) - [Google SAML](/documentation/platform/sso/google-saml) +- [Keycloak OIDC](/documentation/platform/sso/keycloak-oidc) +- [Auth0 OIDC](/documentation/platform/sso/auth0-oidc) +- [General OIDC](/documentation/platform/sso/general-oidc) If your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. 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 0000000000..8c683a1001 Binary files /dev/null and b/docs/images/integrations/bitbucket/integrations-bitbucket-env.png differ diff --git a/docs/images/sso/auth0-oidc/application-connections.png b/docs/images/sso/auth0-oidc/application-connections.png new file mode 100644 index 0000000000..8307f3b248 Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-connections.png differ diff --git a/docs/images/sso/auth0-oidc/application-credential.png b/docs/images/sso/auth0-oidc/application-credential.png new file mode 100644 index 0000000000..157d824156 Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-credential.png differ diff --git a/docs/images/sso/auth0-oidc/application-origin.png b/docs/images/sso/auth0-oidc/application-origin.png new file mode 100644 index 0000000000..82394c6fd5 Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-origin.png differ diff --git a/docs/images/sso/auth0-oidc/application-settings.png b/docs/images/sso/auth0-oidc/application-settings.png new file mode 100644 index 0000000000..5f708ba2af Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-settings.png differ diff --git a/docs/images/sso/auth0-oidc/application-uris.png b/docs/images/sso/auth0-oidc/application-uris.png new file mode 100644 index 0000000000..dadc6ce00e Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-uris.png differ diff --git a/docs/images/sso/auth0-oidc/application-urls.png b/docs/images/sso/auth0-oidc/application-urls.png new file mode 100644 index 0000000000..b467d54c3b Binary files /dev/null and b/docs/images/sso/auth0-oidc/application-urls.png differ diff --git a/docs/images/sso/auth0-oidc/enable-oidc.png b/docs/images/sso/auth0-oidc/enable-oidc.png new file mode 100644 index 0000000000..32682c302e Binary files /dev/null and b/docs/images/sso/auth0-oidc/enable-oidc.png differ diff --git a/docs/images/sso/auth0-oidc/org-oidc-overview.png b/docs/images/sso/auth0-oidc/org-oidc-overview.png new file mode 100644 index 0000000000..ce5fb0d600 Binary files /dev/null and b/docs/images/sso/auth0-oidc/org-oidc-overview.png differ diff --git a/docs/images/sso/auth0-oidc/org-update-oidc.png b/docs/images/sso/auth0-oidc/org-update-oidc.png new file mode 100644 index 0000000000..0b9e96b5b8 Binary files /dev/null and b/docs/images/sso/auth0-oidc/org-update-oidc.png differ diff --git a/docs/images/sso/general-oidc/custom-oidc-form.png b/docs/images/sso/general-oidc/custom-oidc-form.png new file mode 100644 index 0000000000..2aee026801 Binary files /dev/null and b/docs/images/sso/general-oidc/custom-oidc-form.png differ diff --git a/docs/images/sso/general-oidc/discovery-oidc-form.png b/docs/images/sso/general-oidc/discovery-oidc-form.png new file mode 100644 index 0000000000..ae99b35b25 Binary files /dev/null and b/docs/images/sso/general-oidc/discovery-oidc-form.png differ diff --git a/docs/images/sso/general-oidc/org-oidc-enable.png b/docs/images/sso/general-oidc/org-oidc-enable.png new file mode 100644 index 0000000000..69f0062bc6 Binary files /dev/null and b/docs/images/sso/general-oidc/org-oidc-enable.png differ diff --git a/docs/images/sso/general-oidc/org-oidc-manage.png b/docs/images/sso/general-oidc/org-oidc-manage.png new file mode 100644 index 0000000000..69a60f0f5b Binary files /dev/null and b/docs/images/sso/general-oidc/org-oidc-manage.png differ diff --git a/docs/images/sso/keycloak-oidc/client-scope-complete-overview.png b/docs/images/sso/keycloak-oidc/client-scope-complete-overview.png new file mode 100644 index 0000000000..a0965db0b1 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/client-scope-complete-overview.png differ diff --git a/docs/images/sso/keycloak-oidc/client-scope-list.png b/docs/images/sso/keycloak-oidc/client-scope-list.png new file mode 100644 index 0000000000..c35a7691f2 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/client-scope-list.png differ diff --git a/docs/images/sso/keycloak-oidc/client-scope-mapper-menu.png b/docs/images/sso/keycloak-oidc/client-scope-mapper-menu.png new file mode 100644 index 0000000000..141bc5dd0b Binary files /dev/null and b/docs/images/sso/keycloak-oidc/client-scope-mapper-menu.png differ diff --git a/docs/images/sso/keycloak-oidc/client-secret.png b/docs/images/sso/keycloak-oidc/client-secret.png new file mode 100644 index 0000000000..c91ddb164b Binary files /dev/null and b/docs/images/sso/keycloak-oidc/client-secret.png differ diff --git a/docs/images/sso/keycloak-oidc/clients-list.png b/docs/images/sso/keycloak-oidc/clients-list.png new file mode 100644 index 0000000000..50e4e49cb9 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/clients-list.png differ diff --git a/docs/images/sso/keycloak-oidc/create-client-capability.png b/docs/images/sso/keycloak-oidc/create-client-capability.png new file mode 100644 index 0000000000..72aa508505 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/create-client-capability.png differ diff --git a/docs/images/sso/keycloak-oidc/create-client-general-settings.png b/docs/images/sso/keycloak-oidc/create-client-general-settings.png new file mode 100644 index 0000000000..87ec8d8370 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/create-client-general-settings.png differ diff --git a/docs/images/sso/keycloak-oidc/create-client-login-settings.png b/docs/images/sso/keycloak-oidc/create-client-login-settings.png new file mode 100644 index 0000000000..1c839f8d46 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/create-client-login-settings.png differ diff --git a/docs/images/sso/keycloak-oidc/create-oidc.png b/docs/images/sso/keycloak-oidc/create-oidc.png new file mode 100644 index 0000000000..358af1330a Binary files /dev/null and b/docs/images/sso/keycloak-oidc/create-oidc.png differ diff --git a/docs/images/sso/keycloak-oidc/enable-oidc.png b/docs/images/sso/keycloak-oidc/enable-oidc.png new file mode 100644 index 0000000000..6518319c45 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/enable-oidc.png differ diff --git a/docs/images/sso/keycloak-oidc/manage-org-oidc.png b/docs/images/sso/keycloak-oidc/manage-org-oidc.png new file mode 100644 index 0000000000..e6346cd24d Binary files /dev/null and b/docs/images/sso/keycloak-oidc/manage-org-oidc.png differ diff --git a/docs/images/sso/keycloak-oidc/realm-setting-oidc-config.png b/docs/images/sso/keycloak-oidc/realm-setting-oidc-config.png new file mode 100644 index 0000000000..9d3866c7e7 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/realm-setting-oidc-config.png differ diff --git a/docs/images/sso/keycloak-oidc/scope-predefined-mapper-1.png b/docs/images/sso/keycloak-oidc/scope-predefined-mapper-1.png new file mode 100644 index 0000000000..8b1cb16c48 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/scope-predefined-mapper-1.png differ diff --git a/docs/images/sso/keycloak-oidc/scope-predefined-mapper-2.png b/docs/images/sso/keycloak-oidc/scope-predefined-mapper-2.png new file mode 100644 index 0000000000..189cb34b27 Binary files /dev/null and b/docs/images/sso/keycloak-oidc/scope-predefined-mapper-2.png differ diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx index 8f29e43a5f..2aa2106dac 100644 --- a/docs/integrations/cicd/bitbucket.mdx +++ b/docs/integrations/cicd/bitbucket.mdx @@ -7,26 +7,62 @@ 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. + + + 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. + + + 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). + + + + + + diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx index 3fd70df73f..d68e8a14fd 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 + diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 1b0fb9f325..02b6fd9fb1 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -79,10 +79,9 @@ Infisical uses AES-256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 By default, Infisical employs a zero-knowledge-first approach to securely storing and sharing secrets. - Each secret belongs to a project and is symmetrically encrypted by that project's unique key. Each member of a project is shared a copy of the project key, encrypted under their public key, when they are first invited to join the project. -Since these encryption operations occur on the client-side, the Infisical API is not able to view the value of any secret and the default zero-knowledge property of Infisical is retained; as you'd expect, it follows that decryption operations also occur on the client-side. + Since these encryption operations occur on the client-side, the Infisical API is not able to view the value of any secret and the default zero-knowledge property of Infisical is retained; as you'd expect, it follows that decryption operations also occur on the client-side. - An exception to the zero-knowledge property occurs when a member of a project explicitly shares that project's unique key with Infisical. It is often necessary to share the project key with Infisical in order to use features like native integrations and secret rotation that wouldn't be possible to offer otherwise. - ## Infrastructure ### High availability @@ -90,19 +89,22 @@ Since these encryption operations occur on the client-side, the Infisical API is Infisical Cloud utilizes several strategies to ensure high availability, leveraging AWS services to maintain continuous operation and data integrity. #### Multi-AZ AWS RDS -Infisical Cloud uses AWS Relational Database Service (RDS) with Multi-AZ deployments. -This configuration ensures that the database service is highly available and durable. -AWS RDS automatically provisions and maintains a synchronous standby replica of the database in a different Availability Zone (AZ). -This setup facilitates immediate failover to the standby in the event of an AZ failure, thereby ensuring that database operations can continue with minimal interruption. + +Infisical Cloud uses AWS Relational Database Service (RDS) with Multi-AZ deployments. +This configuration ensures that the database service is highly available and durable. +AWS RDS automatically provisions and maintains a synchronous standby replica of the database in a different Availability Zone (AZ). +This setup facilitates immediate failover to the standby in the event of an AZ failure, thereby ensuring that database operations can continue with minimal interruption. The continuous backup and replication to the standby instance safeguard data against loss and ensure its availability even during system failures. #### Multi-AZ ECS for Container Orchestration -Infisical Cloud leverages Amazon Elastic Container Service (ECS) in a Multi-AZ configuration for container orchestration. -This arrangement enables the management and operation of containers across multiple availability zones, increasing the application's fault tolerance. -Should there be an AZ failure, load is seamlessly sent to an operational AZ, thus minimizing downtime and preserving service availability. + +Infisical Cloud leverages Amazon Elastic Container Service (ECS) in a Multi-AZ configuration for container orchestration. +This arrangement enables the management and operation of containers across multiple availability zones, increasing the application's fault tolerance. +Should there be an AZ failure, load is seamlessly sent to an operational AZ, thus minimizing downtime and preserving service availability. #### Standby Regions for Regional Failover -To fight regional outages, secondary regions are always in standby mode and maintained with up-to-date configurations and data, ready to take over in case the primary region fails. + +To fight regional outages, secondary regions are always in standby mode and maintained with up-to-date configurations and data, ready to take over in case the primary region fails. The standby regions enable a rapid transition and service continuity with minimal disruption in the event of a complete regional failure, ensuring that Infisical Cloud services remain accessible. ### Snapshots @@ -127,7 +129,7 @@ JWT tokens are stored in browser memory and appended to outbound requests requir ### User authentication -Infisical supports several authentication methods including email/password, Google SSO, GitHub SSO, and SAML 2.0 (Okta, Azure, JumpCloud); Infisical also currently offers email-based 2FA with authenticator app methods coming in Q1 2024. +Infisical supports several authentication methods including email/password, Google SSO, GitHub SSO, SAML 2.0 (Okta, Azure, JumpCloud), and OpenID Connect; Infisical also currently offers email-based 2FA with authenticator app methods coming in Q1 2024. Infisical uses the [secure remote password protocol](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol#:~:text=The%20SRP%20protocol%20has%20a,the%20user%20to%20the%20server), commonly found in other zero-knowledge platform architectures, for authentication. Put simply, the protocol enables Infisical to validate a user's knowledge of their password without ever seeing it by constructing a mutual secret; we use this protocol because each user's password is used to seed the generation of a master encryption/decryption key via KDF for that user which the platform @@ -141,6 +143,7 @@ Lastly, Infisical enforces strong password requirements according to the guidanc to access the platform. We strongly encourage users to generate and store their passwords / master decryption key in a password manager, such as 1Password, Bitwarden, or Dashlane. + ## Role-based access control (RBAC) @@ -172,7 +175,7 @@ Please email security@infisical.com to request any reports including a letter of Whether or not Infisical or your employees can access data in the Infisical instance and/or storage backend depends on many factors how you use Infisical: - Infisical Self-Hosted: Self-hosting Infisical is common amongst organizations that prefer to keep data on their own infrastructure usually to adhere to strict regulatory and compliance requirements. In this option, organizations retain full control over their data and therefore govern the data access policy of their Infisical instance and storage backend. -- Infisical Cloud: Using Infisical's managed service, [Infisical Cloud](https://app.infisical.com) means delegating data oversight and management to Infisical. Under our policy controls, employees are only granted access to parts of infrastructure according to principle of least privilege; this is especially relevant to customer data can only be accessed currently by executive management of Infisical. Moreover, any changes to sensitive customer data is prohibited without explicit customer approval. +- Infisical Cloud: Using Infisical's managed service, [Infisical Cloud](https://app.infisical.com) means delegating data oversight and management to Infisical. Under our policy controls, employees are only granted access to parts of infrastructure according to principle of least privilege; this is especially relevant to customer data can only be accessed currently by executive management of Infisical. Moreover, any changes to sensitive customer data is prohibited without explicit customer approval. It should be noted that, even on Infisical Cloud, it is physically impossible for employees of Infisical to view the values of secrets if users have not explicitly granted Infisical access to their project (i.e. opted out of zero-knowledge). diff --git a/docs/mint.json b/docs/mint.json index 1caad07156..ac2a536c0a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -178,7 +178,10 @@ "documentation/platform/sso/azure", "documentation/platform/sso/jumpcloud", "documentation/platform/sso/keycloak-saml", - "documentation/platform/sso/google-saml" + "documentation/platform/sso/google-saml", + "documentation/platform/sso/keycloak-oidc", + "documentation/platform/sso/auth0-oidc", + "documentation/platform/sso/general-oidc" ] }, { @@ -416,7 +419,9 @@ "pages": [ "api-reference/endpoints/identities/create", "api-reference/endpoints/identities/update", - "api-reference/endpoints/identities/delete" + "api-reference/endpoints/identities/delete", + "api-reference/endpoints/identities/get-by-id", + "api-reference/endpoints/identities/list" ] }, { @@ -426,9 +431,11 @@ "api-reference/endpoints/universal-auth/attach", "api-reference/endpoints/universal-auth/retrieve", "api-reference/endpoints/universal-auth/update", + "api-reference/endpoints/universal-auth/revoke", "api-reference/endpoints/universal-auth/create-client-secret", "api-reference/endpoints/universal-auth/list-client-secrets", "api-reference/endpoints/universal-auth/revoke-client-secret", + "api-reference/endpoints/universal-auth/get-client-secret-by-id", "api-reference/endpoints/universal-auth/renew-access-token", "api-reference/endpoints/universal-auth/revoke-access-token" ] @@ -638,5 +645,10 @@ ], "integrations": { "intercom": "hsg644ru" + }, + "analytics": { + "koala": { + "publicApiKey": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" + } } } diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 578e8ad0f3..3f779f1871 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 @@ -43,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. - diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index b225a3ace6..754409dfeb 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings https://app.infisical.com). + + Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`. + + ## Data Layer The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks diff --git a/docs/style.css b/docs/style.css index 3359151e46..4d9877c6cc 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 +} diff --git a/frontend/src/components/dashboard/DropZone.tsx b/frontend/src/components/dashboard/DropZone.tsx index ba0b9efdba..b196e8cd4b 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; diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 0d6b0d0615..befe79e4f3 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -26,4 +26,4 @@ export const createNotification = ( type: myProps?.type || "info", }); -export const NotificationContainer = () => ; +export const NotificationContainer = () => ; diff --git a/frontend/src/components/utilities/secrets/checkOverrides.ts b/frontend/src/components/utilities/secrets/checkOverrides.ts index 84ba0bbb7a..7f311a2529 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) => { diff --git a/frontend/src/components/utilities/secrets/encryptSecrets.ts b/frontend/src/components/utilities/secrets/encryptSecrets.ts index 943502cdfb..4eb59ae9ff 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 }; diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 5e7bbef786..4ba089c462 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); diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 29dba23c72..015b16f98c 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 ? ( +
+ {props.icon && } + +
+ + + + +
+ + +
- - Loading... +
- ) : ( - children - )} - - -
- -
-
-
-
-
+ + + {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 6a783605ab..3765851d5c 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/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 8d5f59ede3..524bc6ace0 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -5,7 +5,10 @@ export type TServerConfig = { isMigrationModeOn?: boolean; trustSamlEmails: boolean; trustLdapEmails: boolean; + trustOidcEmails: boolean; isSecretScanningDisabled: boolean; + defaultAuthOrgSlug: string | null; + defaultAuthOrgId: string | null; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 64511253bd..7cb5dbf429 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -81,6 +81,7 @@ export type TCreateCertificateDTO = { caId: string; friendlyName?: string; commonName: string; + altNames: string; // sans ttl: string; // string compatible with ms notBefore?: string; notAfter?: string; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 2dd6001877..d1ba46910a 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -6,6 +6,7 @@ export type TCertificate = { status: CertStatus; friendlyName: string; commonName: string; + altNames: string; serialNumber: string; notBefore: string; notAfter: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index b87c3f13f5..7e19ece330 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -17,6 +17,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 0000000000..b69c251208 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/index.tsx @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx new file mode 100644 index 0000000000..4a0d963cc1 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -0,0 +1,111 @@ +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, + configurationType, + discoveryURL, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + allowedEmailDomains, + clientId, + clientSecret, + isActive, + orgSlug + }: { + allowedEmailDomains?: string; + issuer?: string; + authorizationEndpoint?: string; + discoveryURL?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId?: string; + clientSecret?: string; + isActive?: boolean; + configurationType?: string; + orgSlug: string; + }) => { + const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { + issuer, + allowedEmailDomains, + authorizationEndpoint, + discoveryURL, + configurationType, + 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, + configurationType, + discoveryURL, + authorizationEndpoint, + allowedEmailDomains, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer?: string; + configurationType: string; + discoveryURL?: string; + authorizationEndpoint?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; + allowedEmailDomains?: string; + }) => { + const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { + issuer, + configurationType, + discoveryURL, + authorizationEndpoint, + allowedEmailDomains, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/oidcConfig/queries.tsx b/frontend/src/hooks/api/oidcConfig/queries.tsx new file mode 100644 index 0000000000..08b38cbb88 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/queries.tsx @@ -0,0 +1,23 @@ +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 +}; + +export const useGetOIDCConfig = (orgSlug: string) => { + return useQuery({ + queryKey: oidcConfigKeys.getOIDCConfig(orgSlug), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/sso/oidc/config?orgSlug=${orgSlug}` + ); + + return data; + }, + enabled: true + }); +}; diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts new file mode 100644 index 0000000000..1b8ede5e3e --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -0,0 +1,15 @@ +export type OIDCConfigData = { + id: string; + issuer: string; + authorizationEndpoint: string; + configurationType: string; + discoveryURL: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + isActive: boolean; + orgId: string; + clientId: string; + clientSecret: string; + allowedEmailDomains?: string; +}; diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 701a205435..a7c5106622 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 = ({ diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx index ca1ec76fd4..cd81152a13 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/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 28999389e9..382d01785b 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 diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index f36872e43a..378405c967 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; diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 911526404c..3e22c2684b 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; }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 3d4a6dc36b..89635a9533 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -23,6 +23,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + oidcSSO: boolean; scim: boolean; ldap: boolean; groups: boolean; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 649af434cf..43e4085716 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -29,7 +29,8 @@ export type User = { export enum UserAliasType { LDAP = "ldap", - SAML = "saml" + SAML = "saml", + OIDC = "oidc" } export type UserEnc = { diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index d560102783..1b6473bfe2 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -297,6 +297,7 @@ export const IntegrationsSection = ({ (popUp?.deleteConfirmation?.data as TIntegration)?.app || (popUp?.deleteConfirmation?.data as TIntegration)?.owner || (popUp?.deleteConfirmation?.data as TIntegration)?.path || + (popUp?.deleteConfirmation?.data as TIntegration)?.integration || "" } onDeleteApproved={async () => diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index 04a24d233c..36cd355f52 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, SAMLSSOStep } from "./components"; -import { navigateUserToSelectOrg } from "./Login.utils"; +import { InitialStep, MFAStep, SSOStep } from "./components"; +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"); @@ -57,7 +56,9 @@ export const Login = () => { /> ); case 2: - return ; + return ; + case 3: + return ; default: return
; } diff --git a/frontend/src/views/Login/Login.utils.tsx b/frontend/src/views/Login/Login.utils.tsx index b6e3c1a105..00f6037f16 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.defaultAuthOrgId) { + await selectOrganization.mutateAsync({ + organizationId: config.defaultAuthOrgId + }); - if (cliCallbackPort) { - redirectTo += `?callback_port=${cliCallbackPort}`; - } + await navigateUserToOrg(router, config.defaultAuthOrgId); + } + + queryClient.invalidateQueries(userKeys.getUser); + let redirectTo = "/login/select-organization"; + + if (cliCallbackPort) { + redirectTo += `?callback_port=${cliCallbackPort}`; + } + + router.push(redirectTo, undefined, { shallow: true }); + }; - router.push(redirectTo, undefined, { shallow: true }); + return { navigateToSelectOrganization: navigate }; }; diff --git a/frontend/src/views/Login/LoginLDAP.tsx b/frontend/src/views/Login/LoginLDAP.tsx index 021ac73342..1e99c611df 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.defaultAuthOrgSlug || passedOrgSlug || "" + ); const [username, setUsername] = useState(passedUsername || ""); const [password, setPassword] = useState(""); @@ -63,21 +67,22 @@ 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.defaultAuthOrgSlug && !passedOrgSlug && ( +
+
+ setOrganizationSlug(e.target.value)} + type="text" + placeholder="Enter your organization slug..." + isRequired + autoComplete="email" + id="email" + className="h-12" + /> +
-
+ )}
void; @@ -39,14 +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.defaultAuthOrgSlug) { + redirectToSaml(config.defaultAuthOrgSlug); + } else { + setStep(step); + } + }, []); + const handleLogin = async (e: FormEvent) => { e.preventDefault(); try { @@ -73,7 +87,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } - navigateUserToSelectOrg(router, callbackPort!); + navigateToSelectOrganization(callbackPort!); } else { setLoginError(true); createNotification({ @@ -98,7 +112,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } - navigateUserToSelectOrg(router); + navigateToSelectOrganization(); // case: login does not require MFA step createNotification({ @@ -209,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" @@ -217,6 +231,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 0000000000..e7d80b2c05 --- /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 7c55c4acfd..296b0503e6 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"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx index 03e70a80c0..12bd8c47cd 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -24,6 +24,7 @@ const schema = z.object({ caId: z.string(), friendlyName: z.string(), commonName: z.string().trim().min(1), + altNames: z.string(), ttl: z.string().trim() }); @@ -71,6 +72,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId: cert.caId, friendlyName: cert.friendlyName, commonName: cert.commonName, + altNames: cert.altNames, ttl: "" }); } else { @@ -78,12 +80,13 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId: "", friendlyName: "", commonName: "", + altNames: "", ttl: "" }); } }, [cert]); - const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => { + const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => { try { if (!currentWorkspace?.slug) return; @@ -92,6 +95,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId, friendlyName, commonName, + altNames, ttl }); @@ -192,6 +196,24 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + + + )} + /> ({ 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 4efd6bdc30..7d7126c560 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 98ffb0862a..ca0923894a 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/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index f1b9a4c353..8890174cdb 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -7,6 +7,7 @@ import { faCircleDot, faClock, faPlus, + faShare, faTag } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -57,6 +58,7 @@ type Props = { ) => Promise; tags: WsTag[]; onCreateTag: () => void; + handleSecretShare: (value: string) => void; }; export const SecretDetailSidebar = ({ @@ -69,7 +71,8 @@ export const SecretDetailSidebar = ({ tags, onCreateTag, environment, - secretPath + secretPath, + handleSecretShare }: Props) => { const { register, @@ -381,7 +384,7 @@ export const SecretDetailSidebar = ({ rows={5} /> -
+
-
+
+ +
+
Version History
{secretVersion?.map(({ createdAt, value, id }, i) => ( diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 98a8030203..3726f30575 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( )} + + + + + { 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 }) @@ -363,6 +367,11 @@ export const SecretListView = ({ onDeleteSecret={onDeleteSecret} onDetailViewSecret={onDetailViewSecret} onCreateTag={onCreateTag} + handleSecretShare={() => + handlePopUpOpen("createSharedSecret", { + value: secret.valueOverride ?? secret.value + }) + } /> ))}
@@ -389,11 +398,18 @@ export const SecretListView = ({ onSaveSecret={handleSaveSecret} tags={wsTags} onCreateTag={() => handlePopUpOpen("createTag")} + handleSecretShare={(value: string) => handlePopUpOpen("createSharedSecret", { value })} /> 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 c80296b7fd..1e1e4c7a8b 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/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 11ad5fcbad..09a16e4072 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"; @@ -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, @@ -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", @@ -807,6 +813,7 @@ export const SecretOverviewPage = () => { isSelected={selectedEntries.secret[key]} onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} onSecretDelete={handleSecretDelete} diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 85d3125d5c..0ef342a2e7 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/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index 71529c95cf..fc335baec1 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 }); @@ -93,12 +108,13 @@ export const SecretEditRow = ({
( 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: ( + env: string, + secretName: string + ) => { secret?: DecryptedSecret; environmentInfo?: WorkspaceEnv } | undefined; }; export const SecretOverviewTableRow = ({ @@ -41,6 +52,7 @@ export const SecretOverviewTableRow = ({ onSecretCreate, onSecretDelete, isImportedSecretPresentInEnv, + getImportedSecretByKey, expandableColWidth, onToggleSecretSelect, isSelected @@ -53,8 +65,9 @@ export const SecretOverviewTableRow = ({ <> setIsFormExpanded.toggle()} className="group">
@@ -107,8 +120,8 @@ export const SecretOverviewTableRow = ({ isSecretPresent ? "Present secret" : isSecretImported - ? "Imported secret" - : "Missing secret" + ? "Imported secret" + : "Missing secret" } >
-
+
{name} + {isImportedSecret && ( + + + + )}
@@ -198,8 +220,13 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} isVisible={isSecretVisible} secretName={secretKey} - defaultValue={secret?.value} + defaultValue={ + secret?.valueOverride || + secret?.value || + importedSecret?.secret?.value + } secretId={secret?.id} + isOverride={Boolean(secret?.valueOverride)} isImportedSecret={isImportedSecret} isCreatable={isCreatable} onSecretDelete={onSecretDelete} diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx index 8059f5e7b1..e5488ab9f4 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 6a2fe9bf6a..3d97228c3d 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 } ]; } 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 0000000000..ba3b0b8af4 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -0,0 +1,404 @@ +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, + Select, + SelectItem +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +enum ConfigurationType { + CUSTOM = "custom", + DISCOVERY_URL = "discoveryURL" +} + +type Props = { + popUp: UsePopUpState<["addOIDC"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + configurationType: z.string(), + issuer: z.string().optional(), + discoveryURL: z.string().optional(), + authorizationEndpoint: z.string().optional(), + jwksUri: z.string().optional(), + tokenEndpoint: z.string().optional(), + userinfoEndpoint: z.string().optional(), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + allowedEmailDomains: z.string().optional() + }) + .superRefine((data, ctx) => { + if (data.configurationType === ConfigurationType.CUSTOM) { + if (!data.issuer) { + ctx.addIssue({ + path: ["issuer"], + message: "Issuer is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.authorizationEndpoint) { + ctx.addIssue({ + path: ["authorizationEndpoint"], + message: "Authorization endpoint is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.jwksUri) { + ctx.addIssue({ + path: ["jwksUri"], + message: "JWKS URI is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.tokenEndpoint) { + ctx.addIssue({ + path: ["tokenEndpoint"], + message: "Token endpoint is required", + code: z.ZodIssueCode.custom + }); + } + if (!data.userinfoEndpoint) { + ctx.addIssue({ + path: ["userinfoEndpoint"], + message: "Userinfo endpoint is required", + code: z.ZodIssueCode.custom + }); + } + } else { + // eslint-disable-next-line no-lonely-if + if (!data.discoveryURL) { + ctx.addIssue({ + path: ["discoveryURL"], + message: "Discovery URL is required", + code: z.ZodIssueCode.custom + }); + } + } + }); + +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, watch } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + configurationType: ConfigurationType.DISCOVERY_URL + } + }); + + const [isClientIdFocused, setIsClientIdFocused] = useToggle(); + const [isClientSecretFocused, setIsClientSecretFocused] = useToggle(); + + const configurationTypeValue = watch("configurationType"); + + useEffect(() => { + if (data) { + setValue("issuer", data.issuer); + setValue("authorizationEndpoint", data.authorizationEndpoint); + setValue("jwksUri", data.jwksUri); + setValue("tokenEndpoint", data.tokenEndpoint); + setValue("userinfoEndpoint", data.userinfoEndpoint); + setValue("discoveryURL", data.discoveryURL); + setValue("clientId", data.clientId); + setValue("clientSecret", data.clientSecret); + setValue("allowedEmailDomains", data.allowedEmailDomains); + setValue("configurationType", data.configurationType); + } + }, [data]); + + const onOIDCModalSubmit = async ({ + issuer, + authorizationEndpoint, + allowedEmailDomains, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + configurationType, + discoveryURL, + clientId, + clientSecret + }: OIDCFormData) => { + try { + if (!currentOrg) { + return; + } + + if (!data) { + await createMutateAsync({ + issuer, + configurationType, + discoveryURL, + authorizationEndpoint, + allowedEmailDomains, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } else { + await updateMutateAsync({ + issuer, + configurationType, + discoveryURL, + authorizationEndpoint, + allowedEmailDomains, + 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(); + }} + > + +
+ ( + + + + )} + /> + {configurationTypeValue === ConfigurationType.DISCOVERY_URL && ( + ( + + + + )} + /> + )} + {configurationTypeValue === ConfigurationType.CUSTOM && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + ( + + + + )} + /> + ( + + setIsClientIdFocused.on()} + {...field} + onBlur={() => { + field.onBlur(); + setIsClientIdFocused.off(); + }} + autoComplete="off" + className="bg-mineshaft-800" + /> + + )} + /> + ( + + setIsClientSecretFocused.on()} + onBlur={() => { + field.onBlur(); + setIsClientSecretFocused.off(); + }} + className="bg-mineshaft-800" + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 322798d165..31d6987bd8 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 0000000000..ee09e3fe96 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -0,0 +1,118 @@ +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +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"; + +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", + "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 + }); + + 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 () => { + if (subscription?.oidcSSO && currentOrg) { + handlePopUpOpen("addOIDC"); + } else { + handlePopUpOpen("upgradePlan"); + } + }; + + 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 +

+
+ )} + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use OIDC SSO if you switch to Infisical's Pro plan." + /> + + ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index 511dff93e1..bdbd41c1c9 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 0000000000..23c4950743 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/RebuildSecretIndicesSection/RebuildSecretIndicesSection.tsx @@ -0,0 +1,93 @@ +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); + + if (!isAdmin) { + return null; + } + + return ( +
+
+

Rebuild Secret Indices

+
+

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

+ +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx index f8201fb9e1..5a97dd9e41 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -6,15 +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, - SecretInput, - 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({ @@ -32,7 +24,8 @@ export const AddShareSecretForm = ({ handleSubmit, control, isSubmitting, - setNewSharedSecret + setNewSharedSecret, + isInputDisabled }: { isPublic: boolean; inModal: boolean; @@ -40,6 +33,7 @@ export const AddShareSecretForm = ({ control: any; isSubmitting: boolean; setNewSharedSecret: (value: string) => void; + isInputDisabled?: boolean; }) => { const publicSharedSecretCreator = useCreatePublicSharedSecret(); const privateSharedSecretCreator = useCreateSharedSecret(); @@ -125,37 +119,39 @@ export const AddShareSecretForm = ({ }; return (
-
+
( - )} />
-
+
( @@ -164,16 +160,16 @@ export const AddShareSecretForm = ({ )} />
-
+

OR

-
-
-
+
+
+
(
-
+
onChange(e)} - className="w-full" + className="w-full border border-mineshaft-600" > {expirationUnitsAndActions.map(({ unit }) => ( @@ -211,7 +207,7 @@ export const AddShareSecretForm = ({
-
+
diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx index d30432982e..f8cfd1e8b6 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -34,6 +34,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModa control, reset, handleSubmit, + setValue, formState: { isSubmitting } } = useForm({ resolver: yupResolver(schema) @@ -45,6 +46,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModa initialState: false }); + const [isSecretInputDisabled, setIsSecretInputDisabled] = useState(false); + const copyUrlToClipboard = () => { navigator.clipboard.writeText(newSharedSecret); setIsUrlCopied(true); @@ -55,6 +58,13 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModa } }, [isUrlCopied]); + useEffect(() => { + if (popUp.createSharedSecret.data) { + setValue("value", (popUp.createSharedSecret.data as { value: string }).value); + setIsSecretInputDisabled(true); + } + }, [popUp.createSharedSecret.data]); + // eslint-disable-next-line no-nested-ternary return inModal ? ( ) : ( ) : ( void; }) => { return ( -
-
-
+
+
+

{newSharedSecret}

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

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

-
- {id && ( - - )} -
- - {isNewSession && ( - - )} - -
-
-
+
+
+
+ + Infisical logo + +
+
+

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

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

- Safe, Secure, & Open Source -

-

- Infisical is the #1 {" "} + {!isNewSession && ( +

- 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 - - + + +
+ )} +
+
+
+
+

+ 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 d2a17e5668..399bdc40d8 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 diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx index c608fb99c7..6d73c879bf 100644 --- a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -92,6 +92,10 @@ export const EmailConfirmationStep = ({ router.push(`/login/ldap?organizationSlug=${organizationSlug}`); break; } + case UserAliasType.OIDC: { + router.push(`/api/v1/sso/oidc/login?orgSlug=${organizationSlug}`); + break; + } default: { setStep(1); break; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 52fddb22c2..134a67f8cd 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, @@ -21,7 +22,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"; @@ -39,7 +40,9 @@ 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(), + defaultAuthOrgId: z.string() }); type TDashboardForm = z.infer; @@ -60,16 +63,21 @@ 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, + defaultAuthOrgId: config.defaultAuthOrgId ?? "" } }); - const signupMode = watch("signUpMode"); + const signUpMode = watch("signUpMode"); + const defaultAuthOrgId = watch("defaultAuthOrgId"); const { user, isLoading: isUserLoading } = useUser(); 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 @@ -84,13 +92,15 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + const { allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = formData; await updateServerConfig({ + defaultAuthOrgId: defaultAuthOrgId || null, allowSignUp: signUpMode !== SignUpModes.Disabled, allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, trustSamlEmails, - trustLdapEmails + trustLdapEmails, + trustOidcEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -126,7 +136,7 @@ export const AdminDashboardPage = () => {
@@ -142,13 +152,13 @@ export const AdminDashboardPage = () => { name="signUpMode" render={({ field: { onChange, ...field }, fieldState: { error } }) => ( onChange(e)} + {...field} + > + { + console.log("clearing"); + onChange(""); + }} + > + Allow all organizations + + {organizations.data?.map((org) => ( + + {org.name} + + ))} + + + )} + /> +
+ +
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/OIDC + provisioned users to verify their email upon their first login.
{ ); }} /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust OIDC emails

+
+
+ ); + }} + />