diff --git a/Makefile b/Makefile index 2bddeec0e..11143162e 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ push: up-dev: docker compose -f docker-compose.dev.yml up --build +up-dev-ldap: + docker compose -f docker-compose.dev.yml --profile ldap up --build + up-prod: docker-compose -f docker-compose.prod.yml up --build diff --git a/backend/package-lock.json b/backend/package-lock.json index a829cf346..8a8e9d13d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -52,6 +52,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", @@ -3973,6 +3974,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", @@ -5120,6 +5129,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", @@ -5286,6 +5311,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", @@ -5331,6 +5367,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", @@ -5815,6 +5856,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", @@ -6885,6 +6931,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", @@ -8623,6 +8677,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", @@ -9755,6 +9860,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", @@ -10309,6 +10426,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", @@ -12759,6 +12884,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 ad44dc2da..954f1181d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -113,6 +113,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..2e8b2611d 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, @@ -161,6 +164,9 @@ import { TUserActions, TUserActionsInsert, TUserActionsUpdate, + TUserAliases, + TUserAliasesInsert, + TUserAliasesUpdate, TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, @@ -175,6 +181,7 @@ import { declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; + [TableName.UserAliases]: Knex.CompositeTableType; [TableName.UserEncryptionKey]: Knex.CompositeTableType< TUserEncryptionKeys, TUserEncryptionKeysInsert, @@ -318,6 +325,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/20240311210135_ldap-config.ts b/backend/src/db/migrations/20240311210135_ldap-config.ts new file mode 100644 index 000000000..93ac2c7ac --- /dev/null +++ b/backend/src/db/migrations/20240311210135_ldap-config.ts @@ -0,0 +1,68 @@ +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).onDelete("CASCADE"); + t.boolean("isActive").notNullable(); + t.string("url").notNullable(); + t.string("encryptedBindDN").notNullable(); + t.string("bindDNIV").notNullable(); + t.string("bindDNTag").notNullable(); + t.string("encryptedBindPass").notNullable(); + t.string("bindPassIV").notNullable(); + t.string("bindPassTag").notNullable(); + t.string("searchBase").notNullable(); + t.text("encryptedCACert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.LdapConfig); + + if (!(await knex.schema.hasTable(TableName.UserAliases))) { + await knex.schema.createTable(TableName.UserAliases, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.string("username").notNullable(); + t.string("aliasType").notNullable(); + t.string("externalId").notNullable(); + t.specificType("emails", "text[]"); + t.uuid("orgId").nullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.UserAliases); + + await knex.schema.alterTable(TableName.Users, (t) => { + t.string("username").unique(); + t.string("email").nullable().alter(); + t.dropUnique(["email"]); + }); + + await knex(TableName.Users).update("username", knex.ref("email")); + + await knex.schema.alterTable(TableName.Users, (t) => { + t.string("username").notNullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.LdapConfig); + await knex.schema.dropTableIfExists(TableName.UserAliases); + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("username"); + // 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..fb717d344 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"; @@ -52,6 +53,7 @@ export * from "./service-tokens"; export * from "./super-admin"; export * from "./trusted-ips"; export * from "./user-actions"; +export * from "./user-aliases"; export * from "./user-encryption-keys"; export * from "./users"; export * from "./webhooks"; diff --git a/backend/src/db/schemas/ldap-configs.ts b/backend/src/db/schemas/ldap-configs.ts new file mode 100644 index 000000000..e3c6c8c75 --- /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(), + bindDNIV: z.string(), + bindDNTag: z.string(), + encryptedBindPass: z.string(), + bindPassIV: z.string(), + bindPassTag: z.string(), + searchBase: z.string(), + encryptedCACert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TLdapConfigs = z.infer; +export type TLdapConfigsInsert = Omit, TImmutableDBKeys>; +export type TLdapConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index abfb67949..c85aad66a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export enum TableName { Users = "users", + UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", AuthTokenSession = "auth_token_sessions", @@ -50,6 +51,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/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts new file mode 100644 index 000000000..d8712fe75 --- /dev/null +++ b/backend/src/db/schemas/user-aliases.ts @@ -0,0 +1,24 @@ +// 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 UserAliasesSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + username: z.string(), + aliasType: z.string(), + externalId: z.string(), + emails: z.string().array().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserAliases = z.infer; +export type TUserAliasesInsert = Omit, TImmutableDBKeys>; +export type TUserAliasesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 8f9758b4f..86ee2fb74 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,8 @@ 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() }); export type TUsers = z.infer; diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts index 6406e44ab..5f4ea1b4f 100644 --- a/backend/src/db/seed-data.ts +++ b/backend/src/db/seed-data.ts @@ -21,6 +21,7 @@ export let userPublicKey: string | undefined; export const seedData1 = { id: "3dafd81d-4388-432b-a4c5-f735616868c1", + username: process.env.TEST_USER_USERNAME || "test@localhost.local", email: process.env.TEST_USER_EMAIL || "test@localhost.local", password: process.env.TEST_USER_PASSWORD || "testInfisical@1", organization: { diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 5a5ff5404..86cd2be34 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -22,6 +22,7 @@ export async function seed(knex: Knex): Promise { // eslint-disable-next-line // @ts-ignore id: seedData1.id, + username: seedData1.username, email: seedData1.email, superAdmin: true, firstName: "test", 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..97527e783 --- /dev/null +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -0,0 +1,192 @@ +/* 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 { IncomingMessage } from "node:http"; + +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()); + + const getLdapPassportOpts = (req: FastifyRequest, done: any) => { + const { organizationSlug } = req.body as { + organizationSlug: string; + }; + + process.nextTick(async () => { + try { + const { opts, ldapConfig } = await server.services.ldap.bootLdap(organizationSlug); + req.ldapConfig = ldapConfig; + done(null, opts); + } catch (err) { + done(err); + } + }); + }; + + passport.use( + new LdapStrategy( + getLdapPassportOpts as any, + // eslint-disable-next-line + async (req: IncomingMessage, user, cb) => { + try { + const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ + externalId: user.uidNumber, + username: user.uid, + firstName: user.givenName, + lastName: user.sn, + emails: user.mail ? [user.mail] : [], + relayState: ((req as unknown as FastifyRequest).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(err, 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.getLdapCfgWithPermissionCheck({ + 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/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 6ed079a61..fe387143e 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -99,14 +99,14 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { async (req, profile, cb) => { try { if (!profile) throw new BadRequestError({ message: "Missing profile" }); - const { firstName } = profile; const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved - if (!email || !firstName) { + if (!profile.email || !profile.firstName) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ + username: profile.nameID ?? email, email, firstName: profile.firstName as string, lastName: profile.lastName as string, diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 80eed5231..2a3772cd6 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -122,7 +122,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { emails: z.array( z.object({ primary: z.boolean(), - value: z.string().email(), + value: z.string(), type: z.string().trim() }) ), @@ -168,7 +168,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { emails: z.array( z.object({ primary: z.boolean(), - value: z.string().email(), + value: z.string(), type: z.string().trim() }) ), @@ -198,13 +198,15 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { familyName: z.string().trim(), givenName: z.string().trim() }), - // emails: z.array( // optional? - // z.object({ - // primary: z.boolean(), - // value: z.string().email(), - // type: z.string().trim() - // }) - // ), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), // displayName: z.string().trim(), active: z.boolean() }), @@ -231,8 +233,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; + const user = await req.server.services.scim.createScimUser({ - email: req.body.userName, + username: req.body.userName, + email: primaryEmail, firstName: req.body.name.givenName, lastName: req.body.name.familyName, orgId: req.permission.orgId as string 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..220c25002 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 | null; + 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..37e1fb6f8 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -0,0 +1,429 @@ +import { ForbiddenError } from "@casl/ability"; +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 { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-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"; + +type TLdapConfigServiceFactoryDep = { + ldapConfigDAL: TLdapConfigDALFactory; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgBotDAL: Pick; + userDAL: Pick; + userAliasDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TLdapConfigServiceFactory = ReturnType; + +export const ldapConfigServiceFactory = ({ + ldapConfigDAL, + orgDAL, + orgBotDAL, + userDAL, + userAliasDAL, + 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.Ldap); + + 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.Ldap); + + 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 getLdapCfg = async (filter: { orgId: string; isActive?: boolean }) => { + const ldapConfig = await ldapConfigDAL.findOne(filter); + 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 getLdapCfgWithPermissionCheck = async ({ actor, actorId, orgId, actorOrgId }: TOrgPermission) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + return getLdapCfg({ + orgId + }); + }; + + const bootLdap = async (organizationSlug: string) => { + const organization = await orgDAL.findOne({ slug: organizationSlug }); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + const ldapConfig = await getLdapCfg({ + orgId: organization.id, + isActive: true + }); + + const opts = { + server: { + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + searchBase: ldapConfig.searchBase, + searchFilter: "(uid={{username}})", + searchAttributes: ["uid", "uidNumber", "givenName", "sn", "mail"], + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }, + passReqToCallback: true + }; + + return { opts, ldapConfig }; + }; + + const ldapLogin = async ({ externalId, username, firstName, lastName, emails, orgId, relayState }: TLdapLoginDTO) => { + const appCfg = getConfig(); + let userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: AuthMethod.LDAP + }); + + const organization = await orgDAL.findOrgById(orgId); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + if (userAlias) { + await userDAL.transaction(async (tx) => { + const [orgMembership] = await orgDAL.findMembership({ userId: userAlias.userId }, { tx }); + if (!orgMembership) { + await orgDAL.createMembership( + { + userId: userAlias.userId, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Accepted + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + }); + } else { + userAlias = await userDAL.transaction(async (tx) => { + const uniqueUsername = await normalizeUsername(username, userDAL); + const newUser = await userDAL.create( + { + username: uniqueUsername, + email: emails[0], + firstName, + lastName, + authMethods: [AuthMethod.LDAP], + isGhost: false + }, + tx + ); + const newUserAlias = await userAliasDAL.create( + { + userId: newUser.id, + username, + aliasType: AuthMethod.LDAP, + externalId, + emails, + orgId + }, + tx + ); + + await orgDAL.createMembership( + { + userId: newUser.id, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }, + tx + ); + + return newUserAlias; + }); + } + + const user = await userDAL.findOne({ id: userAlias.userId }); + + const isUserCompleted = Boolean(user.isAccepted); + + const providerAuthToken = jwt.sign( + { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user.id, + username: user.username, + 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, + getLdapCfgWithPermissionCheck, + getLdapCfg, + // getLdapPassportOpts, + ldapLogin, + bootLdap + }; +}; 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..025ce7781 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -0,0 +1,30 @@ +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 = { + externalId: string; + username: string; + firstName: string; + lastName: string; + emails: string[]; + orgId: string; + relayState?: string; +}; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index e404aaace..8f52939c5 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -18,6 +18,8 @@ export const getDefaultOnPremFeatures = () => { auditLogs: false, auditLogsRetentionDays: 0, samlSSO: false, + scim: false, + ldap: false, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9b1d4c203..8dca96737 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: false, 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 663e9d879..49609e8c9 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -147,14 +147,14 @@ export const licenseServiceFactory = ({ } }; - 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..80f422380 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: false; status: null; trial_end: null; has_used_trial: true; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index e527f1a4a..30b601c2c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -17,6 +17,7 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Scim = "scim", + Ldap = "ldap", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -31,6 +32,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; @@ -76,6 +78,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); 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..d944803f8 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -5,6 +5,7 @@ import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, + TableName, TSamlConfigs, TSamlConfigsUpdate } from "@app/db/schemas"; @@ -31,7 +32,7 @@ import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } f type TSamlConfigServiceFactoryDep = { samlConfigDAL: TSamlConfigDALFactory; - userDAL: Pick; + userDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -69,7 +70,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 +123,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, @@ -300,16 +300,30 @@ export const samlConfigServiceFactory = ({ }; }; - const samlLogin = async ({ firstName, email, lastName, authProvider, orgId, relayState }: TSamlLoginDTO) => { + const samlLogin = async ({ + username, + email, + firstName, + lastName, + authProvider, + orgId, + relayState + }: TSamlLoginDTO) => { const appCfg = getConfig(); - let user = await userDAL.findUserByEmail(email); + let user = await userDAL.findOne({ username }); 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 }); + const [orgMembership] = await orgDAL.findMembership( + { + userId: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); if (!orgMembership) { await orgDAL.createMembership( { @@ -335,6 +349,7 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { const newUser = await userDAL.create( { + username, email, firstName, lastName, @@ -357,7 +372,7 @@ export const samlConfigServiceFactory = ({ { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, - email: user.email, + username: user.username, firstName, lastName, organizationName: organization.name, diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 9d13cb342..ec7c066fc 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -37,7 +37,8 @@ export type TGetSamlCfgDTO = }; export type TSamlLoginDTO = { - email: string; + username: string; + email?: string; firstName: string; lastName?: string; authProvider: string; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index b5f35c9ee..8b68870da 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -20,34 +20,38 @@ export const buildScimUserList = ({ export const buildScimUser = ({ userId, + username, + email, firstName, lastName, - email, active }: { userId: string; + username: string; + email?: string | null; firstName: string; lastName: string; - email: string; active: boolean; }): TScimUser => { - return { + const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], id: userId, - userName: email, + userName: username, displayName: `${firstName} ${lastName}`, name: { givenName: firstName, middleName: null, familyName: lastName }, - emails: [ - { - primary: true, - value: email, - type: "work" - } - ], + emails: email + ? [ + { + primary: true, + value: email, + type: "work" + } + ] + : [], active, groups: [], meta: { @@ -55,4 +59,6 @@ export const buildScimUser = ({ location: null } }; + + return scimUser; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 33b2f2c84..c542b2340 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -146,15 +146,16 @@ export const scimServiceFactory = ({ const users = await orgDAL.findMembership( { - orgId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId, ...parseFilter(filter) }, findOpts ); - const scimUsers = users.map(({ userId, firstName, lastName, email }) => + const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => buildScimUser({ userId: userId ?? "", + username, firstName: firstName ?? "", lastName: lastName ?? "", email, @@ -173,7 +174,7 @@ export const scimServiceFactory = ({ const [membership] = await orgDAL .findMembership({ userId, - orgId + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -196,14 +197,15 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username, + email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active: true }); }; - const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => { + const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { const org = await orgDAL.findById(orgId); if (!org) @@ -219,12 +221,18 @@ export const scimServiceFactory = ({ }); let user = await userDAL.findOne({ - email + username }); if (user) { await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx }); + const [orgMembership] = await orgDAL.findMembership( + { + userId: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); if (orgMembership) throw new ScimRequestError({ detail: "User already exists in the database", @@ -248,6 +256,7 @@ export const scimServiceFactory = ({ user = await userDAL.transaction(async (tx) => { const newUser = await userDAL.create( { + username, email, firstName, lastName, @@ -272,21 +281,25 @@ export const scimServiceFactory = ({ } const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.ScimUserProvisioned, - subjectLine: "Infisical organization invitation", - recipients: [email], - substitutions: { - organizationName: org.name, - callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}` - } - }); + + if (email) { + await smtpService.sendMail({ + template: SmtpTemplates.ScimUserProvisioned, + subjectLine: "Infisical organization invitation", + recipients: [email], + substitutions: { + organizationName: org.name, + callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}` + } + }); + } return buildScimUser({ userId: user.id, + username: user.username, firstName: user.firstName as string, lastName: user.lastName as string, - email: user.email, + email: user.email ?? "", active: true }); }; @@ -295,7 +308,7 @@ export const scimServiceFactory = ({ const [membership] = await orgDAL .findMembership({ userId, - orgId + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -342,9 +355,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username, + email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active }); }; @@ -353,7 +367,7 @@ export const scimServiceFactory = ({ const [membership] = await orgDAL .findMembership({ userId, - orgId + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -387,9 +401,10 @@ export const scimServiceFactory = ({ return buildScimUser({ userId: membership.userId as string, + username: membership.username, + email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, - email: membership.email, active }); }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 9751d591f..c99dec794 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -32,7 +32,8 @@ export type TGetScimUserDTO = { }; export type TCreateScimUserDTO = { - email: string; + username: string; + email?: string; firstName: string; lastName: string; orgId: string; 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 d8f4e647e..1b19fd7f5 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 @@ -64,7 +64,7 @@ export const secretScanningQueueFactory = ({ orgId: organizationId, role: OrgMembershipRole.Admin }); - return adminsOfWork.map((userObject) => userObject.email); + return adminsOfWork.filter((userObject) => userObject.email).map((userObject) => userObject.email as string); }; queueService.start(QueueName.SecretPushEventScan, async (job) => { @@ -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), 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), substitutions: { numberOfSecrets: findings.length } diff --git a/backend/src/server/lib/telemetry.ts b/backend/src/server/lib/telemetry.ts index 5169b0e97..9d04d0357 100644 --- a/backend/src/server/lib/telemetry.ts +++ b/backend/src/server/lib/telemetry.ts @@ -5,7 +5,7 @@ import { ActorType } from "@app/services/auth/auth-type"; // this is a unique id for sending posthog event export const getTelemetryDistinctId = (req: FastifyRequest) => { if (req.auth.actor === ActorType.USER) { - return req.auth.user.email; + return req.auth.user.username; } if (req.auth.actor === ActorType.IDENTITY) { return `identity-${req.auth.identityId}`; diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index de352a9bc..084b0cb54 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -44,6 +44,7 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { type: ActorType.USER, metadata: { email: req.auth.user.email, + username: req.auth.user.username, userId: req.permission.id } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 9394bad38..e7a0118d7 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"; @@ -102,6 +104,7 @@ import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry- import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; +import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; @@ -126,6 +129,7 @@ export const registerRoutes = async ( // db layers const userDAL = userDALFactory(db); + const userAliasDAL = userAliasDALFactory(db); const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); @@ -166,12 +170,13 @@ export const registerRoutes = async ( const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); - const scimDAL = scimDALFactory(db); const telemetryDAL = telemetryDALFactory(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); @@ -235,6 +240,16 @@ export const registerRoutes = async ( smtpService }); + const ldapService = ldapConfigServiceFactory({ + ldapConfigDAL, + orgDAL, + orgBotDAL, + userDAL, + userAliasDAL, + permissionService, + licenseService + }); + const telemetryService = telemetryServiceFactory({ keyStore, licenseService @@ -561,6 +576,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 38f948851..1a048e945 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -92,9 +92,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.AdminInit, - distinctId: user.user.email, + distinctId: user.user.username ?? "", properties: { - email: user.user.email, + username: user.user.username, + email: user.user.email ?? "", lastName: user.user.lastName || "", firstName: user.user.firstName || "" } diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index fcc1a86e0..d31682d88 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -58,6 +58,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { users: OrgMembershipsSchema.merge( z.object({ user: UsersSchema.pick({ + username: true, email: true, firstName: true, lastName: true, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 026b16f1f..ef928a4dc 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -63,6 +63,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { users: ProjectMembershipsSchema.merge( z.object({ user: UsersSchema.pick({ + username: true, email: true, firstName: true, lastName: true, diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 01ef7973a..ba5ca3c21 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -24,6 +24,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { users: OrgMembershipsSchema.merge( z.object({ user: UsersSchema.pick({ + username: true, email: true, firstName: true, lastName: true, @@ -179,11 +180,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/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index 417fcb44c..f63770346 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -14,7 +14,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe("The ID of the project.") }), body: z.object({ - emails: z.string().email().array().describe("Emails of the users to add to the project.") + emails: z.string().email().array().default([]).describe("Emails of the users to add to the project."), + usernames: z.string().array().default([]).describe("Usernames of the users to add to the project.") }), response: { 200: z.object({ @@ -28,7 +29,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: req.params.projectId, actorId: req.permission.id, actor: req.permission.type, - emails: req.body.emails + emails: req.body.emails, + usernames: req.body.usernames }); await server.services.auditLog.createAuditLog({ @@ -57,7 +59,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }), body: z.object({ - emails: z.string().email().array().describe("Emails of the users to remove from the project.") + emails: z.string().email().array().default([]).describe("Emails of the users to remove from the project."), + usernames: z.string().array().default([]).describe("Usernames of the users to remove from the project.") }), response: { 200: z.object({ @@ -72,7 +75,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.projectId, - emails: req.body.emails + emails: req.body.emails, + usernames: req.body.usernames }); for (const membership of memberships) { diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 0cda8e5ff..240aa21b1 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -12,7 +12,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), providerAuthToken: z.string().trim().optional(), clientPublicKey: z.string().trim() }), @@ -42,7 +42,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), providerAuthToken: z.string().trim().optional(), clientProof: z.string().trim() }), diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 24387d2f5..17787be84 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -88,7 +88,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), firstName: z.string().trim(), lastName: z.string().trim().optional(), protectedKey: z.string().trim(), @@ -131,13 +131,16 @@ 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.username ?? "", properties: { - email: user.email, + username: user.username, + email: user.email ?? "", attributionSource: req.body.attributionSource } }); @@ -194,13 +197,16 @@ 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.username ?? "", properties: { - email: user.email, + username: user.username, + 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..0b78ab438 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -5,13 +5,14 @@ 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, username?: string) => { if (!providerToken) throw new UnauthorizedError(); const appCfg = getConfig(); const decodedToken = jwt.verify(providerToken, appCfg.AUTH_SECRET) as AuthModeProviderJwtTokenPayload; if (decodedToken.authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new UnauthorizedError(); - if (decodedToken.email !== email) throw new Error("Invalid auth credentials"); + + if (decodedToken.username !== username) throw new Error("Invalid auth credentials"); if (decodedToken.organizationId) { return { orgId: decodedToken.organizationId }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 11ce57735..786e69d73 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -39,17 +39,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 + } + }); + } } }; @@ -131,7 +133,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByEmail(email); + const userEnc = await userDAL.findUserEncKeyByUsername({ + username: email + }); if (!userEnc || (userEnc && !userEnc.isAccepted)) { throw new Error("Failed to find user"); } @@ -158,7 +162,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: ip, userAgent }: TLoginClientProofDTO) => { - const userEnc = await userDAL.findUserEncKeyByEmail(email); + const userEnc = await userDAL.findUserEncKeyByUsername({ + username: email + }); if (!userEnc) throw new Error("Failed to find user"); const cfg = getConfig(); @@ -187,7 +193,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: clientPublicKey: null }); // send multi factor auth token if they it enabled - if (userEnc.isMfaEnabled) { + if (userEnc.isMfaEnabled && userEnc.email) { const mfaToken = jwt.sign( { authTokenType: AuthTokenType.MFA_TOKEN, @@ -227,7 +233,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 @@ -263,7 +269,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: * OAuth2 login for google,github, and other oauth2 provider * */ const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { - let user = await userDAL.findUserByEmail(email); + let user = await userDAL.findUserByUsername(email); const serverCfg = await getServerCfg(); const appCfg = getConfig(); @@ -282,7 +288,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: }); } - user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod], isGhost: false }); + user = await userDAL.create({ + username: email, + email, + firstName, + lastName, + authMethods: [authMethod], + isGhost: false + }); } const isLinkingRequired = !user?.authMethods?.includes(authMethod); const isUserCompleted = user.isAccepted; @@ -290,7 +303,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, - email: user.email, + username: user.username, firstName: user.firstName, lastName: user.lastName, authMethod, diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index ff07d422f..42b8c2c39 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -99,7 +99,7 @@ export const authPaswordServiceFactory = ({ * Email password reset flow via email. Step 1 send email */ const sendPasswordResetEmail = async (email: string) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) return; @@ -126,7 +126,7 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) { throw new Error("Failed email verification for pass reset"); diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 61fcfe3ce..39bfec8b1 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -44,13 +44,13 @@ export const authSignupServiceFactory = ({ throw new Error("Provided a disposable email"); } - let user = await userDAL.findUserByEmail(email); + let user = await userDAL.findUserByUsername(email); if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email, isGhost: false }); + user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); } if (!user) throw new Error("Failed to create user"); @@ -70,7 +70,7 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); @@ -115,14 +115,14 @@ export const authSignupServiceFactory = ({ userAgent, authorization }: TCompleteAccountSignupDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findOne({ username: email }); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } let organizationId; if (providerAuthToken) { - const { orgId } = validateProviderAuthToken(providerAuthToken, user.email); + const { orgId } = validateProviderAuthToken(providerAuthToken, user.username); 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 ?? user.username, + orgName: organizationName + }); } const updatedMembersips = await orgDAL.updateMembership( @@ -215,7 +219,7 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, authorization }: TCompleteAccountInviteDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index b051952e7..57c86158f 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 { @@ -61,7 +62,7 @@ export type AuthModeRefreshJwtTokenPayload = { export type AuthModeProviderJwtTokenPayload = { authTokenType: AuthTokenType.PROVIDER_TOKEN; - email: string; + username: string; organizationId?: string; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 05c77e746..3b1daa827 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -57,7 +57,7 @@ export const orgDALFactory = (db: TDbClient) => { const findAllOrgMembers = async (orgId: string) => { try { const members = await db(TableName.OrgMembership) - .where({ orgId }) + .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin( TableName.UserEncryptionKey, @@ -72,25 +72,27 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("roleId").withSchema(TableName.OrgMembership), db.ref("status").withSchema(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("id").withSchema(TableName.Users).as("userId"), db.ref("publicKey").withSchema(TableName.UserEncryptionKey) ) .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER - return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ + + return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, - user: { email, firstName, lastName, id: userId, publicKey } + user: { email, username, firstName, lastName, id: userId, publicKey } })); } catch (error) { throw new DatabaseError({ error, name: "Find all org members" }); } }; - const findOrgMembersByEmail = async (orgId: string, emails: string[]) => { + const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => { try { const members = await db(TableName.OrgMembership) - .where({ orgId }) + .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin( TableName.UserEncryptionKey, @@ -104,6 +106,7 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("role").withSchema(TableName.OrgMembership), db.ref("roleId").withSchema(TableName.OrgMembership), db.ref("status").withSchema(TableName.OrgMembership), + db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), @@ -111,7 +114,7 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("publicKey").withSchema(TableName.UserEncryptionKey) ) .where({ isGhost: false }) - .whereIn("email", emails); + .whereIn("username", usernames); return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, user: { email, firstName, lastName, id: userId, publicKey } @@ -243,10 +246,13 @@ 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) - ); + ) + .where({ isGhost: false }); + if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -266,7 +272,7 @@ export const orgDALFactory = (db: TDbClient) => { findOrgById, findAllOrgsByUserId, ghostUserExists, - findOrgMembersByEmail, + findOrgMembersByUsername, findOrgGhostUser, create, updateById, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 28cf4b382..db6d9654d 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -103,11 +103,11 @@ export const orgServiceFactory = ({ return members; }; - const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => { + const findOrgMembersByUsername = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); - const members = await orgDAL.findOrgMembersByEmail(orgId, emails); + const members = await orgDAL.findOrgMembersByUsername(orgId, emails); return members; }; @@ -145,6 +145,7 @@ export const orgServiceFactory = ({ { isGhost: true, authMethods: [AuthMethod.EMAIL], + username: email, email, isAccepted: true }, @@ -239,7 +240,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 { @@ -367,7 +376,7 @@ export const orgServiceFactory = ({ }); } const invitee = await orgDAL.transaction(async (tx) => { - const inviteeUser = await userDAL.findUserByEmail(inviteeEmail, tx); + const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); if (inviteeUser) { // if user already exist means its already part of infisical // Thus the signup flow is not needed anymore @@ -403,6 +412,7 @@ export const orgServiceFactory = ({ // not invited before const user = await userDAL.create( { + username: inviteeEmail, email: inviteeEmail, isAccepted: false, authMethods: [AuthMethod.EMAIL], @@ -437,7 +447,7 @@ export const orgServiceFactory = ({ recipients: [inviteeEmail], substitutions: { inviterFirstName: user.firstName, - inviterEmail: user.email, + inviterUsername: user.username, organizationName: org?.name, email: inviteeEmail, organizationId: org?.id.toString(), @@ -457,7 +467,7 @@ export const orgServiceFactory = ({ * magic link and issue a temporary signup token for user to complete setting up their account */ const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user) { throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); } @@ -595,7 +605,7 @@ export const orgServiceFactory = ({ inviteUserToOrganization, verifyUserToOrg, updateOrg, - findOrgMembersByEmail, + findOrgMembersByUsername, createOrganization, deleteOrganizationById, deleteOrgMembership, diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 77ed867c4..291eb1045 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -25,6 +25,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("role").withSchema(TableName.ProjectMembership), db.ref("roleId").withSchema(TableName.ProjectMembership), db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), @@ -32,9 +33,9 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.Users).as("userId") ) .where({ isGhost: false }); - return members.map(({ email, firstName, lastName, publicKey, isGhost, ...data }) => ({ + return members.map(({ username, email, firstName, lastName, publicKey, isGhost, ...data }) => ({ ...data, - user: { email, firstName, lastName, id: data.userId, publicKey, isGhost } + user: { username, email, firstName, lastName, id: data.userId, publicKey, isGhost } })); } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); @@ -56,7 +57,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; - const findMembershipsByEmail = async (projectId: string, emails: string[]) => { + const findMembershipsByUsername = async (projectId: string, usernames: string[]) => { try { const members = await db(TableName.ProjectMembership) .where({ projectId }) @@ -69,13 +70,13 @@ export const projectMembershipDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.ProjectMembership), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("email").withSchema(TableName.Users) + db.ref("username").withSchema(TableName.Users) ) - .whereIn("email", emails) + .whereIn("username", usernames) .where({ isGhost: false }); - return members.map(({ userId, email, ...data }) => ({ + return members.map(({ userId, username, ...data }) => ({ ...data, - user: { id: userId, email } + user: { id: userId, username } })); } catch (error) { throw new DatabaseError({ error, name: "Find members by email" }); @@ -100,7 +101,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, - findMembershipsByEmail, + findMembershipsByUsername, findProjectMembershipsByUserId }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 014e9fb1b..70d1c8023 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -45,7 +45,7 @@ type TProjectMembershipServiceFactoryDep = { projectMembershipDAL: TProjectMembershipDALFactory; userDAL: Pick; projectRoleDAL: Pick; - orgDAL: Pick; + orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; @@ -134,8 +134,8 @@ export const projectMembershipServiceFactory = ({ const appCfg = getConfig(); await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: invitees.map((i) => i.email), + subjectLine: "Infisical project invitation", + recipients: invitees.filter((i) => i.email).map((i) => i.email as string), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` @@ -206,8 +206,8 @@ export const projectMembershipServiceFactory = ({ const appCfg = getConfig(); await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ email }) => email).filter(Boolean), + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.email).map((i) => i.email as string), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` @@ -222,6 +222,7 @@ export const projectMembershipServiceFactory = ({ actorId, actor, emails, + usernames, sendEmails = true }: TAddUsersToWorkspaceNonE2EEDTO) => { const project = await projectDAL.findById(projectId); @@ -234,9 +235,14 @@ export const projectMembershipServiceFactory = ({ const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - const orgMembers = await orgDAL.findOrgMembersByEmail(project.orgId, emails); + const usernamesAndEmails = [...emails, ...usernames]; - if (orgMembers.length !== emails.length) throw new BadRequestError({ message: "Some users are not part of org" }); + const orgMembers = await orgDAL.findOrgMembersByUsername(project.orgId, [ + ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) + ]); + + if (orgMembers.length !== usernamesAndEmails.length) + throw new BadRequestError({ message: "Some users are not part of org" }); if (!orgMembers.length) return []; @@ -315,16 +321,21 @@ export const projectMembershipServiceFactory = ({ }); if (sendEmails) { + const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); + const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ user }) => user.email).filter(Boolean), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); + + if (recipients.length) { + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } } return members; }; @@ -407,7 +418,8 @@ export const projectMembershipServiceFactory = ({ actor, actorOrgId, projectId, - emails + emails, + usernames }: TDeleteProjectMembershipsDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); @@ -421,9 +433,13 @@ export const projectMembershipServiceFactory = ({ }); } - const projectMembers = await projectMembershipDAL.findMembershipsByEmail(projectId, emails); + const usernamesAndEmails = [...emails, ...usernames]; - if (projectMembers.length !== emails.length) { + const projectMembers = await projectMembershipDAL.findMembershipsByUsername(projectId, [ + ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) + ]); + + if (projectMembers.length !== usernamesAndEmails.length) { throw new BadRequestError({ message: "Some users are not part of project", name: "Delete project membership" diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 634b51d77..abe4d2f72 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -18,6 +18,7 @@ export type TDeleteProjectMembershipOldDTO = { export type TDeleteProjectMembershipsDTO = { emails: string[]; + usernames: string[]; } & TProjectPermission; export type TAddUsersToWorkspaceDTO = { @@ -33,4 +34,5 @@ export type TAddUsersToWorkspaceDTO = { export type TAddUsersToWorkspaceNonE2EEDTO = { sendEmails?: boolean; emails: string[]; + usernames: string[]; } & TProjectPermission; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index eddf780e2..2e5ea7f93 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -391,7 +391,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/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index b281786f4..024fca132 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -8,7 +8,7 @@

Join your organization on Infisical

-

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}

+

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}

Join now

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 2a6a2c75e..d76ad3ec3 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -97,6 +97,7 @@ export const superAdminServiceFactory = ({ { firstName, lastName, + username: email, email, superAdmin: true, isGhost: false, @@ -126,11 +127,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/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 947403783..b168fe5d7 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -37,6 +37,7 @@ export type TSecretModifiedEvent = { export type TAdminInitEvent = { event: PostHogEventTypes.AdminInit; properties: { + username: string; email: string; firstName: string; lastName: string; @@ -46,6 +47,7 @@ export type TAdminInitEvent = { export type TUserSignedUpEvent = { event: PostHogEventTypes.UserSignedUp; properties: { + username: string; email: string; attributionSource?: string; }; diff --git a/backend/src/services/user-alias/user-alias-dal.ts b/backend/src/services/user-alias/user-alias-dal.ts new file mode 100644 index 000000000..366eadce0 --- /dev/null +++ b/backend/src/services/user-alias/user-alias-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TUserAliasDALFactory = ReturnType; + +export const userAliasDALFactory = (db: TDbClient) => { + const userAliasOrm = ormify(db, TableName.UserAliases); + + return { + ...userAliasOrm + }; +}; diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 2c89b9c4d..425e8215c 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -16,14 +16,17 @@ export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); - const findUserByEmail = async (email: string, tx?: Knex) => userOrm.findOne({ email }, tx); + const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); // USER ENCRYPTION FUNCTIONS // ------------------------- - const findUserEncKeyByEmail = async (email: string) => { + const findUserEncKeyByUsername = async ({ username }: { username: string }) => { try { return await db(TableName.Users) - .where({ email, isGhost: false }) + .where({ + username, + isGhost: false + }) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); } catch (error) { @@ -118,8 +121,8 @@ export const userDALFactory = (db: TDbClient) => { return { ...userOrm, - findUserByEmail, - findUserEncKeyByEmail, + findUserByUsername, + findUserEncKeyByUsername, findUserEncKeyByUserId, updateUserEncryptionByUserId, findUserByProjectMembershipId, diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts new file mode 100644 index 000000000..23789df1b --- /dev/null +++ b/backend/src/services/user/user-fns.ts @@ -0,0 +1,21 @@ +import slugify from "@sindresorhus/slugify"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +export const normalizeUsername = async (username: string, userDAL: Pick) => { + let attempt = slugify(username); + + let user = await userDAL.findOne({ username: attempt }); + if (!user) return attempt; + + while (true) { + attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); + // eslint-disable-next-line no-await-in-loop + user = await userDAL.findOne({ username: attempt }); + + if (!user) { + return attempt; + } + } +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index eebfd958f..c85e40eb3 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"] : [] @@ -30,6 +34,12 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { const user = await userDAL.findById(userId); if (!user) throw new BadRequestError({ name: "Update auth methods" }); + if (user.authMethods?.includes(AuthMethod.LDAP)) + throw new BadRequestError({ message: "LDAP auth method cannot be updated", name: "Update auth methods" }); + + if (authMethods.includes(AuthMethod.LDAP)) + throw new BadRequestError({ message: "LDAP auth method cannot be updated", name: "Update auth methods" }); + const updatedUser = await userDAL.updateById(userId, { authMethods }); return updatedUser; }; diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 9c3590e89..1a34219eb 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -59,7 +59,8 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) + if err != nil { util.HandleError(err, "Unable to parse flag") } diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go index 12e2206fa..290f32c38 100644 --- a/cli/packages/cmd/folder.go +++ b/cli/packages/cmd/folder.go @@ -36,7 +36,8 @@ var getCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) + if err != nil { util.HandleError(err, "Unable to parse flag") } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 48eaffea9..d008af1ec 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -62,7 +62,8 @@ var runCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) + if err != nil { util.HandleError(err, "Unable to parse flag") } diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 667f39b95..ffe82179c 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -7,7 +7,6 @@ import ( "crypto/sha256" "encoding/base64" "fmt" - "os" "regexp" "sort" "strings" @@ -39,10 +38,10 @@ var secretsCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) - if infisicalToken == "" { - infisicalToken = os.Getenv(util.INFISICAL_TOKEN_NAME) + if err != nil { + util.HandleError(err, "Unable to parse flag") } if err != nil { @@ -399,7 +398,8 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) + if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -466,7 +466,8 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - infisicalToken, err := cmd.Flags().GetString("token") + infisicalToken, err := util.GetInfisicalServiceToken(cmd) + if err != nil { util.HandleError(err, "Unable to parse flag") } diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 043db468e..413afb2b8 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/Infisical/infisical-merge/packages/models" + "github.com/spf13/cobra" ) type DecodedSymmetricEncryptionDetails = struct { @@ -63,6 +64,20 @@ func IsSecretTypeValid(s string) bool { return false } +func GetInfisicalServiceToken(cmd *cobra.Command) (serviceToken string, err error) { + infisicalToken, err := cmd.Flags().GetString("token") + + if infisicalToken == "" { + infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) + } + + if err != nil { + return "", err + } + + return infisicalToken, nil +} + // Checks if the passed in email already exists in the users slice func ConfigContainsEmail(users []models.LoggedInUser, email string) bool { for _, value := range users { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6c3a89a74..f07aeb190 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -126,9 +126,38 @@ services: ports: - 1025:1025 # SMTP server - 8025:8025 # Web UI + + openldap: # note: more advanced configuration is available + image: osixia/openldap:1.5.0 + restart: always + environment: + LDAP_ORGANISATION: Acme + LDAP_DOMAIN: acme.com + LDAP_ADMIN_PASSWORD: admin + ports: + - 389:389 + - 636:636 + volumes: + - ldap_data:/var/lib/ldap + - ldap_config:/etc/ldap/slapd.d + profiles: [ldap] + + phpldapadmin: # username: cn=admin,dc=acme,dc=com, pass is admin + image: osixia/phpldapadmin:latest + restart: always + environment: + - PHPLDAPADMIN_LDAP_HOSTS=openldap + - PHPLDAPADMIN_HTTPS=false + ports: + - 6433:80 + depends_on: + - openldap + profiles: [ldap] volumes: postgres-data: driver: local redis_data: driver: local + ldap_data: + ldap_config: diff --git a/docs/documentation/platform/ldap.mdx b/docs/documentation/platform/ldap.mdx index 63b2a814b..01237e1c9 100644 --- a/docs/documentation/platform/ldap.mdx +++ b/docs/documentation/platform/ldap.mdx @@ -10,7 +10,7 @@ description: "Log in to Infisical with LDAP" then you should contact team@infisical.com to purchase an enterprise license to use it. -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); this includes support for Active Directory. +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). diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index b4f772ea3..137538cff 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -5,7 +5,6 @@ description: "Log in to Infisical with LDAP" LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. @@ -30,7 +29,6 @@ You can configure your organization in Infisical to have members authenticate wi Enabling LDAP allows members in your organization to log into Infisical via LDAP. - ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) \ No newline at end of file diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index b52a75880..5e7e42ca8 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -5,7 +5,6 @@ description: "Configure JumpCloud LDAP for Logging into Infisical" LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. @@ -47,7 +46,6 @@ description: "Configure JumpCloud LDAP for Logging into Infisical" Enabling LDAP allows members in your organization to log into Infisical via LDAP. - ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 25303fed6..30622e6e8 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -4,7 +4,7 @@ description: "Configure Azure SAML for Infisical SSO" --- - Azure SAML SSO feature is a paid feature. + Azure SAML 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 team@infisical.com to purchase an enterprise license to use it. diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index d25ec0b6d..8b64c8643 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -4,7 +4,7 @@ description: "Configure JumpCloud SAML for Infisical SSO" --- - JumpCloud SAML SSO feature is a paid feature. + JumpCloud SAML 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 team@infisical.com to purchase an enterprise license to use it. diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index bac8efb68..f18203963 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -4,7 +4,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" --- - Okta SAML SSO feature is a paid feature. + Okta SAML 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 team@infisical.com to purchase an enterprise license to use it. diff --git a/docs/infisical-agent/overview.mdx b/docs/infisical-agent/overview.mdx index f62194ad8..9265d9bfe 100644 --- a/docs/infisical-agent/overview.mdx +++ b/docs/infisical-agent/overview.mdx @@ -1,5 +1,6 @@ --- title: "Overview" +description: "This page describes how to manage secrets using Infisical Agent." --- Infisical Agent is a client daemon that simplifies the adoption of Infisical by providing a more scalable and user-friendly approach for applications to interact with Infisical. diff --git a/docs/mint.json b/docs/mint.json index cb6ce08ac..5da117548 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -158,6 +158,14 @@ "documentation/platform/ldap/general" ] }, + { + "group": "LDAP", + "pages": [ + "documentation/platform/ldap/overview", + "documentation/platform/ldap/jumpcloud", + "documentation/platform/ldap/general" + ] + }, { "group": "SCIM", "pages": [ diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx deleted file mode 100644 index 8ad06375b..000000000 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons"; - -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { Select, SelectItem } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; -import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission"; -import { - useDeleteUserFromWorkspace, - useGetUserWsKey, - useUpdateUserWorkspaceRole, - useUploadWsKey -} from "@app/hooks/api"; - -import { decryptAssymmetric, encryptAssymmetric } from "../../utilities/cryptography/crypto"; -import guidGenerator from "../../utilities/randomId"; -import Button from "../buttons/Button"; -import UpgradePlanModal from "../dialog/UpgradePlan"; - -// const roles = ['admin', 'user']; -// TODO: Set type for this -type Props = { - userData: any[]; - changeData: (users: any[]) => void; - myUser: string; - filter: string; - isUserListLoading: boolean; -}; - -type EnvironmentProps = { - name: string; - slug: string; -}; - -/** - * This is the component that shows the users of a certin project - * #TODO: add the possibility of choosing and doing operations on multiple users. - * @param {*} props - * @returns - */ -const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => { - const { currentWorkspace } = useWorkspace(); - const { subscription } = useSubscription(); - const { data: wsKey } = useGetUserWsKey(currentWorkspace?.id ?? ""); - - const { mutateAsync: deleteUserFromWorkspaceMutateAsync } = useDeleteUserFromWorkspace(); - const { mutateAsync: uploadWsKeyMutateAsync } = useUploadWsKey(); - const { mutateAsync: updateUserWorkspaceRoleMutateAsync } = useUpdateUserWorkspaceRole(); - // const [roleSelected, setRoleSelected] = useState( - // Array(userData?.length).fill(userData.map((user) => user.role)) - // ); - const router = useRouter(); - const [myRole, setMyRole] = useState("member"); - const [workspaceEnvs, setWorkspaceEnvs] = useState([]); - const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); - const { createNotification } = useNotificationContext(); - - const workspaceId = router.query.id as string; - // Delete the row in the table (e.g. a user) - // #TODO: Add a pop-up that warns you that the user is going to be deleted. - const handleDelete = async (email: string) => { - await deleteUserFromWorkspaceMutateAsync({ emails: [email], workspaceId }); - }; - - const handleRoleUpdate = async (index: number, e: string) => { - await updateUserWorkspaceRoleMutateAsync({ - workspaceId, - membershipId: userData[index].membershipId, - role: e.toLowerCase() - }); - createNotification({ - text: "Successfully changed user role.", - type: "success" - }); - }; - - const handlePermissionUpdate = ( - index: number, - val: string, - membershipId: string, - slug: string - ) => { - let denials: { ability: string; environmentSlug: string }[]; - if (val === "Read Only") { - denials = [ - { - ability: "write", - environmentSlug: slug - } - ]; - } else if (val === "No Access") { - denials = [ - { - ability: "write", - environmentSlug: slug - }, - { - ability: "read", - environmentSlug: slug - } - ]; - } else if (val === "Add Only") { - denials = [ - { - ability: "read", - environmentSlug: slug - } - ]; - } else { - denials = []; - } - - if (subscription?.rbac === false) { - setIsUpgradeModalOpen(true); - } else { - const allDenials = userData[index].deniedPermissions - .filter( - (perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug - ) - .concat(denials); - updateUserProjectPermission({ membershipId, denials: allDenials }); - changeData([ - ...userData.slice(0, index), - ...[ - { - key: userData[index].key, - firstName: userData[index].firstName, - lastName: userData[index].lastName, - email: userData[index].email, - role: userData[index].role, - status: userData[index].status, - userId: userData[index].userId, - membershipId: userData[index].membershipId, - publicKey: userData[index].publicKey, - deniedPermissions: allDenials - } - ], - ...userData.slice(index + 1, userData?.length) - ]); - createNotification({ - text: "Successfully changed user permissions.", - type: "success" - }); - } - }; - - useEffect(() => { - setMyRole(userData.filter((user) => user.email === myUser)[0]?.role); - (async () => { - if (currentWorkspace) { - setWorkspaceEnvs(currentWorkspace.environments); - } - })(); - }, [userData, myUser, currentWorkspace]); - - const grantAccess = async (id: string, publicKey: string) => { - if (wsKey) { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - - // assymmetrically decrypt symmetric key with local private key - const key = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKeyMutateAsync({ - workspaceId, - userId: id, - encryptedKey: ciphertext, - nonce - }); - router.reload(); - } - }; - - const closeUpgradeModal = () => { - setIsUpgradeModalOpen(false); - }; - - return ( -
-
- {subscription && ( - - )} - - - - - - - {workspaceEnvs.map((env) => ( - - ))} - - - - {!isUserListLoading && - userData?.filter( - (user) => - user.firstName?.toLowerCase().includes(filter) || - user.lastName?.toLowerCase().includes(filter) || - user.email?.toLowerCase().includes(filter) - ).length > 0 && - userData - ?.filter( - (user) => - user.firstName?.toLowerCase().includes(filter) || - user.lastName?.toLowerCase().includes(filter) || - user.email?.toLowerCase().includes(filter) - ) - .map((row, index) => ( - - - - - {workspaceEnvs.map((env) => ( - - ))} - - - ))} - {isUserListLoading && ( - <> - - - - )} - -
NAMEEMAILROLE - - {env.slug.toUpperCase()} -
-
- {/* PERMISSION */} -
-
- {row.firstName} {row.lastName} - - {row.email} - -
- - {row.status === "completed" && myUser !== row.email && ( -
-
- )} -
-
- - - {myUser !== row.email && - // row.role !== "admin" && - myRole !== "member" ? ( -
-
- ) : ( -
- )} -
-
- ); -}; - -export default ProjectUsersTable; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 8a7393a17..8127cb772 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -15,6 +15,7 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Scim = "scim", Sso = "sso", + Ldap = "ldap", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -29,6 +30,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; 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/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 5680da02d..97b287084 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -19,6 +19,7 @@ export type SubscriptionPlan = { environmentLimit: number; samlSSO: boolean; scim: boolean; + ldap: boolean; status: | "incomplete" | "incomplete_expired" diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 7d982cea1..a5c77b15f 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -50,9 +50,9 @@ export const useAddUserToWsNonE2EE = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, AddUserToWsDTONonE2EE>({ - mutationFn: async ({ projectId, emails }) => { + mutationFn: async ({ projectId, usernames }) => { const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, { - emails + usernames }); return data; }, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 366ee93bf..65a603698 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -7,13 +7,15 @@ export enum AuthMethod { GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + LDAP = "ldap" } export type User = { createdAt: Date; updatedAt: Date; - email: string; + username: string; + email?: string; superAdmin: boolean; firstName?: string; lastName?: string; @@ -38,7 +40,8 @@ export type UserEnc = { export type OrgUser = { id: string; user: { - email: string; + username: string; + email?: string; firstName: string; lastName: string; id: string; @@ -75,7 +78,7 @@ export type AddUserToWsDTOE2EE = { export type AddUserToWsDTONonE2EE = { projectId: string; - emails: string[]; + usernames: string[]; }; export type UpdateOrgUserRoleDTO = { diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index d970cab54..2d28be501 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -323,11 +323,11 @@ export const useDeleteUserFromWorkspace = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ emails, workspaceId }: { workspaceId: string; emails: string[] }) => { + mutationFn: async ({ usernames, workspaceId }: { workspaceId: string; usernames: string[] }) => { const { data: { deletedMembership } } = await apiRequest.delete(`/api/v2/workspace/${workspaceId}/memberships`, { - data: { emails } + data: { usernames } }); return deletedMembership; }, diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 0e38c9a13..539aedfd9 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -91,7 +91,7 @@ export const AdminLayout = ({ children }: LayoutProps) => { console.error(error); } }; - + return ( <>
@@ -121,7 +121,7 @@ export const AdminLayout = ({ children }: LayoutProps) => {
-
{user?.email}
+
{user?.username}
Personal Settings diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 8957f6d91..8df1d15d2 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -231,11 +231,10 @@ export const AppLayout = ({ children }: LayoutProps) => { if (addMembers) { const orgUsers = await fetchOrgUsers(currentOrg.id); - await addUsersToProject.mutateAsync({ - emails: orgUsers - .map((member) => member.user.email) - .filter((email) => email !== user.email), + usernames: orgUsers + .map((member) => member.user.username) + .filter((username) => username !== user.username), projectId: newProjectId }); } @@ -292,7 +291,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
-
{user?.email}
+
{user?.username}
{orgs?.map((org) => { return ( @@ -367,7 +366,7 @@ export const AppLayout = ({ children }: LayoutProps) => { -
{user?.email}
+
{user?.username}
Personal Settings diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 8a703b3dc..ee39d08b3 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -513,9 +513,9 @@ const OrganizationPage = withPermission( const orgUsers = await fetchOrgUsers(currentOrg); await addUsersToProject.mutateAsync({ - emails: orgUsers - .map((member) => member.user.email) - .filter((email) => email !== user.email), + usernames: orgUsers + .map((member) => member.user.username) + .filter((username) => username !== user.username), projectId: newProjectId }); } 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/LoginSSO.tsx b/frontend/src/views/Login/LoginSSO.tsx index 8140b872f..ea26ca7d6 100644 --- a/frontend/src/views/Login/LoginSSO.tsx +++ b/frontend/src/views/Login/LoginSSO.tsx @@ -15,7 +15,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => { const [password, setPassword] = useState(""); const { - email, + username, isUserCompleted } = jwt_decode(providerAuthToken) as any; @@ -35,7 +35,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => { return ( { 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..44f19b605 --- /dev/null +++ b/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx @@ -0,0 +1,137 @@ +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 + }); + + if (!nextUrl) { + createNotification({ + text: "Login unsuccessful. Double-check your credentials and try again.", + type: "error" + }); + + return; + } + + 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/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index c73376aba..65346533e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -113,8 +113,8 @@ export const OrgMembersSection = () => { /> handlePopUpToggle("removeMember", isOpen)} deleteKey="confirm" diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 3be945c2a..011f67de7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -41,7 +41,7 @@ type Props = { popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, data?: { orgMembershipId?: string; - email?: string; + username?: string; description?: string; } ) => void; @@ -143,6 +143,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop ({ user: u, inviteEmail }) => u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || + u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || inviteEmail?.includes(searchMemberFilter.toLowerCase()) ), @@ -162,7 +163,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop Name - Email + Username Role @@ -174,10 +175,11 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop ({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status }) => { const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-"; const email = u?.email || inviteEmail; + const username = u?.username ?? inviteEmail ?? "-"; return ( {name} - {email} + {username} )} - {(status === "invited" || status === "verified") && + {(status === "invited" || status === "verified") && email && serverDetails?.emailConfigured && ( -
- )} - )} @@ -375,7 +322,7 @@ export const MemberListTab = () => { className="ml-4" isDisabled={userId === u?.id || !isAllowed} onClick={() => - handlePopUpOpen("removeMember", { email: u.email }) + handlePopUpOpen("removeMember", { username: u.username }) } > @@ -407,20 +354,20 @@ export const MemberListTab = () => {
( - + diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index b0e34d507..312b676b7 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -1,10 +1,11 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import NavHeader from "@app/components/navigation/NavHeader"; import { PermissionDeniedBanner } from "@app/components/permissions"; import { ContentLoader } from "@app/components/v2"; @@ -47,9 +48,10 @@ const LOADER_TEXT = [ export const SecretMainPage = () => { const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); const router = useRouter(); const { permission } = useProjectPermission(); + const { createNotification } = useNotificationContext(); const [isVisible, setIsVisible] = useState(false); const [sortDir, setSortDir] = useState(SortDir.ASC); @@ -75,6 +77,20 @@ export const SecretMainPage = () => { ProjectPermissionSub.SecretRollback ); + useEffect(() => { + if ( + !isWorkspaceLoading && + !currentWorkspace?.environments.find((env) => env.slug === environment) && + router.isReady + ) { + router.push(`/project/${workspaceId}/secrets/overview`); + createNotification({ + text: "No envronment found with given slug", + type: "error" + }); + } + }, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]); + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); // fetch secrets 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..37bc87e2c --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx @@ -0,0 +1,229 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +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 LDAPFormSchema = z.object({ + url: z.string().min(1, "URL is requiredx"), + bindDN: z.string().min(1, "Bind DN is requiredx"), + bindPass: z.string().min(1, "Bind Pass is required"), + searchBase: z.string().min(1, "Search Base is required"), + caCert: z.string().optional() +}); + +export type TLDAPFormData = z.infer; + +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: zodResolver(LDAPFormSchema) + }) + + 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 + }: TLDAPFormData) => { + 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(); + }} + > + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +