diff --git a/backend/package-lock.json b/backend/package-lock.json index fc6553745..ef794f1d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -53,6 +53,7 @@ "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", @@ -3957,6 +3958,14 @@ "integrity": "sha512-2h3tFvkbHksiNcDiUdcJ08gXWG10fnahp30GJ2Tbt4vd4pfsbfkoKTaTbYykFoppaJ6DL3914nQ3PU1vVIlBRQ==", "dev": true }, + "node_modules/@types/ldapjs": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", + "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/libsodium-wrappers": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz", @@ -5104,6 +5113,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -5270,6 +5295,17 @@ "axios": "0.x || 1.x" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5315,6 +5351,11 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -5785,6 +5826,11 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -6855,6 +6901,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ] + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -8584,6 +8638,57 @@ "node": ">=8" } }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapauth-fork": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz", + "integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==", + "dependencies": { + "@types/ldapjs": "^2.2.2", + "bcryptjs": "^2.4.0", + "ldapjs": "^2.2.1", + "lru-cache": "^7.10.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ldapauth-fork/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -9730,6 +9835,18 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-ldapauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz", + "integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==", + "dependencies": { + "ldapauth-fork": "^5.0.1", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", @@ -10284,6 +10401,14 @@ "node": ">=15.0.0" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12737,6 +12862,43 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/vite": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", diff --git a/backend/package.json b/backend/package.json index 4921d7f30..2487e9151 100644 --- a/backend/package.json +++ b/backend/package.json @@ -114,6 +114,7 @@ "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index bd7a09d86..649c54c54 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -3,6 +3,7 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; @@ -69,6 +70,7 @@ declare module "fastify" { }; auditLogInfo: Pick; ssoConfig: Awaited>; + ldapConfig: Awaited>; } interface FastifyInstance { @@ -107,6 +109,7 @@ declare module "fastify" { snapshot: TSecretSnapshotServiceFactory; saml: TSamlConfigServiceFactory; scim: TScimServiceFactory; + ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5b5961558..dadc2fac2 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -50,6 +50,9 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TLdapConfigs, + TLdapConfigsInsert, + TLdapConfigsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -318,6 +321,7 @@ declare module "knex/types/tables" { TSecretSnapshotFoldersUpdate >; [TableName.SamlConfig]: Knex.CompositeTableType; + [TableName.LdapConfig]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; [TableName.AuditLog]: Knex.CompositeTableType; [TableName.GitAppInstallSession]: Knex.CompositeTableType< diff --git a/backend/src/db/migrations/20240222182823_ldap-config.ts b/backend/src/db/migrations/20240222182823_ldap-config.ts new file mode 100644 index 000000000..7add62363 --- /dev/null +++ b/backend/src/db/migrations/20240222182823_ldap-config.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.LdapConfig))) { + await knex.schema.createTable(TableName.LdapConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable().unique(); + t.foreign("orgId").references("id").inTable(TableName.Organization); + t.boolean("isActive").notNullable(); + t.string("url").notNullable(); + t.string("encryptedBindDN"); + t.string("bindDNIV"); + t.string("bindDNTag"); + t.string("encryptedBindPass"); + t.string("bindPassIV"); + t.string("bindPassTag"); + t.text("searchBase").notNullable(); + t.string("encryptedCACert"); + t.string("caCertIV"); + t.string("caCertTag"); + t.timestamps(true, true, true); + }); + } + + await knex.schema.alterTable(TableName.Users, (t) => { + t.string("username"); + t.uuid("orgId"); + t.string("email").nullable().alter(); + }); + + await knex(TableName.Users).update("username", knex.ref("email")); + + await createOnUpdateTrigger(knex, TableName.LdapConfig); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.LdapConfig); + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("username"); + t.dropColumn("orgId"); + t.string("email").notNullable().alter(); + }); + await dropOnUpdateTrigger(knex, TableName.LdapConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b330c90b7..b70e97847 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -14,6 +14,7 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./ldap-configs"; export * from "./models"; export * from "./org-bots"; export * from "./org-memberships"; diff --git a/backend/src/db/schemas/ldap-configs.ts b/backend/src/db/schemas/ldap-configs.ts new file mode 100644 index 000000000..653553bae --- /dev/null +++ b/backend/src/db/schemas/ldap-configs.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const LdapConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + isActive: z.boolean(), + url: z.string(), + encryptedBindDN: z.string().nullable().optional(), + bindDNIV: z.string().nullable().optional(), + bindDNTag: z.string().nullable().optional(), + encryptedBindPass: z.string().nullable().optional(), + bindPassIV: z.string().nullable().optional(), + bindPassTag: z.string().nullable().optional(), + searchBase: z.string(), + encryptedCACert: z.string().nullable().optional(), + caCertIV: z.string().nullable().optional(), + caCertTag: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TLdapConfigs = z.infer; +export type TLdapConfigsInsert = Omit; +export type TLdapConfigsUpdate = Partial>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index abfb67949..d2c9bc4dc 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -50,6 +50,7 @@ export enum TableName { SecretRotation = "secret_rotations", SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", + LdapConfig = "ldap_configs", AuditLog = "audit_logs", GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index cdbb5607a..191dff6c4 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -9,7 +9,7 @@ import { TImmutableDBKeys } from "./models"; export const UsersSchema = z.object({ id: z.string().uuid(), - email: z.string(), + email: z.string().nullable().optional(), authMethods: z.string().array().nullable().optional(), superAdmin: z.boolean().default(false).nullable().optional(), firstName: z.string().nullable().optional(), @@ -20,7 +20,9 @@ export const UsersSchema = z.object({ devices: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - isGhost: z.boolean().default(false) + isGhost: z.boolean().default(false), + username: z.string().nullable().optional(), + orgId: z.string().uuid().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index fb92aa3c5..7d1492f84 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -35,6 +36,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }); await server.register(registerSamlRouter, { prefix: "/sso" }); await server.register(registerScimRouter, { prefix: "/scim" }); + await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts new file mode 100644 index 000000000..d6ef93381 --- /dev/null +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -0,0 +1,172 @@ +/* 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 } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { FastifyRequest } from "fastify"; +import LdapStrategy from "passport-ldapauth"; +import { z } from "zod"; + +import { LdapConfigsSchema } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerLdapRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "ldap", userProperty: "passportUser" }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + passport.use( + new LdapStrategy( + server.services.ldap.getLDAPConfiguration, + // eslint-disable-next-line + async (req, user, cb) => { + try { + const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ + username: user.uid, + firstName: user.givenName, + lastName: user.sn, + relayState: (req.body as { RelayState?: string }).RelayState, + orgId: (req as unknown as FastifyRequest).ldapConfig.organization + }); + + return cb(null, { isUserCompleted, providerAuthToken }); + } catch (err) { + logger.error(err); + return cb(null, false); + } + } + ) + ); + + server.route({ + url: "/login", + method: "POST", + schema: { + body: z.object({ + organizationSlug: z.string().trim() + }) + }, + preValidation: passport.authenticate("ldapauth", { + session: false, + failureFlash: true, + failureRedirect: "/login/provider/error" + // this is due to zod type difference + }) as any, + handler: (req, res) => { + let nextUrl; + if (req.passportUser.isUserCompleted) { + nextUrl = `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`; + } else { + nextUrl = `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`; + } + + return res.status(200).send({ + nextUrl + }); + } + }); + + server.route({ + url: "/config", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + id: z.string(), + organization: z.string(), + isActive: z.boolean(), + url: z.string(), + bindDN: z.string(), + bindPass: z.string(), + searchBase: z.string(), + caCert: z.string() + }) + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.getLdapCfg({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.query.organizationId, + actorOrgId: req.permission.orgId + }); + return ldap; + } + }); + + server.route({ + url: "/config", + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + organizationId: z.string().trim(), + isActive: z.boolean(), + url: z.string().trim(), + bindDN: z.string().trim(), + bindPass: z.string().trim(), + searchBase: z.string().trim(), + caCert: z.string().trim().default("") + }), + response: { + 200: LdapConfigsSchema + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.createLdapCfg({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.body.organizationId, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return ldap; + } + }); + + server.route({ + url: "/config", + method: "PATCH", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + organizationId: z.string().trim(), + isActive: z.boolean().optional(), + url: z.string().trim().optional(), + bindDN: z.string().trim().optional(), + bindPass: z.string().trim().optional(), + searchBase: z.string().trim().optional(), + caCert: z.string().trim().optional() + }), + response: { + 200: LdapConfigsSchema + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.updateLdapCfg({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.body.organizationId, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return ldap; + } + }); +}; 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 2aaaf7562..1a41c08a0 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -92,7 +92,8 @@ export enum EventType { interface UserActorMetadata { userId: string; - email: string; + email?: string; + username?: string; } interface ServiceActorMetadata { diff --git a/backend/src/ee/services/ldap-config/ldap-config-dal.ts b/backend/src/ee/services/ldap-config/ldap-config-dal.ts new file mode 100644 index 000000000..d05747c36 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-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 TLdapConfigDALFactory = ReturnType; + +export const ldapConfigDALFactory = (db: TDbClient) => { + const ldapCfgOrm = ormify(db, TableName.LdapConfig); + + return { ...ldapCfgOrm }; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts new file mode 100644 index 000000000..1a2230174 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -0,0 +1,427 @@ +import { ForbiddenError } from "@casl/ability"; +import { FastifyRequest } from "fastify"; +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TLdapConfigsUpdate } from "@app/db/schemas"; +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 { TOrgPermission } from "@app/lib/types"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TLdapConfigDALFactory } from "./ldap-config-dal"; +import { TCreateLdapCfgDTO, TLdapLoginDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; + +// TODO: check the Picks +type TLdapConfigServiceFactoryDep = { + ldapConfigDAL: TLdapConfigDALFactory; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgBotDAL: Pick; + userDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TLdapConfigServiceFactory = ReturnType; + +export const ldapConfigServiceFactory = ({ + ldapConfigDAL, + orgDAL, + orgBotDAL, + userDAL, + permissionService, + licenseService +}: TLdapConfigServiceFactoryDep) => { + const createLdapCfg = async ({ + actor, + actorId, + orgId, + actorOrgId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + }: TCreateLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: + "Failed to create LDAP configuration due to plan restriction. Upgrade plan to create LDAP configuration." + }); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId }, 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, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedBindDN, iv: bindDNIV, tag: bindDNTag } = encryptSymmetric(bindDN, key); + const { ciphertext: encryptedBindPass, iv: bindPassIV, tag: bindPassTag } = encryptSymmetric(bindPass, key); + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + + const ldapConfig = await ldapConfigDAL.create({ + orgId, + isActive, + url, + encryptedBindDN, + bindDNIV, + bindDNTag, + encryptedBindPass, + bindPassIV, + bindPassTag, + searchBase, + encryptedCACert, + caCertIV, + caCertTag + }); + + return ldapConfig; + }; + + const updateLdapCfg = async ({ + actor, + actorId, + orgId, + actorOrgId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + }: TUpdateLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: + "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." + }); + + const updateQuery: TLdapConfigsUpdate = { + isActive, + url, + searchBase + }; + + const orgBot = await orgBotDAL.findOne({ 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 + }); + + if (bindDN) { + const { ciphertext: encryptedBindDN, iv: bindDNIV, tag: bindDNTag } = encryptSymmetric(bindDN, key); + updateQuery.encryptedBindDN = encryptedBindDN; + updateQuery.bindDNIV = bindDNIV; + updateQuery.bindDNTag = bindDNTag; + } + + if (bindPass) { + const { ciphertext: encryptedBindPass, iv: bindPassIV, tag: bindPassTag } = encryptSymmetric(bindPass, key); + updateQuery.encryptedBindPass = encryptedBindPass; + updateQuery.bindPassIV = bindPassIV; + updateQuery.bindPassTag = bindPassTag; + } + + if (caCert) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCACert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + const [ldapConfig] = await ldapConfigDAL.update({ orgId }, updateQuery); + + return ldapConfig; + }; + + const getLdapCfg2 = async (orgId: string) => { + const ldapConfig = await ldapConfigDAL.findOne({ orgId }); + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const orgBot = await orgBotDAL.findOne({ orgId: ldapConfig.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 { + encryptedBindDN, + bindDNIV, + bindDNTag, + encryptedBindPass, + bindPassIV, + bindPassTag, + encryptedCACert, + caCertIV, + caCertTag + } = ldapConfig; + + let bindDN = ""; + if (encryptedBindDN && bindDNIV && bindDNTag) { + bindDN = decryptSymmetric({ + ciphertext: encryptedBindDN, + key, + tag: bindDNTag, + iv: bindDNIV + }); + } + + let bindPass = ""; + if (encryptedBindPass && bindPassIV && bindPassTag) { + bindPass = decryptSymmetric({ + ciphertext: encryptedBindPass, + key, + tag: bindPassTag, + iv: bindPassIV + }); + } + + let caCert = ""; + if (encryptedCACert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCACert, + key, + tag: caCertTag, + iv: caCertIV + }); + } + + return { + id: ldapConfig.id, + organization: ldapConfig.orgId, + isActive: ldapConfig.isActive, + url: ldapConfig.url, + bindDN, + bindPass, + searchBase: ldapConfig.searchBase, + caCert + }; + }; + + const getLdapCfg = async ({ actor, actorId, orgId, actorOrgId }: TOrgPermission) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + return getLdapCfg2(orgId); + }; + + // eslint-disable-next-line + const getLDAPConfiguration = (req: FastifyRequest, callback: any) => { + const { organizationSlug } = req.body as { + organizationSlug: string; + }; + + // req.ldapConfig = getLdapCfg2(organization.id); + + const boot = async () => { + const organization = await orgDAL.findOne({ slug: organizationSlug }); + const ldapConfig = await getLdapCfg2(organization.id); // repeat? + req.ldapConfig = ldapConfig; + + // example + // var opts = { + // server: { + // // url: 'ldaps://openldap:636', // connection over SSL/TLS + // url: 'ldap://openldap:389', + // bindDN: 'cn=admin,dc=acme,dc=com', + // bindCredentials: 'admin', + // searchBase: 'ou=people,dc=acme,dc=com', + // searchFilter: '(uid={{username}})', + // searchAttributes: ['uid', 'givenName', 'sn'], // optional, defaults to all (get username too) + // // tlsOptions: { + // // ca: [caCert] + // // } + // }, + // passReqToCallback: true + // }; + + const opts = { + server: { + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + searchBase: ldapConfig.searchBase, + searchFilter: "(uid={{username}})", + searchAttributes: ["uid", "givenName", "sn"], + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }, + passReqToCallback: true + }; + + // eslint-disable-next-line + callback(null, opts); + }; + + process.nextTick(async () => { + await boot(); + }); + }; + + const ldapLogin = async ({ username, firstName, lastName, orgId, relayState }: TLdapLoginDTO) => { + const appCfg = getConfig(); + let user = await userDAL.findOne({ + username, + orgId + }); + + const organization = await orgDAL.findOrgById(orgId); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + if (user) { + await userDAL.transaction(async (tx) => { + const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx }); + if (!orgMembership) { + await orgDAL.createMembership( + { + userId: user.id, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Accepted + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + }); + } else { + user = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.create( + { + username, + firstName, + lastName, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + await orgDAL.createMembership({ + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited // should this be invited? + }); + return newUser; + }); + } + + const isUserCompleted = Boolean(user.isAccepted); + + const providerAuthToken = jwt.sign( + { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user.id, + firstName, + lastName, + organizationName: organization.name, + organizationId: organization.id, + authMethod: AuthMethod.LDAP, + isUserCompleted, + ...(relayState + ? { + callbackPort: (JSON.parse(relayState) as { callbackPort: string }).callbackPort + } + : {}) + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME + } + ); + + return { isUserCompleted, providerAuthToken }; + }; + + return { + createLdapCfg, + updateLdapCfg, + getLdapCfg, + getLDAPConfiguration, + ldapLogin + }; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts new file mode 100644 index 000000000..263085735 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -0,0 +1,28 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TCreateLdapCfgDTO = { + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + caCert: string; +} & TOrgPermission; + +export type TUpdateLdapCfgDTO = Partial<{ + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + caCert: string; +}> & + TOrgPermission; + +export type TLdapLoginDTO = { + username: string; + firstName: string; + lastName: string; + orgId: string; + relayState?: string; +}; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9b1d4c203..fa23664f4 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -25,6 +25,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogsRetentionDays: 0, samlSSO: false, scim: false, + ldap: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 6d97b537c..5f935c6a2 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -135,14 +135,14 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: } }; - const generateOrgCustomerId = async (orgName: string, email: string) => { + const generateOrgCustomerId = async (orgName: string, email?: string | null) => { if (instanceType === InstanceType.Cloud) { const { data: { customerId } } = await licenseServerCloudApi.request.post<{ customerId: string }>( "/api/license-server/v1/customers", { - email, + email: email ?? "", name: orgName }, { timeout: 5000, signal: AbortSignal.timeout(5000) } diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 354714489..33ed2ba0b 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -26,6 +26,7 @@ export type TFeatureSet = { auditLogsRetentionDays: 0; samlSSO: false; scim: false; + ldap: true; status: null; trial_end: null; has_used_trial: true; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index b541e16be..f2ce8cf65 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -69,7 +69,7 @@ export const samlConfigServiceFactory = ({ if (!plan.samlSSO) throw new BadRequestError({ message: - "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to create SSO configuration." }); const orgBot = await orgBotDAL.transaction(async (tx) => { @@ -122,7 +122,6 @@ export const samlConfigServiceFactory = ({ const { ciphertext: encryptedEntryPoint, iv: entryPointIV, tag: entryPointTag } = encryptSymmetric(entryPoint, key); const { ciphertext: encryptedIssuer, iv: issuerIV, tag: issuerTag } = encryptSymmetric(issuer, key); - const { ciphertext: encryptedCert, iv: certIV, tag: certTag } = encryptSymmetric(cert, key); const samlConfig = await samlConfigDAL.create({ orgId, diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index b5f35c9ee..53c2b60ef 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -20,21 +20,23 @@ export const buildScimUserList = ({ export const buildScimUser = ({ userId, + username, + email, firstName, lastName, - email, active }: { userId: string; + username: string; + email: string; firstName: string; lastName: string; - email: string; active: boolean; }): TScimUser => { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], id: userId, - userName: email, + userName: username, displayName: `${firstName} ${lastName}`, name: { givenName: firstName, diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 33b2f2c84..f54636654 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -152,12 +152,13 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ userId, firstName, lastName, email }) => + const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => buildScimUser({ userId: userId ?? "", + username: username ?? "", firstName: firstName ?? "", lastName: lastName ?? "", - email, + email: email ?? "", active: true }) ); @@ -196,9 +197,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username as string, + email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active: true }); }; @@ -284,9 +286,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: user.id, + username: user.username as string, firstName: user.firstName as string, lastName: user.lastName as string, - email: user.email, + email: user.email ?? "", active: true }); }; @@ -342,9 +345,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username as string, + email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active }); }; @@ -387,9 +391,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username as string, + email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active }); }; diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts index aab8d1218..04de3e7ef 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts @@ -149,7 +149,7 @@ export const secretScanningQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretLeakIncident, subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails, + recipients: adminEmails.filter((email) => email).map((email) => email as string), substitutions: { numberOfSecrets: Object.keys(allFindingsByFingerprint).length, pusher_email: pusher.email, @@ -221,7 +221,7 @@ export const secretScanningQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretLeakIncident, subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails, + recipients: adminEmails.filter((email) => email).map((email) => email as string), substitutions: { numberOfSecrets: findings.length } diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index de352a9bc..303662bba 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -43,7 +43,8 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { payload.actor = { type: ActorType.USER, metadata: { - email: req.auth.user.email, + email: req.auth.user.email as string | undefined, + username: req.auth.user.username as string | undefined, userId: req.permission.id } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 9444aff16..1c1829647 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -5,6 +5,8 @@ import { registerV1EERoutes } from "@app/ee/routes/v1"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; +import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; @@ -158,11 +160,12 @@ export const registerRoutes = async ( const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); - const scimDAL = scimDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); const samlConfigDAL = samlConfigDALFactory(db); + const scimDAL = scimDALFactory(db); + const ldapConfigDAL = ldapConfigDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); @@ -226,6 +229,15 @@ export const registerRoutes = async ( smtpService }); + const ldapService = ldapConfigServiceFactory({ + ldapConfigDAL, + orgDAL, + orgBotDAL, + userDAL, + permissionService, + licenseService + }); + const telemetryService = telemetryServiceFactory(); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); const userService = userServiceFactory({ userDAL }); @@ -529,6 +541,7 @@ export const registerRoutes = async ( secretRotation: secretRotationService, snapshot: snapshotService, saml: samlService, + ldap: ldapService, auditLog: auditLogService, secretScanning: secretScanningService, license: licenseService, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index ab069a2dd..0b2d19a7c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -91,9 +91,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.AdminInit, - distinctId: user.user.email, + distinctId: user.user.email ?? user.user.username ?? "", properties: { - email: user.user.email, + email: user.user.email ?? "", lastName: user.user.lastName || "", firstName: user.user.firstName || "" } diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 01ef7973a..0356798c7 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -179,11 +179,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const organization = await server.services.org.createOrganization( - req.permission.id, - req.auth.user.email, - req.body.name - ); + const organization = await server.services.org.createOrganization({ + userId: req.permission.id, + userEmail: req.auth.user.email, + orgName: req.body.name + }); + return { organization }; } }); diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 1aac86205..ab5f81b96 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -22,7 +22,7 @@ import { secretRawSchema } from "../sanitizedSchemas"; const getDistinctId = (req: FastifyRequest) => { if (req.auth.actor === ActorType.USER) { - return req.auth.user.email; + return req.auth.user.email ?? req.auth.user.username ?? "unknown-user"; } if (req.auth.actor === ActorType.IDENTITY) { return `identity-${req.auth.identityId}`; diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 209e86ac7..0f7a90dc2 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -97,13 +97,15 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { authorization: req.headers.authorization as string }); - void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + if (user.email) { + void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + } void server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.UserSignedUp, - distinctId: user.email, + distinctId: user.email ?? user.username ?? "", properties: { - email: user.email, + email: user.email ?? "", attributionSource: req.body.attributionSource } }); @@ -160,13 +162,15 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { authorization: req.headers.authorization as string }); - void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + if (user.email) { + void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + } void server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.UserSignedUp, - distinctId: user.email, + distinctId: user.email ?? user.username ?? "", properties: { - email: user.email, + email: user.email ?? "", attributionSource: "Team Invite" } }); diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index b46803b06..31675a599 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -5,7 +5,7 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { AuthModeProviderJwtTokenPayload, AuthModeProviderSignUpTokenPayload, AuthTokenType } from "./auth-type"; -export const validateProviderAuthToken = (providerToken: string, email: string) => { +export const validateProviderAuthToken = (providerToken: string, email?: string) => { if (!providerToken) throw new UnauthorizedError(); const appCfg = getConfig(); const decodedToken = jwt.verify(providerToken, appCfg.AUTH_SECRET) as AuthModeProviderJwtTokenPayload; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index dcc9381b7..7d5291a94 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -38,17 +38,19 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }); - await smtpService.sendMail({ - template: SmtpTemplates.NewDeviceJoin, - subjectLine: "Successful login from new device", - recipients: [user.email], - substitutions: { - email: user.email, - timestamp: new Date().toString(), - ip, - userAgent - } - }); + if (user.email) { + await smtpService.sendMail({ + template: SmtpTemplates.NewDeviceJoin, + subjectLine: "Successful login from new device", + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } } }; @@ -199,10 +201,12 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: } ); - await sendUserMfaCode({ - userId: userEnc.userId, - email: userEnc.email - }); + if (userEnc.email) { + await sendUserMfaCode({ + userId: userEnc.userId, + email: userEnc.email + }); + } return { isMfaEnabled: true, token: mfaToken } as const; } @@ -226,7 +230,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: */ const resendMfaToken = async (userId: string) => { const user = await userDAL.findById(userId); - if (!user) return; + if (!user || !user.email) return; await sendUserMfaCode({ userId: user.id, email: user.email diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 61fcfe3ce..1e8610544 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -122,7 +122,7 @@ export const authSignupServiceFactory = ({ let organizationId; if (providerAuthToken) { - const { orgId } = validateProviderAuthToken(providerAuthToken, user.email); + const { orgId } = validateProviderAuthToken(providerAuthToken, user.email as string); organizationId = orgId; } else { validateSignUpAuthorization(authorization, user.id); @@ -150,7 +150,11 @@ export const authSignupServiceFactory = ({ }); if (!organizationId) { - await orgService.createOrganization(user.id, user.email, organizationName); + await orgService.createOrganization({ + userId: user.id, + userEmail: user.email, + orgName: organizationName + }); } const updatedMembersips = await orgDAL.updateMembership( diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index b051952e7..56097e6ac 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -5,7 +5,8 @@ export enum AuthMethod { GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + LDAP = "ldap" } export enum AuthTokenType { diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 05c77e746..393efd6d6 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -243,6 +243,7 @@ export const orgDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("scimEnabled").withSchema(TableName.Organization) diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f39ac5366..1e647ed3b 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -233,7 +233,15 @@ export const orgServiceFactory = ({ /* * Create organization * */ - const createOrganization = async (userId: string, userEmail: string, orgName: string) => { + const createOrganization = async ({ + userId, + userEmail, + orgName + }: { + userId: string; + orgName: string; + userEmail?: string | null; + }) => { const { privateKey, publicKey } = generateAsymmetricKeyPair(); const key = generateSymmetricKey(); const { diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index e96247c97..34f5d4006 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -135,7 +135,7 @@ export const projectMembershipServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, subjectLine: "Infisical workspace invitation", - recipients: invitees.map((i) => i.email), + recipients: invitees.filter((i) => i.email).map((i) => i.email as string), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` @@ -207,7 +207,9 @@ export const projectMembershipServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ email }) => email).filter(Boolean), + recipients: orgMembers + .map(({ email }) => email) + .filter((email): email is string => email !== null && email !== undefined), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` @@ -317,7 +319,7 @@ export const projectMembershipServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ user }) => user.email).filter(Boolean), + recipients: orgMembers.filter(({ user }) => user.email).map(({ user }) => user.email as string), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index b797b7caf..a686dd41f 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -350,7 +350,7 @@ export const secretQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)], + recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 64dac73f2..41fd14d85 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -68,6 +68,7 @@ export const superAdminServiceFactory = ({ { firstName, lastName, + username: email, email, superAdmin: true, isGhost: false, @@ -97,11 +98,11 @@ export const superAdminServiceFactory = ({ const initialOrganizationName = appCfg.INITIAL_ORGANIZATION_NAME ?? "Admin Org"; - const organization = await orgService.createOrganization( - userInfo.user.id, - userInfo.user.email, - initialOrganizationName - ); + const organization = await orgService.createOrganization({ + userId: userInfo.user.id, + userEmail: userInfo.user.email, + orgName: initialOrganizationName + }); await updateServerCfg({ initialized: true }); const token = await authService.generateUserTokens({ diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index eebfd958f..e309cdb17 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -11,6 +11,10 @@ export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { + const user = await userDAL.findById(userId); + + if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" }); + const updatedUser = await userDAL.updateById(userId, { isMfaEnabled, mfaMethods: isMfaEnabled ? ["email"] : [] diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 3d32161d1..41f1b02f5 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -14,6 +14,8 @@ import { Login1Res, Login2DTO, Login2Res, + LoginLDAPDTO, + LoginLDAPRes, ResetPasswordDTO, SendMfaTokenDTO, SRP1DTO, @@ -37,6 +39,11 @@ export const login2 = async (loginDetails: Login2DTO) => { return data; }; +export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { + const { data } = await apiRequest.post("/api/v1/ldap/login", loginLDAPDetails); // return if account is complete or not + provider auth token + return data; +} + export const useLogin1 = () => { return useMutation({ mutationFn: async (details: { diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 7ed7af566..a18d023c4 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -53,6 +53,16 @@ export type Login2Res = { tag?: string; } +export type LoginLDAPDTO = { + organizationSlug: string; + username: string; + password: string; +} + +export type LoginLDAPRes = { + nextUrl: string; +} + export type SRP1DTO = { clientPublicKey: string; } diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index d63a5e111..84c1632c1 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -8,6 +8,7 @@ export * from "./incidentContacts"; export * from "./integrationAuth"; export * from "./integrations"; export * from "./keys"; +export * from "./ldapConfig"; export * from "./organization"; export * from "./roles"; export * from "./scim"; diff --git a/frontend/src/hooks/api/ldapConfig/index.tsx b/frontend/src/hooks/api/ldapConfig/index.tsx new file mode 100644 index 000000000..db04c294f --- /dev/null +++ b/frontend/src/hooks/api/ldapConfig/index.tsx @@ -0,0 +1,5 @@ +export { + useCreateLDAPConfig, + useGetLDAPConfig, + useUpdateLDAPConfig +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/ldapConfig/queries.tsx b/frontend/src/hooks/api/ldapConfig/queries.tsx new file mode 100644 index 000000000..5616ad35c --- /dev/null +++ b/frontend/src/hooks/api/ldapConfig/queries.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +const ldapConfigKeys = { + getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const, +} + +export const useGetLDAPConfig = (organizationId: string) => { + return useQuery({ + queryKey: ldapConfigKeys.getLDAPConfig(organizationId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/ldap/config?organizationId=${organizationId}` + ); + + return data; + }, + enabled: true + }); +} + +export const useCreateLDAPConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + }: { + organizationId: string; + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + caCert?: string; + }) => { + const { data } = await apiRequest.post( + "/api/v1/ldap/config", + { + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + } + ); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); + } + }); +}; + +export const useUpdateLDAPConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + }: { + organizationId: string; + isActive?: boolean; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; + caCert?: string; + }) => { + const { data } = await apiRequest.patch( + "/api/v1/ldap/config", + { + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + caCert + } + ); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index d8688b25f..16ea91718 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -7,9 +7,9 @@ import { getAuthToken, isLoggedIn } from "@app/reactQuery"; import { InitialStep, + LDAPStep, MFAStep, - SAMLSSOStep -} from "./components"; + SAMLSSOStep} from "./components"; import { navigateUserToOrg } from "./Login.utils"; export const Login = () => { @@ -72,7 +72,10 @@ export const Login = () => { return ( ); - + case 3: + return ( + + ); default: return
; } diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index bbef55751..bf3920c9d 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -180,7 +180,20 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: leftIcon={} className="mx-0 h-10 w-full" > - Continue with SAML SSO + Continue with SAML + +
+
+
diff --git a/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx b/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx new file mode 100644 index 000000000..454c05df2 --- /dev/null +++ b/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, Input } from "@app/components/v2"; +import { loginLDAPRedirect } from "@app/hooks/api/auth/queries"; + +type Props = { + setStep: (step: number) => void; +} + +export const LDAPStep = ({ + setStep +}: Props) => { + const { createNotification } = useNotificationContext(); + const [organizationSlug, setOrganizationSlug] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const { t } = useTranslation(); + + // const queryParams = new URLSearchParams(window.location.search); + + const handleSubmission = async (e:React.FormEvent) => { + e.preventDefault() + try { + const { nextUrl } = await loginLDAPRedirect({ + organizationSlug, + username, + password + }); + + createNotification({ + text: "Successfully logged in", + type: "success" + }); + + // redirects either to /login/sso or /signup/sso + window.open(nextUrl); + window.close(); + } catch (err) { + createNotification({ + text: "Login unsuccessful. Double-check your credentials and try again.", + type: "error" + }); + } + + // TODO: add callback port support + + // const callbackPort = queryParams.get("callback_port"); + // window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`); + // window.close(); + } + + return ( +
+

+ What's your LDAP Login? +

+
+
+
+ setOrganizationSlug(e.target.value)} + type="text" + placeholder="Enter your organization slug..." + isRequired + autoComplete="email" + id="email" + className="h-12" + /> +
+
+
+
+ setUsername(e.target.value)} + type="text" + placeholder="Enter your LDAP username..." + isRequired + autoComplete="email" + id="email" + className="h-12" + /> +
+
+
+
+ setPassword(e.target.value)} + type="password" + placeholder="Enter your LDAP password..." + isRequired + autoComplete="current-password" + id="current-password" + className="select:-webkit-autofill:focus h-10" + /> +
+
+
+ +
+
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/views/Login/components/LDAPStep/index.tsx b/frontend/src/views/Login/components/LDAPStep/index.tsx new file mode 100644 index 000000000..3420f802b --- /dev/null +++ b/frontend/src/views/Login/components/LDAPStep/index.tsx @@ -0,0 +1 @@ +export { LDAPStep } from "./LDAPStep"; \ No newline at end of file diff --git a/frontend/src/views/Login/components/index.tsx b/frontend/src/views/Login/components/index.tsx index e86cce83d..ccf603f79 100644 --- a/frontend/src/views/Login/components/index.tsx +++ b/frontend/src/views/Login/components/index.tsx @@ -1,4 +1,5 @@ export { InitialStep } from "./InitialStep"; +export { LDAPStep } from "./LDAPStep"; export { MFAStep } from "./MFAStep"; export { SAMLSSOStep } from "./SAMLSSOStep"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx new file mode 100644 index 000000000..bc3a91336 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx @@ -0,0 +1,228 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateLDAPConfig, + useGetLDAPConfig, + useUpdateLDAPConfig +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + url: yup.string().required("URL is required"), + bindDN: yup.string().required("Bind DN is required"), + bindPass: yup.string().required("Bind Pass is required"), + searchBase: yup.string().required("Search Base is required"), + caCert: yup.string() +}).required(); + +export type AddLDAPFormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["addLDAP"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addLDAP"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addLDAP"]>, state?: boolean) => void; +}; + +export const LDAPModal = ({ + popUp, + handlePopUpClose, + handlePopUpToggle +}: Props) => { + const { currentOrg } = useOrganization(); + const { createNotification } = useNotificationContext(); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig(); + const { data } = useGetLDAPConfig(currentOrg?.id?? ""); + + const { + control, + handleSubmit, + reset, + } = useForm({ + resolver: yupResolver(schema) + }); + + useEffect(() => { + if (data) { + reset({ + url: data?.url ?? "", + bindDN: data?.bindDN ?? "", + bindPass: data?.bindPass ?? "", + searchBase: data?.searchBase ?? "", + caCert: data?.caCert ?? "" + }); + } + }, [data]); + + const onSSOModalSubmit = async ({ + url, + bindDN, + bindPass, + searchBase, + caCert + }: AddLDAPFormData) => { + try { + if (!currentOrg) return; + + if (!data) { + await createMutateAsync({ + organizationId: currentOrg.id, + isActive: false, + url, + bindDN, + bindPass, + searchBase, + caCert + }); + } else { + await updateMutateAsync({ + organizationId: currentOrg.id, + isActive: false, + url, + bindDN, + bindPass, + searchBase, + caCert + }); + } + + handlePopUpClose("addLDAP"); + + createNotification({ + text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${!data ? "add" : "update"} LDAP configuration`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("addLDAP", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +